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, ReftableWriteOptions,
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
23pub mod branch;
24pub mod migration;
25
26/// Match a refname component against Git's ref-filter wildcard rules.
27///
28/// `*` spans slashes, `?` matches one byte, bracket classes and backslash
29/// escapes are supported, and malformed bracket classes treat `[` literally.
30pub fn refname_pattern_matches_case(pattern: &str, name: &str, ignore_case: bool) -> bool {
31    fn matches_from(pattern: &[u8], name: &[u8]) -> bool {
32        match pattern {
33            [] => name.is_empty(),
34            [b'*', rest @ ..] => {
35                matches_from(rest, name) || (!name.is_empty() && matches_from(pattern, &name[1..]))
36            }
37            [b'?', rest @ ..] => !name.is_empty() && matches_from(rest, &name[1..]),
38            [b'\\', escaped, rest @ ..] => {
39                matches!(name, [first, ..] if first == escaped) && matches_from(rest, &name[1..])
40            }
41            [b'[', rest @ ..] => {
42                if let Some((matched, consumed)) =
43                    match_refname_pattern_class(rest, name.first().copied())
44                {
45                    !name.is_empty() && matched && matches_from(&rest[consumed..], &name[1..])
46                } else {
47                    matches!(name, [b'[', ..]) && matches_from(rest, &name[1..])
48                }
49            }
50            [literal, rest @ ..] => {
51                matches!(name, [first, ..] if first == literal) && matches_from(rest, &name[1..])
52            }
53        }
54    }
55
56    if ignore_case {
57        let pattern = pattern
58            .as_bytes()
59            .iter()
60            .map(u8::to_ascii_lowercase)
61            .collect::<Vec<_>>();
62        let name = name
63            .as_bytes()
64            .iter()
65            .map(u8::to_ascii_lowercase)
66            .collect::<Vec<_>>();
67        matches_from(&pattern, &name)
68    } else {
69        matches_from(pattern.as_bytes(), name.as_bytes())
70    }
71}
72
73fn match_refname_pattern_class(class: &[u8], name: Option<u8>) -> Option<(bool, usize)> {
74    let mut idx = 0;
75    let negated = matches!(class.first(), Some(b'!' | b'^'));
76    if negated {
77        idx += 1;
78    }
79
80    let mut matched = false;
81    let mut saw_member = false;
82    while idx < class.len() {
83        if class[idx] == b']' && saw_member {
84            return Some((if negated { !matched } else { matched }, idx + 1));
85        }
86        let start = class[idx];
87        if start == b'\\' && idx + 1 < class.len() {
88            idx += 1;
89        }
90        let start = class[idx];
91        saw_member = true;
92        if idx + 2 < class.len() && class[idx + 1] == b'-' && class[idx + 2] != b']' {
93            let mut end_idx = idx + 2;
94            if class[end_idx] == b'\\' && end_idx + 1 < class.len() {
95                end_idx += 1;
96            }
97            let end = class[end_idx];
98            if let Some(value) = name {
99                matched |= start <= value && value <= end;
100            }
101            idx = end_idx + 1;
102        } else {
103            matched |= name == Some(start);
104            idx += 1;
105        }
106    }
107    None
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum RefTarget {
112    Direct(ObjectId),
113    Symbolic(String),
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct Ref {
118    pub name: String,
119    pub target: RefTarget,
120}
121
122/// One entry in a transaction-oriented ref snapshot.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum RefReadSnapshotValue {
125    Target(RefTarget),
126    Broken,
127}
128
129/// A single-pass view of the ref namespace used while planning a transaction.
130///
131/// Unlike user-facing ref iteration, snapshot construction is silent and keeps
132/// broken loose refs as typed entries so callers can diagnose them without
133/// rescanning the filesystem or emitting iteration warnings.
134#[derive(Debug, Clone, Default)]
135pub struct RefReadSnapshot {
136    entries: BTreeMap<String, RefReadSnapshotValue>,
137}
138
139impl RefReadSnapshot {
140    #[must_use]
141    pub fn get(&self, name: &str) -> Option<&RefReadSnapshotValue> {
142        self.entries.get(name)
143    }
144}
145
146/// A backend-independent export used to move all live refs and reflogs as one
147/// logical operation.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct RefSnapshot {
150    pub refs: Vec<Ref>,
151    pub reflogs: Vec<(String, Vec<ReflogEntry>)>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct RefDelete {
156    pub name: String,
157    pub oid: ObjectId,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct DeleteRef {
162    pub name: String,
163    pub expected_old: Option<ObjectId>,
164    pub reflog: Option<DeleteRefReflog>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct DeleteRefReflog {
169    pub committer: Vec<u8>,
170    pub message: Vec<u8>,
171}
172
173#[derive(Debug)]
174pub enum RefDeleteError {
175    NotFound,
176    ExpectedMismatch {
177        expected: Option<ObjectId>,
178        actual: Option<ObjectId>,
179    },
180    Locked,
181    InvalidName,
182    Io(std::io::Error),
183}
184
185impl fmt::Display for RefDeleteError {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        match self {
188            Self::NotFound => f.write_str("ref not found"),
189            Self::ExpectedMismatch { expected, actual } => {
190                write!(
191                    f,
192                    "ref expected old oid mismatch: expected {:?}, actual {:?}",
193                    expected, actual
194                )
195            }
196            Self::Locked => f.write_str("ref is locked"),
197            Self::InvalidName => f.write_str("invalid ref name"),
198            Self::Io(err) => write!(f, "io error: {err}"),
199        }
200    }
201}
202
203impl std::error::Error for RefDeleteError {}
204
205impl From<std::io::Error> for RefDeleteError {
206    fn from(value: std::io::Error) -> Self {
207        Self::Io(value)
208    }
209}
210
211/// Parse the leading object id of a loose ref body, tolerating any trailing
212/// content that begins with whitespace (git's `parse_loose_ref_contents`).
213fn parse_leading_oid(format: ObjectFormat, value: &str) -> Option<ObjectId> {
214    let hexsz = format.hex_len();
215    let bytes = value.as_bytes();
216    if bytes.len() < hexsz {
217        return None;
218    }
219    if bytes.len() > hexsz && !bytes[hexsz].is_ascii_whitespace() {
220        return None;
221    }
222    ObjectId::from_hex(format, &value[..hexsz]).ok()
223}
224
225fn loose_ref_bytes_are_reftable_sentinel(name: &str, bytes: &[u8]) -> bool {
226    name == "refs/heads" && bytes == b"this repository uses the reftable format\n"
227}
228
229pub fn parse_loose_ref(format: ObjectFormat, name: impl Into<String>, bytes: &[u8]) -> Result<Ref> {
230    let name = name.into();
231    let value = std::str::from_utf8(bytes)
232        .map_err(|err| GitError::InvalidFormat(err.to_string()))?
233        .trim_end_matches('\n');
234    if name == "FETCH_HEAD" {
235        let oid = value
236            .lines()
237            .find_map(|line| line.split_whitespace().next())
238            .ok_or_else(|| GitError::InvalidFormat("FETCH_HEAD is empty".into()))?;
239        return Ok(Ref {
240            name,
241            target: RefTarget::Direct(ObjectId::from_hex(format, oid)?),
242        });
243    }
244    let target = if let Some(symbolic) = value.strip_prefix("ref: ") {
245        RefTarget::Symbolic(symbolic.to_string())
246    } else {
247        // git's parse_loose_ref_contents reads the leading <hexsz> hex digits
248        // and tolerates trailing content as long as it begins with whitespace
249        // (a bare "<oid> garbage" still resolves to <oid>; `refs verify` flags
250        // the trailing separately as trailingRefContent).
251        let oid = parse_leading_oid(format, value).ok_or_else(|| {
252            GitError::InvalidFormat(format!(
253                "reference {name} has neither a valid OID nor a target"
254            ))
255        })?;
256        RefTarget::Direct(oid)
257    };
258    Ok(Ref { name, target })
259}
260
261pub fn write_loose_ref(reference: &Ref) -> Vec<u8> {
262    match &reference.target {
263        RefTarget::Direct(oid) => format!("{oid}\n").into_bytes(),
264        RefTarget::Symbolic(target) => format!("ref: {target}\n").into_bytes(),
265    }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub struct PackedRef {
270    pub reference: Ref,
271    pub peeled: Option<ObjectId>,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum PackRefDecision {
276    Pack { peeled: Option<ObjectId> },
277    Skip,
278}
279
280pub fn parse_packed_refs(format: ObjectFormat, bytes: &[u8]) -> Result<Vec<PackedRef>> {
281    parse_packed_refs_filtered(format, bytes, |_| true)
282}
283
284fn parse_packed_refs_with_prefix(
285    format: ObjectFormat,
286    bytes: &[u8],
287    prefix: &str,
288) -> Result<Vec<PackedRef>> {
289    if !bytes.is_empty() && !bytes.ends_with(b"\n") {
290        let line_start = bytes
291            .iter()
292            .rposition(|byte| *byte == b'\n')
293            .map_or(0, |index| index + 1);
294        let line = String::from_utf8_lossy(&bytes[line_start..]);
295        return Err(GitError::InvalidFormat(format!(
296            "fatal: unterminated line in .git/packed-refs: {line}"
297        )));
298    }
299    let text =
300        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
301    let mut refs: Vec<PackedRef> = Vec::new();
302    let mut saw_ref = false;
303    let mut included_last_ref = false;
304    for raw_line in text.lines() {
305        let line = raw_line.trim_end();
306        if line.is_empty() || line.starts_with('#') {
307            continue;
308        }
309        if let Some(peeled) = line.strip_prefix('^') {
310            if !saw_ref {
311                return Err(GitError::InvalidFormat(
312                    "peeled packed ref without preceding ref".into(),
313                ));
314            }
315            if included_last_ref {
316                let oid = ObjectId::from_hex(format, peeled)?;
317                if let Some(last) = refs.last_mut() {
318                    last.peeled = Some(oid);
319                }
320            }
321            continue;
322        }
323        let (oid, name) = line
324            .split_once(' ')
325            .ok_or_else(|| packed_refs_unexpected_line(line))?;
326        // git validates EVERY packed-refs line as it reads the file, regardless
327        // of whether the ref matches the caller's prefix; a malformed line
328        // anywhere aborts with "unexpected line in .git/packed-refs" (t1463
329        // "reject packed-refs containing junk"). So validate before applying the
330        // prefix filter — skipping validation for non-matching lines would let
331        // junk pass silently.
332        if oid.len() != format.hex_len()
333            || !oid.bytes().all(|byte| byte.is_ascii_hexdigit())
334            || validate_ref_name_for_read(name).is_err()
335        {
336            return Err(packed_refs_unexpected_line(line));
337        }
338        saw_ref = true;
339        included_last_ref = name.starts_with(prefix);
340        if !included_last_ref {
341            continue;
342        }
343        let oid = ObjectId::from_hex(format, oid)?;
344        refs.push(PackedRef {
345            reference: Ref {
346                name: name.into(),
347                target: RefTarget::Direct(oid),
348            },
349            peeled: None,
350        });
351    }
352    Ok(refs)
353}
354
355fn parse_packed_refs_filtered(
356    format: ObjectFormat,
357    bytes: &[u8],
358    mut include: impl FnMut(&str) -> bool,
359) -> Result<Vec<PackedRef>> {
360    if !bytes.is_empty() && !bytes.ends_with(b"\n") {
361        let line_start = bytes
362            .iter()
363            .rposition(|byte| *byte == b'\n')
364            .map_or(0, |index| index + 1);
365        let line = String::from_utf8_lossy(&bytes[line_start..]);
366        return Err(GitError::InvalidFormat(format!(
367            "fatal: unterminated line in .git/packed-refs: {line}"
368        )));
369    }
370    let text =
371        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
372    let mut refs: Vec<PackedRef> = Vec::new();
373    let mut saw_ref = false;
374    let mut included_last_ref = false;
375    for raw_line in text.lines() {
376        let line = raw_line.trim_end();
377        if line.is_empty() || line.starts_with('#') {
378            continue;
379        }
380        if let Some(peeled) = line.strip_prefix('^') {
381            let oid = ObjectId::from_hex(format, peeled)?;
382            if !saw_ref {
383                return Err(GitError::InvalidFormat(
384                    "peeled packed ref without preceding ref".into(),
385                ));
386            }
387            if included_last_ref && let Some(last) = refs.last_mut() {
388                last.peeled = Some(oid);
389            }
390            continue;
391        }
392        let (oid, name) = line
393            .split_once(' ')
394            .ok_or_else(|| packed_refs_unexpected_line(line))?;
395        if oid.len() != format.hex_len()
396            || !oid.bytes().all(|byte| byte.is_ascii_hexdigit())
397            || validate_ref_name_for_read(name).is_err()
398        {
399            return Err(packed_refs_unexpected_line(line));
400        }
401        let oid = ObjectId::from_hex(format, oid)?;
402        saw_ref = true;
403        included_last_ref = include(name);
404        if included_last_ref {
405            refs.push(PackedRef {
406                reference: Ref {
407                    name: name.into(),
408                    target: RefTarget::Direct(oid),
409                },
410                peeled: None,
411            });
412        }
413    }
414    Ok(refs)
415}
416
417fn packed_ref_names_with_prefix(
418    format: ObjectFormat,
419    bytes: &[u8],
420    prefix: &str,
421    strip_prefix: bool,
422    names: &mut Vec<String>,
423) -> Result<()> {
424    if !bytes.is_empty() && !bytes.ends_with(b"\n") {
425        let line_start = bytes
426            .iter()
427            .rposition(|byte| *byte == b'\n')
428            .map_or(0, |index| index + 1);
429        let line = String::from_utf8_lossy(&bytes[line_start..]);
430        return Err(GitError::InvalidFormat(format!(
431            "fatal: unterminated line in .git/packed-refs: {line}"
432        )));
433    }
434    let text =
435        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
436    let mut saw_ref = false;
437    for raw_line in text.lines() {
438        let line = raw_line.trim_end();
439        if line.is_empty() || line.starts_with('#') {
440            continue;
441        }
442        if line.starts_with('^') {
443            if !saw_ref {
444                return Err(GitError::InvalidFormat(
445                    "peeled packed ref without preceding ref".into(),
446                ));
447            }
448            continue;
449        }
450        let (oid, name) = line
451            .split_once(' ')
452            .ok_or_else(|| packed_refs_unexpected_line(line))?;
453        // Validate every line before the prefix filter (see
454        // `parse_packed_refs_with_prefix`): git rejects a malformed packed-refs
455        // file even when the junk line is outside the requested prefix.
456        if oid.len() != format.hex_len()
457            || !oid.bytes().all(|byte| byte.is_ascii_hexdigit())
458            || validate_ref_name_for_read(name).is_err()
459        {
460            return Err(packed_refs_unexpected_line(line));
461        }
462        saw_ref = true;
463        if !name.starts_with(prefix) {
464            continue;
465        }
466        let name = if strip_prefix {
467            &name[prefix.len()..]
468        } else {
469            name
470        };
471        names.push(name.to_owned());
472    }
473    Ok(())
474}
475
476fn packed_refs_unexpected_line(line: &str) -> GitError {
477    GitError::InvalidFormat(format!(
478        "fatal: unexpected line in .git/packed-refs: {line}"
479    ))
480}
481
482fn packed_refs_have_prefix(format: ObjectFormat, bytes: &[u8], prefix: &str) -> Result<bool> {
483    if !bytes.is_empty() && !bytes.ends_with(b"\n") {
484        let line_start = bytes
485            .iter()
486            .rposition(|byte| *byte == b'\n')
487            .map_or(0, |index| index + 1);
488        let line = String::from_utf8_lossy(&bytes[line_start..]);
489        return Err(GitError::InvalidFormat(format!(
490            "fatal: unterminated line in .git/packed-refs: {line}"
491        )));
492    }
493    let text =
494        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
495    let mut found = false;
496    let mut saw_ref = false;
497    for raw_line in text.lines() {
498        let line = raw_line.trim_end();
499        if line.is_empty() || line.starts_with('#') {
500            continue;
501        }
502        if let Some(peeled) = line.strip_prefix('^') {
503            ObjectId::from_hex(format, peeled)?;
504            if !saw_ref {
505                return Err(GitError::InvalidFormat(
506                    "peeled packed ref without preceding ref".into(),
507                ));
508            }
509            continue;
510        }
511        let (oid, name) = line
512            .split_once(' ')
513            .ok_or_else(|| packed_refs_unexpected_line(line))?;
514        if oid.len() != format.hex_len()
515            || !oid.bytes().all(|byte| byte.is_ascii_hexdigit())
516            || validate_ref_name_for_read(name).is_err()
517        {
518            return Err(packed_refs_unexpected_line(line));
519        }
520        ObjectId::from_hex(format, oid)?;
521        saw_ref = true;
522        found |= name.starts_with(prefix);
523    }
524    Ok(found)
525}
526
527pub fn write_packed_refs(refs: &[PackedRef]) -> Result<Vec<u8>> {
528    let mut refs = refs.to_vec();
529    refs.sort_by(|left, right| left.reference.name.cmp(&right.reference.name));
530    let mut out = b"# pack-refs with: peeled fully-peeled sorted \n".to_vec();
531    for packed in refs {
532        validate_ref_name(&packed.reference.name)?;
533        let RefTarget::Direct(oid) = &packed.reference.target else {
534            return Err(GitError::InvalidFormat(format!(
535                "packed ref {} is symbolic",
536                packed.reference.name
537            )));
538        };
539        out.extend_from_slice(oid.to_hex().as_bytes());
540        out.push(b' ');
541        out.extend_from_slice(packed.reference.name.as_bytes());
542        out.push(b'\n');
543        if let Some(peeled) = packed.peeled {
544            out.push(b'^');
545            out.extend_from_slice(peeled.to_hex().as_bytes());
546            out.push(b'\n');
547        }
548    }
549    Ok(out)
550}
551
552#[derive(Debug, Clone, PartialEq, Eq)]
553pub struct ReflogEntry {
554    pub old_oid: ObjectId,
555    pub new_oid: ObjectId,
556    pub committer: Vec<u8>,
557    pub message: Vec<u8>,
558}
559
560impl ReflogEntry {
561    pub fn to_line(&self) -> Vec<u8> {
562        let mut out = Vec::new();
563        out.extend_from_slice(self.old_oid.to_hex().as_bytes());
564        out.push(b' ');
565        out.extend_from_slice(self.new_oid.to_hex().as_bytes());
566        out.push(b' ');
567        out.extend_from_slice(&self.committer);
568        if !self.message.is_empty() {
569            out.push(b'\t');
570            out.extend_from_slice(&self.message);
571        }
572        out.push(b'\n');
573        out
574    }
575
576    pub fn timestamp_seconds(&self) -> Result<i64> {
577        let committer = std::str::from_utf8(&self.committer)
578            .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
579        let Some((before_tz, _tz)) = committer.rsplit_once(' ') else {
580            return Err(GitError::InvalidFormat(
581                "reflog committer is missing timezone".into(),
582            ));
583        };
584        let Some((_identity, timestamp)) = before_tz.rsplit_once(' ') else {
585            return Err(GitError::InvalidFormat(
586                "reflog committer is missing timestamp".into(),
587            ));
588        };
589        timestamp
590            .parse::<i64>()
591            .map_err(|err| GitError::InvalidFormat(err.to_string()))
592    }
593}
594
595pub fn parse_reflog(format: ObjectFormat, bytes: &[u8]) -> Result<Vec<ReflogEntry>> {
596    let text =
597        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
598    let mut entries = Vec::new();
599    for line in text.lines() {
600        let mut parts = line.splitn(3, ' ');
601        let old = parts
602            .next()
603            .ok_or_else(|| GitError::InvalidFormat("missing reflog old oid".into()))?;
604        let new = parts
605            .next()
606            .ok_or_else(|| GitError::InvalidFormat("missing reflog new oid".into()))?;
607        let rest = parts
608            .next()
609            .ok_or_else(|| GitError::InvalidFormat("missing reflog committer".into()))?;
610        let (committer, message) = rest.split_once('\t').unwrap_or((rest, ""));
611        entries.push(ReflogEntry {
612            old_oid: ObjectId::from_hex(format, old)?,
613            new_oid: ObjectId::from_hex(format, new)?,
614            committer: committer.as_bytes().to_vec(),
615            message: message.as_bytes().to_vec(),
616        });
617    }
618    Ok(entries)
619}
620
621/// Expire reflog entries, mirroring `git reflog expire` semantics.
622///
623/// Entries are kept when their committer timestamp is at or after `cutoff_unix`.
624/// Entries whose `new_oid` is unreachable (per `is_reachable`) are held to the
625/// stricter `expire_unreachable_cutoff` when one is supplied: such an entry is
626/// dropped when its timestamp falls below either cutoff. When
627/// `expire_unreachable_cutoff` is `None`, reachability does not relax the single
628/// `cutoff_unix` bound.
629///
630/// The most recent entry (the one describing the ref's current value) is always
631/// preserved, exactly as git refuses to expire the tip of a reflog, even when it
632/// is older than the cutoff. Relative order of the surviving entries is kept.
633///
634/// This is a pure function over already-parsed entries so callers can read,
635/// filter, and rewrite reflogs however they like; see
636/// [`FileRefStore::expire_reflog_file`] for a filesystem convenience built on top
637/// of it.
638pub fn expire_reflog(
639    entries: &[ReflogEntry],
640    cutoff_unix: i64,
641    expire_unreachable_cutoff: Option<i64>,
642    is_reachable: impl Fn(&ObjectId) -> bool,
643) -> Result<Vec<ReflogEntry>> {
644    let last_index = entries.len().checked_sub(1);
645    let mut retained = Vec::with_capacity(entries.len());
646    for (index, entry) in entries.iter().enumerate() {
647        // Always keep the most recent entry: it records the current ref value
648        // and git never expires it.
649        if Some(index) == last_index {
650            retained.push(entry.clone());
651            continue;
652        }
653        let timestamp = entry.timestamp_seconds()?;
654        let mut expired = timestamp < cutoff_unix;
655        if let Some(unreachable_cutoff) = expire_unreachable_cutoff
656            && !is_reachable(&entry.new_oid)
657        {
658            expired = expired || timestamp < unreachable_cutoff;
659        }
660        if !expired {
661            retained.push(entry.clone());
662        }
663    }
664    Ok(retained)
665}
666
667#[derive(Debug, Default, Clone)]
668pub struct RefStore {
669    refs: HashMap<String, RefTarget>,
670    reflogs: BTreeMap<String, Vec<ReflogEntry>>,
671}
672
673impl RefStore {
674    pub fn new() -> Self {
675        Self::default()
676    }
677
678    pub fn get(&self, name: &str) -> Option<&RefTarget> {
679        self.refs.get(name)
680    }
681
682    pub fn transaction(&mut self) -> RefTransaction<'_> {
683        RefTransaction {
684            store: self,
685            updates: Vec::new(),
686        }
687    }
688
689    pub fn reflog(&self, name: &str) -> &[ReflogEntry] {
690        self.reflogs
691            .get(name)
692            .map(Vec::as_slice)
693            .unwrap_or_default()
694    }
695}
696
697#[derive(Debug)]
698pub struct RefUpdate {
699    pub name: String,
700    pub expected: Option<RefTarget>,
701    pub new: RefTarget,
702    pub reflog: Option<ReflogEntry>,
703}
704
705/// The compare-and-swap precondition a ref update is checked against (re-verified
706/// while the ref is locked, so it is a true CAS, not a check-then-write).
707///
708/// [`RefUpdate::expected`] can express [`Any`](RefPrecondition::Any) (`None`) and
709/// [`MustExistAndMatch`](RefPrecondition::MustExistAndMatch) (`Some`); the
710/// create-only and match-or-create modes are reachable via
711/// [`FileRefTransaction::update_to`].
712#[derive(Debug, Clone, PartialEq, Eq)]
713pub enum RefPrecondition {
714    /// No precondition: create or overwrite unconditionally.
715    Any,
716    /// The ref must currently exist (with any value).
717    MustExist,
718    /// The ref must currently not exist (create-only).
719    MustNotExist,
720    /// The ref must currently exist and point exactly at this target.
721    MustExistAndMatch(RefTarget),
722    /// If the ref exists it must point exactly at this target; if it is absent,
723    /// the update is still allowed (match-or-create).
724    ExistingMustMatch(RefTarget),
725}
726
727impl RefPrecondition {
728    /// The precondition implied by a [`RefUpdate::expected`] value.
729    fn from_expected(expected: Option<RefTarget>) -> Self {
730        match expected {
731            None => Self::Any,
732            Some(target) => Self::MustExistAndMatch(target),
733        }
734    }
735
736    /// Whether `current` — the ref's value right now, or `None` if absent —
737    /// satisfies this precondition.
738    fn is_satisfied_by(&self, current: Option<&RefTarget>) -> bool {
739        match self {
740            Self::Any => true,
741            Self::MustExist => current.is_some(),
742            Self::MustNotExist => current.is_none(),
743            Self::MustExistAndMatch(target) => current == Some(target),
744            Self::ExistingMustMatch(target) => match current {
745                None => true,
746                Some(current) => current == target,
747            },
748        }
749    }
750
751    /// A human-readable description of an unmet precondition, for errors.
752    fn describe(&self, name: &str) -> String {
753        match self {
754            Self::Any => format!("ref {name} precondition not met"),
755            Self::MustExist => format!("expected ref {name} to exist"),
756            Self::MustNotExist => format!("expected ref {name} to not already exist"),
757            Self::MustExistAndMatch(_) => format!("expected ref {name} to match"),
758            Self::ExistingMustMatch(_) => {
759                format!("expected ref {name} to match its current value")
760            }
761        }
762    }
763}
764
765pub struct RefTransaction<'a> {
766    store: &'a mut RefStore,
767    updates: Vec<RefUpdate>,
768}
769
770impl<'a> RefTransaction<'a> {
771    pub fn update(&mut self, update: RefUpdate) {
772        self.updates.push(update);
773    }
774
775    pub fn commit(self) -> Result<()> {
776        for update in &self.updates {
777            if let Some(expected) = &update.expected
778                && self.store.refs.get(&update.name) != Some(expected)
779            {
780                return Err(GitError::Transaction(format!(
781                    "expected ref {} to match",
782                    update.name
783                )));
784            }
785        }
786        for update in self.updates {
787            self.store.refs.insert(update.name.clone(), update.new);
788            if let Some(entry) = update.reflog {
789                self.store
790                    .reflogs
791                    .entry(update.name)
792                    .or_default()
793                    .push(entry);
794            }
795        }
796        Ok(())
797    }
798}
799
800#[derive(Debug, Clone)]
801pub struct FileRefStore {
802    git_dir: PathBuf,
803    common_dir: PathBuf,
804    storage_dir: PathBuf,
805    format: ObjectFormat,
806    ref_storage: RefStorageConfiguration,
807    packed_refs_lock_timeout_millis: u64,
808    reftable_lock_timeout_millis: Option<u64>,
809    reftable_write_options: ReftableWriteOptions,
810    combine_reftable_logs: bool,
811    prefer_symlink_refs: bool,
812    reference_fsync: ReferenceFsyncPolicy,
813    shared_repository: sley_formats::SharedRepositoryPermissions,
814}
815
816#[derive(Debug, Clone)]
817struct RefStorageConfiguration {
818    backend: Option<(RefBackendKind, Option<PathBuf>)>,
819    invalid_value: Option<String>,
820}
821
822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
823enum ReferenceFsyncMethod {
824    Fsync,
825    WriteoutOnly,
826    Batch,
827}
828
829impl ReferenceFsyncMethod {
830    const fn platform_default() -> Self {
831        #[cfg(target_os = "windows")]
832        {
833            Self::Batch
834        }
835        #[cfg(all(not(target_os = "windows"), target_os = "macos"))]
836        {
837            Self::WriteoutOnly
838        }
839        #[cfg(not(any(target_os = "windows", target_os = "macos")))]
840        {
841            Self::Fsync
842        }
843    }
844
845    fn from_config(value: Option<&str>) -> Self {
846        match value {
847            Some("fsync") => Self::Fsync,
848            Some("writeout-only") => Self::WriteoutOnly,
849            Some("batch") => Self::Batch,
850            _ => Self::platform_default(),
851        }
852    }
853}
854
855#[derive(Debug, Clone, Copy, PartialEq, Eq)]
856struct ReferenceFsyncPolicy {
857    enabled: bool,
858    method: ReferenceFsyncMethod,
859}
860
861impl ReferenceFsyncPolicy {
862    fn from_config(core_fsync: Option<&str>, core_fsync_method: Option<&str>) -> Self {
863        let enabled =
864            core_fsync.is_some_and(core_fsync_includes_reference) && git_test_fsync_enabled();
865        Self {
866            enabled,
867            method: ReferenceFsyncMethod::from_config(core_fsync_method),
868        }
869    }
870
871    fn method_if_enabled(self) -> Option<ReferenceFsyncMethod> {
872        self.enabled.then_some(self.method)
873    }
874}
875
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct BranchCreate {
878    pub name: String,
879    pub oid: ObjectId,
880}
881
882#[derive(Debug, Clone, PartialEq, Eq)]
883pub struct BranchDelete {
884    pub name: String,
885    pub oid: ObjectId,
886}
887
888#[derive(Debug, Clone, PartialEq, Eq)]
889pub struct TagCreate {
890    pub name: String,
891    pub oid: ObjectId,
892}
893
894#[derive(Debug, Clone, PartialEq, Eq)]
895pub struct TagDelete {
896    pub name: String,
897    pub oid: ObjectId,
898}
899
900#[derive(Debug, Clone, PartialEq, Eq)]
901pub struct BundleRefUpdate {
902    pub name: String,
903    pub oid: ObjectId,
904}
905
906#[derive(Debug, Clone, PartialEq, Eq)]
907pub struct BundleRefUpdateReflog {
908    pub committer: Vec<u8>,
909    pub message: Vec<u8>,
910}
911
912#[derive(Debug, Clone, PartialEq, Eq)]
913pub struct AppliedBundleRefUpdate {
914    pub name: String,
915    pub old_oid: Option<ObjectId>,
916    pub new_oid: ObjectId,
917}
918
919fn configured_ref_storage_backend(common_dir: &Path, honor_env: bool) -> RefStorageConfiguration {
920    if honor_env && let Ok(value) = env::var("GIT_REFERENCE_BACKEND") {
921        return match parse_ref_storage_backend_value(&value) {
922            Ok((kind, path)) => RefStorageConfiguration {
923                backend: Some((
924                    kind,
925                    path.map(|path| ref_storage_path_from_config(common_dir, path)),
926                )),
927                invalid_value: None,
928            },
929            Err(_) => RefStorageConfiguration {
930                backend: None,
931                invalid_value: Some(value),
932            },
933        };
934    }
935    let value = GitConfig::read(common_dir.join("config"))
936        .ok()
937        .and_then(|config| {
938            config
939                .get("extensions", None, "refStorage")
940                .map(str::to_string)
941        });
942    let Some(value) = value else {
943        return RefStorageConfiguration {
944            backend: None,
945            invalid_value: None,
946        };
947    };
948    match parse_ref_storage_backend_value(&value) {
949        Ok((kind, path)) => RefStorageConfiguration {
950            backend: Some((
951                kind,
952                path.map(|path| ref_storage_path_from_config(common_dir, path)),
953            )),
954            invalid_value: None,
955        },
956        Err(_) => RefStorageConfiguration {
957            backend: None,
958            invalid_value: Some(value),
959        },
960    }
961}
962
963fn configured_reference_fsync(common_dir: &Path) -> ReferenceFsyncPolicy {
964    let context = sley_config::ConfigIncludeContext::new(
965        Some(sley_config::git_dir_for_include_context(common_dir)),
966        sley_config::repo_current_branch_name(common_dir),
967    );
968    let config = sley_config::load_effective_config(common_dir, &context).ok();
969    ReferenceFsyncPolicy::from_config(
970        config
971            .as_ref()
972            .and_then(|config| config.get("core", None, "fsync")),
973        config
974            .as_ref()
975            .and_then(|config| config.get("core", None, "fsyncMethod")),
976    )
977}
978
979fn core_fsync_includes_reference(value: &str) -> bool {
980    const COMPONENTS: [(&str, bool); 11] = [
981        ("loose-object", false),
982        ("pack", false),
983        ("pack-metadata", false),
984        ("commit-graph", false),
985        ("index", false),
986        ("objects", false),
987        ("reference", true),
988        ("derived-metadata", false),
989        ("committed", true),
990        ("added", true),
991        ("all", true),
992    ];
993    let mut positive = false;
994    let mut negative = false;
995    for raw_component in value.split(',') {
996        let component = raw_component.trim();
997        if component == "none" || component.is_empty() {
998            continue;
999        }
1000        let (negated, component) = component
1001            .strip_prefix('-')
1002            .map_or((false, component), |component| (true, component));
1003        if component.is_empty() {
1004            continue;
1005        }
1006        let matches_reference = COMPONENTS
1007            .iter()
1008            .any(|(name, includes_reference)| *includes_reference && name.starts_with(component));
1009        if matches_reference {
1010            if negated {
1011                negative = true;
1012            } else {
1013                positive = true;
1014            }
1015        }
1016    }
1017    // Git starts from FSYNC_COMPONENTS_DEFAULT (which excludes references),
1018    // then applies accumulated negatives and positives. Positives win when the
1019    // same component appears in both sets.
1020    (platform_default_includes_reference_fsync() && !negative) || positive
1021}
1022
1023fn platform_default_includes_reference_fsync() -> bool {
1024    // FSYNC_COMPONENTS_DEFAULT excludes reference files on every platform in
1025    // Git v2.55. Keep this as a function because Git exposes the default as a
1026    // platform-overridable compile-time policy.
1027    false
1028}
1029
1030fn git_test_fsync_enabled() -> bool {
1031    let Ok(value) = env::var("GIT_TEST_FSYNC") else {
1032        return true;
1033    };
1034    !matches!(
1035        value.to_ascii_lowercase().as_str(),
1036        "0" | "false" | "no" | "off" | ""
1037    )
1038}
1039
1040fn ref_storage_path_from_config(common_dir: &Path, path: PathBuf) -> PathBuf {
1041    if path.is_absolute() {
1042        path
1043    } else {
1044        common_dir.join(path)
1045    }
1046}
1047
1048fn parse_ref_storage_backend_value(value: &str) -> Result<(RefBackendKind, Option<PathBuf>)> {
1049    let (backend, path) = if let Some((backend, path)) = value.split_once("://") {
1050        if path.is_empty() {
1051            return Err(GitError::InvalidFormat(format!(
1052                "invalid value for 'extensions.refstorage': '{value}'"
1053            )));
1054        }
1055        (backend, Some(PathBuf::from(path)))
1056    } else {
1057        (value, None)
1058    };
1059    let kind = match backend {
1060        "files" => RefBackendKind::Files,
1061        "reftable" => RefBackendKind::Reftable,
1062        _ => {
1063            return Err(GitError::InvalidFormat(format!(
1064                "invalid value for 'extensions.refstorage': '{value}'"
1065            )));
1066        }
1067    };
1068    Ok((kind, path))
1069}
1070
1071#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072enum RefBackendKind {
1073    Files,
1074    Reftable,
1075}
1076
1077impl FileRefStore {
1078    pub fn new(git_dir: impl Into<PathBuf>, format: ObjectFormat) -> Self {
1079        Self::new_with_reference_backend_env(git_dir, format, true)
1080    }
1081
1082    pub fn new_without_reference_backend_env(
1083        git_dir: impl Into<PathBuf>,
1084        format: ObjectFormat,
1085    ) -> Self {
1086        Self::new_with_reference_backend_env(git_dir, format, false)
1087    }
1088
1089    /// Git directory this store was opened for.
1090    #[must_use]
1091    pub fn git_dir(&self) -> &Path {
1092        &self.git_dir
1093    }
1094
1095    /// Object format used by reference targets and reflogs.
1096    #[must_use]
1097    pub const fn object_format(&self) -> ObjectFormat {
1098        self.format
1099    }
1100
1101    fn new_with_reference_backend_env(
1102        git_dir: impl Into<PathBuf>,
1103        format: ObjectFormat,
1104        honor_reference_backend_env: bool,
1105    ) -> Self {
1106        let git_dir = git_dir.into();
1107        let common_dir = repository_common_dir(&git_dir);
1108        let configured = configured_ref_storage_backend(&common_dir, honor_reference_backend_env);
1109        let reference_fsync = configured_reference_fsync(&common_dir);
1110        let shared_repository =
1111            sley_formats::SharedRepositoryPermissions::from_git_dir(&common_dir);
1112        let prefer_symlink_refs = GitConfig::read(common_dir.join("config"))
1113            .ok()
1114            .and_then(|config| config.get_bool("core", None, "preferSymlinkRefs"))
1115            .unwrap_or(false);
1116        let storage_dir = match configured.backend.as_ref() {
1117            Some((_, Some(path))) => path.clone(),
1118            Some((RefBackendKind::Reftable, None)) if git_dir != common_dir => git_dir.clone(),
1119            _ => common_dir.clone(),
1120        };
1121        Self {
1122            git_dir,
1123            common_dir,
1124            storage_dir,
1125            format,
1126            ref_storage: configured,
1127            packed_refs_lock_timeout_millis: 1_000,
1128            reftable_lock_timeout_millis: None,
1129            reftable_write_options: ReftableWriteOptions::default(),
1130            combine_reftable_logs: false,
1131            prefer_symlink_refs,
1132            reference_fsync,
1133            shared_repository,
1134        }
1135    }
1136
1137    pub fn with_reftable_lock_timeout_millis(mut self, timeout_millis: Option<u64>) -> Self {
1138        self.reftable_lock_timeout_millis = timeout_millis;
1139        self
1140    }
1141
1142    /// Override how long files-backend transactions retry `packed-refs.lock`.
1143    ///
1144    /// Git defaults this to one second and lets `core.packedRefsTimeout`
1145    /// override it. Keeping the timeout on the store makes the transaction
1146    /// responsible for lock ordering and prevents a loose ref from disappearing
1147    /// while its stale packed value is still visible.
1148    #[must_use]
1149    pub fn with_packed_refs_lock_timeout_millis(mut self, timeout_millis: u64) -> Self {
1150        self.packed_refs_lock_timeout_millis = timeout_millis;
1151        self
1152    }
1153
1154    /// Override the writer settings used for newly appended or compacted
1155    /// reftable tables.
1156    pub fn with_reftable_write_options(mut self, options: ReftableWriteOptions) -> Self {
1157        self.reftable_write_options = options;
1158        self
1159    }
1160
1161    pub fn with_reftable_combined_logs(mut self, combine: bool) -> Self {
1162        self.combine_reftable_logs = combine;
1163        self
1164    }
1165
1166    /// Whether files-backend symbolic refs should use legacy filesystem
1167    /// symlinks instead of textual `ref: ...` files.
1168    #[must_use]
1169    pub const fn prefers_symlink_refs(&self) -> bool {
1170        self.prefer_symlink_refs
1171    }
1172
1173    /// Override the effective `core.preferSymlinkRefs` value for callers that
1174    /// carry command-line config outside the repository config file.
1175    #[must_use]
1176    pub fn with_prefer_symlink_refs(mut self, prefer: bool) -> Self {
1177        self.prefer_symlink_refs = prefer;
1178        self
1179    }
1180
1181    /// Override the effective `core.fsync` settings for reference writes.
1182    ///
1183    /// The CLI uses this for command-line `-c` values, which live outside the
1184    /// process environment. Passing `None` for either value retains Git's
1185    /// platform default for that setting.
1186    pub fn with_reference_fsync_config(
1187        mut self,
1188        core_fsync: Option<&str>,
1189        core_fsync_method: Option<&str>,
1190    ) -> Self {
1191        if core_fsync.is_some() || core_fsync_method.is_some() {
1192            let enabled = core_fsync
1193                .map(core_fsync_includes_reference)
1194                .unwrap_or(self.reference_fsync.enabled)
1195                && git_test_fsync_enabled();
1196            let method = core_fsync_method.map_or(self.reference_fsync.method, |value| {
1197                ReferenceFsyncMethod::from_config(Some(value))
1198            });
1199            self.reference_fsync = ReferenceFsyncPolicy { enabled, method };
1200        }
1201        self
1202    }
1203
1204    pub fn read_ref(&self, name: &str) -> Result<Option<RefTarget>> {
1205        validate_ref_name_for_read(name)?;
1206        self.read_ref_unchecked(name)
1207    }
1208
1209    fn read_ref_unchecked(&self, name: &str) -> Result<Option<RefTarget>> {
1210        if self.uses_reftable()? {
1211            let (store, name) = self.reftable_store_for_ref(name)?;
1212            if let Some(target) = store.read_reftable_ref(&name)? {
1213                return Ok(Some(target));
1214            }
1215            if name != "HEAD" && is_root_ref_syntax(&name) {
1216                return Ok(self
1217                    .read_loose_ref(&name)?
1218                    .map(|reference| reference.target));
1219            }
1220            return Ok(None);
1221        }
1222        if let Some(reference) = self.read_loose_ref(name)? {
1223            return Ok(Some(reference.target));
1224        }
1225        if let Some(reference) = self.read_packed_ref(name)? {
1226            return Ok(Some(reference.reference.target));
1227        }
1228        Ok(None)
1229    }
1230
1231    /// Raw existence check matching git's `refs_read_raw_ref` (builtin/refs.c
1232    /// cmd_refs_exists). A ref "exists" if its loose file is present (regardless
1233    /// of contents — dangling symrefs, bad object ids, and refs written with a
1234    /// bad name all count) or if it is recorded in packed-refs / the reftable.
1235    /// Unlike [`Self::read_ref`], no name validation is performed and the object the
1236    /// ref points at is never read. Returns:
1237    ///   * `Ok(true)`  — the raw ref exists.
1238    ///   * `Ok(false)` — ENOENT or EISDIR (a bare directory where the ref would
1239    ///     live and no packed entry); git maps both to exit code 2.
1240    pub fn raw_ref_exists(&self, name: &str) -> Result<bool> {
1241        if self.uses_reftable()? {
1242            let (store, name) = self.reftable_store_for_ref(name)?;
1243            if store.read_reftable_ref(&name)?.is_some() {
1244                return Ok(true);
1245            }
1246            if name != "HEAD" && is_root_ref_syntax(&name) {
1247                return Ok(self.read_loose_ref(&name)?.is_some());
1248            }
1249            return Ok(false);
1250        }
1251        // git routes root-ref-syntax names (HEAD, FETCH_HEAD, MERGE_HEAD, …) and
1252        // per-worktree namespaces to the per-worktree gitdir; mirror
1253        // files_ref_path's REF_WORKTREE_CURRENT vs REF_WORKTREE_SHARED split.
1254        let base = if current_worktree_ref(name) {
1255            self.current_worktree_storage_dir()
1256        } else {
1257            self.storage_dir.clone()
1258        };
1259        let path = base.join(name);
1260        match fs::symlink_metadata(&path) {
1261            Ok(meta) if meta.is_dir() => {
1262                // A directory at the loose path is EISDIR unless packed-refs
1263                // still carries the name.
1264                Ok(self.read_packed_ref(name)?.is_some())
1265            }
1266            Ok(_) => Ok(true),
1267            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1268                Ok(self.read_packed_ref(name)?.is_some())
1269            }
1270            Err(err) => Err(err.into()),
1271        }
1272    }
1273
1274    pub fn read_reflog(&self, name: &str) -> Result<Vec<ReflogEntry>> {
1275        validate_ref_name_for_read(name)?;
1276        if self.uses_reftable()? {
1277            return self.read_reftable_logs(name);
1278        }
1279        let path = self.reflog_path(name);
1280        if !path.exists() {
1281            return Ok(Vec::new());
1282        }
1283        parse_reflog(self.format, &fs::read(path)?)
1284    }
1285
1286    /// Read the well-formed entries usable by reflog maintenance.
1287    ///
1288    /// Files-backend enumeration can encounter hand-written or truncated log
1289    /// lines. Git's expiry walk skips those records rather than aborting
1290    /// `reflog expire --all`, while direct reflog reads retain strict parsing.
1291    pub fn read_reflog_for_expiry(&self, name: &str) -> Result<Vec<ReflogEntry>> {
1292        validate_ref_name_for_read(name)?;
1293        if self.uses_reftable()? {
1294            return self.read_reftable_logs(name);
1295        }
1296        let path = self.reflog_path(name);
1297        let bytes = match fs::read(path) {
1298            Ok(bytes) => bytes,
1299            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1300            Err(err) => return Err(err.into()),
1301        };
1302        let mut entries = Vec::new();
1303        for line in bytes.split_inclusive(|byte| *byte == b'\n') {
1304            if let Ok(mut parsed) = parse_reflog(self.format, line) {
1305                entries.append(&mut parsed);
1306            }
1307        }
1308        Ok(entries)
1309    }
1310
1311    /// Reapply `core.sharedRepository` to an existing reflog even when expiry
1312    /// retained every entry and therefore did not rewrite the file.
1313    pub fn adjust_reflog_permissions(&self, name: &str) -> Result<()> {
1314        validate_ref_name_for_read(name)?;
1315        if self.uses_reftable()? {
1316            return Ok(());
1317        }
1318        let path = self.reflog_path(name);
1319        if path.exists() {
1320            self.shared_repository.adjust_file(&path)?;
1321        }
1322        Ok(())
1323    }
1324
1325    pub fn reflog_exists(&self, name: &str) -> Result<bool> {
1326        validate_ref_name_for_read(name)?;
1327        if self.uses_reftable()? {
1328            return self.reftable_log_exists(name);
1329        }
1330        Ok(self.reflog_path(name).exists())
1331    }
1332
1333    pub fn should_write_reflog_for_update(&self, name: &str, create_reflog: bool) -> Result<bool> {
1334        validate_ref_name_for_read(name)?;
1335        let reflog_exists = if self.uses_reftable()? {
1336            !self.read_reftable_logs(name)?.is_empty()
1337        } else {
1338            self.reflog_path(name).exists()
1339        };
1340        if create_reflog || reflog_exists {
1341            return Ok(true);
1342        }
1343        let context = sley_config::ConfigIncludeContext::new(
1344            Some(sley_config::git_dir_for_include_context(&self.common_dir)),
1345            sley_config::repo_current_branch_name(&self.common_dir),
1346        );
1347        let Ok(config) = sley_config::load_effective_config(&self.common_dir, &context) else {
1348            return Ok(false);
1349        };
1350        if let Some(value) = config.get("core", None, "logAllRefUpdates") {
1351            return Ok(log_all_ref_updates_matches(name, value));
1352        }
1353        if config.get_bool("core", None, "bare").unwrap_or(false) {
1354            return Ok(false);
1355        }
1356        Ok(log_all_ref_updates_matches(name, "true"))
1357    }
1358
1359    pub fn write_reflog(&self, name: &str, entries: &[ReflogEntry]) -> Result<()> {
1360        validate_ref_name_for_read(name)?;
1361        if self.uses_reftable()? {
1362            return self.rewrite_reftable_logs(name, entries, true);
1363        }
1364        let path = self.reflog_path(name);
1365        let parent = path
1366            .parent()
1367            .ok_or_else(|| GitError::InvalidPath("reflog path has no parent".into()))?;
1368        self.shared_repository.create_dir_all(parent)?;
1369        let mut bytes = Vec::new();
1370        for entry in entries {
1371            bytes.extend_from_slice(&entry.to_line());
1372        }
1373        write_locked(&path, &bytes, self.reference_fsync.method_if_enabled())?;
1374        self.shared_repository.adjust_file(&path)
1375    }
1376
1377    /// Export the live refs and, when requested, all non-empty reflogs from the
1378    /// active backend. Reftable exports are folded from one stable stack read;
1379    /// files-backend exports retain the backend's normal per-file consistency.
1380    ///
1381    /// The reftable implementation folds refs and logs while reading the stack
1382    /// once. This avoids the quadratic `list_reflog_names` followed by one full
1383    /// stack scan per name that a storage migration would otherwise perform.
1384    pub fn export_snapshot(&self, include_reflogs: bool) -> Result<RefSnapshot> {
1385        if self.uses_reftable()? {
1386            let mut refs = BTreeMap::<String, Ref>::new();
1387            let mut logs = BTreeMap::<(String, u64), Option<ReftableLogUpdate>>::new();
1388            for table in self.reftables()? {
1389                for record in table.refs {
1390                    let is_migratable_root = !record.name.starts_with("refs/")
1391                        && is_root_ref_syntax(&record.name)
1392                        && !matches!(record.name.as_str(), "FETCH_HEAD" | "MERGE_HEAD");
1393                    if !record.name.starts_with("refs/") && !is_migratable_root {
1394                        continue;
1395                    }
1396                    match reftable_ref_target(record.value)? {
1397                        Some(target) => {
1398                            refs.insert(
1399                                record.name.clone(),
1400                                Ref {
1401                                    name: record.name,
1402                                    target,
1403                                },
1404                            );
1405                        }
1406                        None => {
1407                            refs.remove(&record.name);
1408                        }
1409                    }
1410                }
1411                if include_reflogs {
1412                    for record in table.logs {
1413                        let value = match record.value {
1414                            ReftableLogValue::Deletion => None,
1415                            ReftableLogValue::Update(update) => Some(update),
1416                        };
1417                        logs.insert((record.refname, record.update_index), value);
1418                    }
1419                }
1420            }
1421
1422            let mut reflogs = BTreeMap::<String, Vec<ReflogEntry>>::new();
1423            if include_reflogs {
1424                let null = ObjectId::null(self.format);
1425                for ((name, _), update) in logs {
1426                    let Some(update) = update else {
1427                        continue;
1428                    };
1429                    if !reftable_log_update_is_empty_marker(&update, null) {
1430                        reflogs
1431                            .entry(name)
1432                            .or_default()
1433                            .push(reflog_entry_from_reftable(update));
1434                    }
1435                }
1436            }
1437            return Ok(RefSnapshot {
1438                refs: refs.into_values().collect(),
1439                reflogs: reflogs.into_iter().collect(),
1440            });
1441        }
1442
1443        let refs = self.list_all_refs()?;
1444        if !include_reflogs {
1445            return Ok(RefSnapshot {
1446                refs,
1447                reflogs: Vec::new(),
1448            });
1449        }
1450        // The files backend exposes the complete materialized reflog namespace
1451        // by walking `logs/`. Do not probe every live ref for a corresponding
1452        // log: large repositories commonly have thousands of refs but only a
1453        // handful of reflogs, and one failed filesystem lookup per ref made
1454        // migration time scale with refs twice.
1455        let names = self.list_reflog_names()?;
1456        let mut reflogs = Vec::new();
1457        for name in names {
1458            let entries = self.read_reflog(&name)?;
1459            if !entries.is_empty() {
1460                reflogs.push((name, entries));
1461            }
1462        }
1463        Ok(RefSnapshot { refs, reflogs })
1464    }
1465
1466    pub fn import_snapshot(
1467        &self,
1468        refs: &[Ref],
1469        reflogs: &[(String, Vec<ReflogEntry>)],
1470        pack_files_refs: bool,
1471    ) -> Result<()> {
1472        if self.uses_reftable()? {
1473            let ref_records = refs
1474                .iter()
1475                .map(|reference| ReftableRefRecord {
1476                    name: reference.name.clone(),
1477                    update_index: 0,
1478                    value: reftable_value_from_ref_target(&reference.target),
1479                })
1480                .collect::<Vec<_>>();
1481            let mut log_records = Vec::new();
1482            let mut update_index = 1u64;
1483            for (name, entries) in reflogs {
1484                for entry in entries {
1485                    log_records.push(ReftableLogRecord {
1486                        refname: name.clone(),
1487                        update_index,
1488                        value: ReftableLogValue::Update(self.reftable_update_from_reflog(entry)?),
1489                    });
1490                    update_index = update_index.checked_add(1).ok_or_else(|| {
1491                        GitError::InvalidFormat("reftable update index overflow".into())
1492                    })?;
1493                }
1494            }
1495            if !ref_records.is_empty() || !log_records.is_empty() {
1496                self.append_reftable_table_spanning(ref_records, log_records)?;
1497            }
1498            return Ok(());
1499        }
1500
1501        if pack_files_refs {
1502            let mut packed = Vec::new();
1503            let mut tx = self.transaction();
1504            for reference in refs {
1505                if packable_loose_ref_name(&reference.name)
1506                    && matches!(&reference.target, RefTarget::Direct(_))
1507                {
1508                    packed.push(PackedRef {
1509                        reference: reference.clone(),
1510                        peeled: None,
1511                    });
1512                } else {
1513                    tx.update(RefUpdate {
1514                        name: reference.name.clone(),
1515                        expected: None,
1516                        new: reference.target.clone(),
1517                        reflog: None,
1518                    });
1519                }
1520            }
1521            // A migration directory is private to the caller, so direct refs
1522            // can be materialized in their final packed form. Writing them as
1523            // loose files only to rescan, pack, and prune them adds two file
1524            // operations and a lock per ref without improving atomicity.
1525            self.write_packed_refs(&packed)?;
1526            tx.commit()?;
1527        } else {
1528            let mut tx = self.transaction();
1529            for reference in refs {
1530                tx.update(RefUpdate {
1531                    name: reference.name.clone(),
1532                    expected: None,
1533                    new: reference.target.clone(),
1534                    reflog: None,
1535                });
1536            }
1537            tx.commit()?;
1538        }
1539        for (name, entries) in reflogs {
1540            self.write_reflog(name, entries)?;
1541        }
1542        Ok(())
1543    }
1544
1545    pub fn expire_reflog_older_than(&self, name: &str, cutoff_seconds: i64) -> Result<usize> {
1546        validate_ref_name_for_read(name)?;
1547        if self.uses_reftable()? {
1548            let entries = self.read_reftable_logs(name)?;
1549            let original_len = entries.len();
1550            let mut retained = Vec::new();
1551            for entry in entries {
1552                if entry.timestamp_seconds()? >= cutoff_seconds {
1553                    retained.push(entry);
1554                }
1555            }
1556            let removed = original_len - retained.len();
1557            if removed > 0 {
1558                self.rewrite_reftable_logs(name, &retained, true)?;
1559            }
1560            return Ok(removed);
1561        }
1562        let path = self.reflog_path(name);
1563        if !path.exists() {
1564            return Ok(0);
1565        }
1566        let entries = parse_reflog(self.format, &fs::read(&path)?)?;
1567        let original_len = entries.len();
1568        let mut retained = Vec::new();
1569        for entry in entries {
1570            if entry.timestamp_seconds()? >= cutoff_seconds {
1571                retained.push(entry);
1572            }
1573        }
1574        let mut bytes = Vec::new();
1575        for entry in &retained {
1576            bytes.extend_from_slice(&entry.to_line());
1577        }
1578        write_locked(&path, &bytes, self.reference_fsync.method_if_enabled())?;
1579        Ok(original_len - retained.len())
1580    }
1581
1582    /// Read a ref's reflog, expire entries with [`expire_reflog`], and rewrite
1583    /// the file with the survivors.
1584    ///
1585    /// Reachability of each entry's `new_oid` is delegated to `is_reachable` so
1586    /// the caller can supply whatever object-graph knowledge it has. Rewriting is
1587    /// opt-in via `write`: when `false` nothing is written and the function only
1588    /// reports how many entries would be removed (a dry run). When `true` the
1589    /// reflog is rewritten atomically (lock file + rename) only if at least one
1590    /// entry was removed; an unchanged reflog is left untouched. Returns the
1591    /// number of entries removed.
1592    pub fn expire_reflog_file(
1593        &self,
1594        name: &str,
1595        cutoff_unix: i64,
1596        expire_unreachable_cutoff: Option<i64>,
1597        write: bool,
1598        is_reachable: impl Fn(&ObjectId) -> bool,
1599    ) -> Result<usize> {
1600        validate_ref_name(name)?;
1601        if self.uses_reftable()? {
1602            let entries = self.read_reftable_logs(name)?;
1603            let original_len = entries.len();
1604            let retained = expire_reflog(
1605                &entries,
1606                cutoff_unix,
1607                expire_unreachable_cutoff,
1608                is_reachable,
1609            )?;
1610            let removed = original_len - retained.len();
1611            if write && removed > 0 {
1612                self.rewrite_reftable_logs(name, &retained, true)?;
1613            }
1614            return Ok(removed);
1615        }
1616        let path = self.reflog_path(name);
1617        if !path.exists() {
1618            return Ok(0);
1619        }
1620        let entries = parse_reflog(self.format, &fs::read(&path)?)?;
1621        let original_len = entries.len();
1622        let retained = expire_reflog(
1623            &entries,
1624            cutoff_unix,
1625            expire_unreachable_cutoff,
1626            is_reachable,
1627        )?;
1628        let removed = original_len - retained.len();
1629        if write && removed > 0 {
1630            let mut bytes = Vec::new();
1631            for entry in &retained {
1632                bytes.extend_from_slice(&entry.to_line());
1633            }
1634            write_locked(&path, &bytes, self.reference_fsync.method_if_enabled())?;
1635        }
1636        Ok(removed)
1637    }
1638
1639    pub fn list_refs(&self) -> Result<Vec<Ref>> {
1640        self.list_refs_with_prefix("refs/")
1641    }
1642
1643    /// Capture all updateable refs in one silent filesystem pass.
1644    ///
1645    /// Loose entries override packed entries, including when the loose entry
1646    /// is broken. This mirrors ref resolution while avoiding both repeated
1647    /// packed-ref parsing and the warnings emitted by user-facing iteration.
1648    pub fn read_snapshot_for_update(&self) -> Result<RefReadSnapshot> {
1649        if self.uses_reftable()? {
1650            let entries = self
1651                .list_refs()?
1652                .into_iter()
1653                .map(|reference| {
1654                    (
1655                        reference.name,
1656                        RefReadSnapshotValue::Target(reference.target),
1657                    )
1658                })
1659                .collect();
1660            return Ok(RefReadSnapshot { entries });
1661        }
1662
1663        let mut entries = BTreeMap::new();
1664        let packed_path = self.storage_dir.join("packed-refs");
1665        if packed_path.exists() {
1666            for packed in parse_packed_refs(self.format, &fs::read(packed_path)?)? {
1667                entries.insert(
1668                    packed.reference.name,
1669                    RefReadSnapshotValue::Target(packed.reference.target),
1670                );
1671            }
1672        }
1673        self.collect_loose_ref_snapshot(&self.storage_dir.join("refs"), "refs", &mut entries)?;
1674        let worktree_base = self.current_worktree_storage_dir();
1675        if worktree_base != self.storage_dir {
1676            for namespace in CURRENT_WORKTREE_REF_PREFIXES {
1677                let prefix = namespace.trim_end_matches('/');
1678                self.collect_loose_ref_snapshot(&worktree_base.join(prefix), prefix, &mut entries)?;
1679            }
1680        }
1681        Ok(RefReadSnapshot { entries })
1682    }
1683
1684    pub fn list_refs_with_prefix(&self, prefix: &str) -> Result<Vec<Ref>> {
1685        if self.uses_reftable()? {
1686            let mut refs = BTreeMap::<String, Ref>::new();
1687            for reference in self
1688                .reftable_store_with_storage(self.shared_reftable_storage_dir())
1689                .list_reftable_refs_with_prefix(prefix)?
1690            {
1691                refs.insert(reference.name.clone(), reference);
1692            }
1693            if self.git_dir != self.common_dir {
1694                for reference in self.list_reftable_refs_with_prefix(prefix)? {
1695                    if reftable_current_worktree_ref(&reference.name) {
1696                        refs.insert(reference.name.clone(), reference);
1697                    }
1698                }
1699            }
1700            return Ok(refs.into_values().collect());
1701        }
1702        let mut refs = Vec::new();
1703        let packed_path = self.storage_dir.join("packed-refs");
1704        if packed_path.exists() {
1705            for packed in
1706                parse_packed_refs_with_prefix(self.format, &fs::read(packed_path)?, prefix)?
1707            {
1708                refs.push(packed.reference);
1709            }
1710        }
1711        let mut loose_refs = BTreeMap::new();
1712        self.collect_loose_refs_with_prefix(prefix, &mut loose_refs)?;
1713        self.collect_current_worktree_loose_refs_with_prefix(prefix, &mut loose_refs)?;
1714        if !loose_refs.is_empty() {
1715            refs.retain(|reference| !loose_refs.contains_key(&reference.name));
1716            refs.extend(loose_refs.into_values());
1717        }
1718        refs.retain(|reference| reference.name.starts_with(prefix));
1719        refs.retain(|reference| {
1720            if validate_ref_name(&reference.name).is_ok() {
1721                true
1722            } else {
1723                warn_broken_ref_name(&reference.name);
1724                false
1725            }
1726        });
1727        refs.sort_by(|left, right| left.name.cmp(&right.name));
1728        Ok(refs)
1729    }
1730
1731    pub fn list_ref_names_with_prefix(&self, prefix: &str) -> Result<Vec<String>> {
1732        if self.uses_reftable()? {
1733            return Ok(self
1734                .list_refs_with_prefix(prefix)?
1735                .into_iter()
1736                .map(|reference| reference.name)
1737                .collect());
1738        }
1739        let mut names = Vec::new();
1740        let packed_path = self.storage_dir.join("packed-refs");
1741        if packed_path.exists() {
1742            packed_ref_names_with_prefix(
1743                self.format,
1744                &fs::read(packed_path)?,
1745                prefix,
1746                false,
1747                &mut names,
1748            )?;
1749        }
1750        let mut loose_names = BTreeSet::new();
1751        self.collect_loose_ref_names_with_prefix(prefix, &mut loose_names)?;
1752        self.collect_current_worktree_loose_ref_names_with_prefix(prefix, &mut loose_names)?;
1753        if !loose_names.is_empty() {
1754            names.retain(|name| !loose_names.contains(name));
1755            names.extend(loose_names);
1756        }
1757        names.retain(|name| name.starts_with(prefix));
1758        names.retain(|name| {
1759            if validate_ref_name(name).is_ok() {
1760                true
1761            } else {
1762                warn_broken_ref_name(name);
1763                false
1764            }
1765        });
1766        names.sort();
1767        names.dedup();
1768        Ok(names)
1769    }
1770
1771    pub fn list_short_ref_names_with_prefix(&self, prefix: &str) -> Result<Vec<String>> {
1772        if self.uses_reftable()? {
1773            let mut names = self
1774                .list_reftable_refs_with_prefix(prefix)?
1775                .into_iter()
1776                .filter_map(|reference| reference.name.strip_prefix(prefix).map(str::to_owned))
1777                .collect::<Vec<_>>();
1778            names.sort();
1779            return Ok(names);
1780        }
1781        let mut names = Vec::new();
1782        let packed_path = self.storage_dir.join("packed-refs");
1783        if packed_path.exists() {
1784            packed_ref_names_with_prefix(
1785                self.format,
1786                &fs::read(packed_path)?,
1787                prefix,
1788                true,
1789                &mut names,
1790            )?;
1791        }
1792        let mut loose_full_names = BTreeSet::new();
1793        self.collect_loose_ref_names_with_prefix(prefix, &mut loose_full_names)?;
1794        self.collect_current_worktree_loose_ref_names_with_prefix(prefix, &mut loose_full_names)?;
1795        let loose_names = loose_full_names
1796            .into_iter()
1797            .filter_map(|name| {
1798                if validate_ref_name(&name).is_ok() {
1799                    name.strip_prefix(prefix).map(str::to_owned)
1800                } else {
1801                    warn_broken_ref_name(&name);
1802                    None
1803                }
1804            })
1805            .collect::<BTreeSet<_>>();
1806        if !loose_names.is_empty() {
1807            names.retain(|name| !loose_names.contains(name));
1808            names.extend(loose_names);
1809        }
1810        names.sort();
1811        names.dedup();
1812        Ok(names)
1813    }
1814
1815    pub fn list_all_refs(&self) -> Result<Vec<Ref>> {
1816        let mut refs = self.list_refs()?;
1817        let mut seen = refs
1818            .iter()
1819            .map(|reference| reference.name.clone())
1820            .collect::<BTreeSet<_>>();
1821        for reference in self.list_root_refs()? {
1822            if seen.insert(reference.name.clone()) {
1823                refs.push(reference);
1824            }
1825        }
1826        refs.sort_by(|left, right| left.name.cmp(&right.name));
1827        Ok(refs)
1828    }
1829
1830    pub fn list_reflog_names(&self) -> Result<Vec<String>> {
1831        let mut names = BTreeSet::new();
1832        if self.uses_reftable()? {
1833            let null = ObjectId::null(self.format);
1834            let mut logs_by_index = BTreeMap::<(String, u64), bool>::new();
1835            for table in self.reftables()? {
1836                for record in table.logs {
1837                    let has_visible_entry = match record.value {
1838                        ReftableLogValue::Deletion => false,
1839                        ReftableLogValue::Update(update) => {
1840                            !reftable_log_update_is_empty_marker(&update, null)
1841                        }
1842                    };
1843                    logs_by_index.insert((record.refname, record.update_index), has_visible_entry);
1844                }
1845            }
1846            for ((name, _), has_visible_entry) in logs_by_index {
1847                if has_visible_entry {
1848                    names.insert(name);
1849                }
1850            }
1851            return Ok(names.into_iter().collect());
1852        }
1853        self.collect_reflog_names(&self.storage_dir.join("logs"), "logs", &mut names)?;
1854        let worktree_logs = self.git_dir.join("logs");
1855        if worktree_logs != self.storage_dir.join("logs") {
1856            self.collect_reflog_names(&worktree_logs, "logs", &mut names)?;
1857        }
1858        Ok(names.into_iter().collect())
1859    }
1860
1861    pub fn has_refs_with_prefix(&self, prefix: &str) -> Result<bool> {
1862        if self.uses_reftable()? {
1863            return Ok(self
1864                .list_reftable_refs()?
1865                .iter()
1866                .any(|reference| reference.name.starts_with(prefix)));
1867        }
1868        let packed_path = self.storage_dir.join("packed-refs");
1869        if packed_path.exists()
1870            && packed_refs_have_prefix(self.format, &fs::read(&packed_path)?, prefix)?
1871        {
1872            return Ok(true);
1873        }
1874        self.loose_refs_have_prefix(prefix)
1875    }
1876
1877    pub fn write_packed_refs(&self, refs: &[PackedRef]) -> Result<()> {
1878        self.write_packed_refs_with_timeout(refs, 0)
1879    }
1880
1881    pub fn write_packed_refs_with_timeout(
1882        &self,
1883        refs: &[PackedRef],
1884        timeout_millis: u64,
1885    ) -> Result<()> {
1886        let path = self.packed_refs_write_path()?;
1887        write_locked_with_timeout(
1888            &path,
1889            &write_packed_refs(refs)?,
1890            timeout_millis,
1891            self.reference_fsync.method_if_enabled(),
1892        )
1893    }
1894
1895    pub fn pack_refs(&self, prune_loose: bool) -> Result<Vec<PackedRef>> {
1896        self.pack_refs_with_peeler(prune_loose, |_, _| Ok(None))
1897    }
1898
1899    pub fn pack_refs_with_peeler<F>(&self, prune_loose: bool, mut peel: F) -> Result<Vec<PackedRef>>
1900    where
1901        F: FnMut(&str, &ObjectId) -> Result<Option<ObjectId>>,
1902    {
1903        self.pack_refs_selected_with_timeout(
1904            prune_loose,
1905            false,
1906            0,
1907            |_| true,
1908            |name, oid| peel(name, oid).map(|peeled| PackRefDecision::Pack { peeled }),
1909        )
1910    }
1911
1912    pub fn pack_refs_selected_with_timeout<F, S>(
1913        &self,
1914        prune_loose: bool,
1915        auto: bool,
1916        timeout_millis: u64,
1917        mut should_pack: S,
1918        mut decide: F,
1919    ) -> Result<Vec<PackedRef>>
1920    where
1921        F: FnMut(&str, &ObjectId) -> Result<PackRefDecision>,
1922        S: FnMut(&str) -> bool,
1923    {
1924        if self.uses_reftable()? {
1925            self.compact_reftable_stack()?;
1926            return Ok(Vec::new());
1927        }
1928        let mut packed_refs = BTreeMap::new();
1929        let packed_path = self.storage_dir.join("packed-refs");
1930        if packed_path.exists() {
1931            for packed in parse_packed_refs(self.format, &fs::read(&packed_path)?)? {
1932                packed_refs.insert(packed.reference.name.clone(), packed);
1933            }
1934        }
1935
1936        let mut loose_refs = BTreeMap::new();
1937        let refs_dir = self.storage_dir.join("refs");
1938        if refs_dir.exists() {
1939            self.collect_loose_refs(&refs_dir, "refs", &mut loose_refs)?;
1940        }
1941        let loose_refs = loose_refs
1942            .into_values()
1943            .filter(|reference| packable_loose_ref_name(&reference.name))
1944            .filter(|reference| should_pack(&reference.name))
1945            .collect::<Vec<_>>();
1946        if auto && !pack_refs_auto_required_for(&packed_path, loose_refs.len())? {
1947            return Ok(packed_refs.into_values().collect());
1948        }
1949        let mut packed_loose_refs = Vec::new();
1950        for reference in loose_refs {
1951            let RefTarget::Direct(oid) = reference.target else {
1952                continue;
1953            };
1954            let peeled = match decide(&reference.name, &oid)? {
1955                PackRefDecision::Pack { peeled } => peeled,
1956                PackRefDecision::Skip => continue,
1957            };
1958            packed_loose_refs.push((reference.name.clone(), oid));
1959            packed_refs.insert(
1960                reference.name.clone(),
1961                PackedRef {
1962                    reference: Ref {
1963                        name: reference.name,
1964                        target: RefTarget::Direct(oid),
1965                    },
1966                    peeled,
1967                },
1968            );
1969        }
1970
1971        let refs = packed_refs.into_values().collect::<Vec<_>>();
1972        self.write_packed_refs_with_timeout(&refs, timeout_millis)?;
1973        if prune_loose {
1974            for (name, oid) in packed_loose_refs {
1975                self.prune_loose_ref_after_pack(&name, &oid)?;
1976            }
1977        }
1978        Ok(refs)
1979    }
1980
1981    pub fn pack_refs_auto_required<S>(&self, mut should_pack: S) -> Result<bool>
1982    where
1983        S: FnMut(&str) -> bool,
1984    {
1985        if self.uses_reftable()? {
1986            return Ok(true);
1987        }
1988        let mut loose_refs = BTreeMap::new();
1989        let refs_dir = self.storage_dir.join("refs");
1990        if refs_dir.exists() {
1991            self.collect_loose_refs(&refs_dir, "refs", &mut loose_refs)?;
1992        }
1993        let count = loose_refs
1994            .values()
1995            .filter(|reference| packable_loose_ref_name(&reference.name))
1996            .filter(|reference| matches!(reference.target, RefTarget::Direct(_)))
1997            .filter(|reference| should_pack(&reference.name))
1998            .count();
1999        pack_refs_auto_required_for(&self.storage_dir.join("packed-refs"), count)
2000    }
2001
2002    fn packed_refs_write_path(&self) -> Result<PathBuf> {
2003        let path = self.storage_dir.join("packed-refs");
2004        match fs::symlink_metadata(&path) {
2005            Ok(meta) if meta.file_type().is_symlink() => {
2006                let target = fs::read_link(&path)?;
2007                if target.is_absolute() {
2008                    Ok(target)
2009                } else {
2010                    let parent = path.parent().ok_or_else(|| {
2011                        GitError::InvalidPath("packed-refs path has no parent".into())
2012                    })?;
2013                    Ok(parent.join(target))
2014                }
2015            }
2016            Ok(_) => Ok(path),
2017            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(path),
2018            Err(err) => Err(err.into()),
2019        }
2020    }
2021
2022    pub fn current_branch_ref(&self) -> Result<Option<String>> {
2023        match self.read_ref("HEAD")? {
2024            Some(RefTarget::Symbolic(name)) if name.starts_with("refs/heads/") => Ok(Some(name)),
2025            _ => Ok(None),
2026        }
2027    }
2028
2029    pub fn current_branch(&self) -> Result<Option<String>> {
2030        Ok(self
2031            .current_branch_ref()?
2032            .and_then(|name| name.strip_prefix("refs/heads/").map(str::to_string)))
2033    }
2034
2035    pub fn transaction(&self) -> FileRefTransaction<'_> {
2036        FileRefTransaction {
2037            store: self,
2038            changes: Vec::new(),
2039            hook: None,
2040        }
2041    }
2042
2043    pub fn uses_alternate_ref_storage(&self) -> bool {
2044        self.storage_dir != self.common_dir
2045    }
2046
2047    pub fn current_worktree_ref_storage_dir(&self) -> PathBuf {
2048        self.current_worktree_storage_dir()
2049    }
2050
2051    pub fn create_branch(
2052        &self,
2053        branch: &str,
2054        start: ObjectId,
2055        committer: Vec<u8>,
2056        message: Vec<u8>,
2057    ) -> Result<BranchCreate> {
2058        let name = branch_ref_name(branch)?;
2059        if self.read_ref(&name)?.is_some() {
2060            return Err(GitError::Transaction(format!(
2061                "branch {branch} already exists"
2062            )));
2063        }
2064        let zero = ObjectId::null(self.format);
2065        let mut tx = self.transaction();
2066        tx.update(RefUpdate {
2067            name: name.clone(),
2068            expected: None,
2069            new: RefTarget::Direct(start),
2070            reflog: Some(ReflogEntry {
2071                old_oid: zero,
2072                new_oid: start,
2073                committer,
2074                message,
2075            }),
2076        });
2077        tx.commit()?;
2078        Ok(BranchCreate { name, oid: start })
2079    }
2080
2081    pub fn delete_branch(&self, branch: &str) -> Result<BranchDelete> {
2082        let name = branch_ref_name_for_read(branch)?;
2083        if matches!(self.read_ref("HEAD")?, Some(RefTarget::Symbolic(head)) if head == name) {
2084            return Err(GitError::Transaction(format!(
2085                "cannot delete branch {branch} checked out at HEAD"
2086            )));
2087        }
2088        let oid = self.delete_direct_ref(&name, "branch", branch)?;
2089        self.remove_reflog_file(&name);
2090        Ok(BranchDelete { name, oid })
2091    }
2092
2093    pub fn move_branch(
2094        &self,
2095        old_branch: &str,
2096        new_branch: &str,
2097        force: bool,
2098        committer: Vec<u8>,
2099    ) -> Result<()> {
2100        self.copy_or_move_branch(old_branch, new_branch, force, false, committer)
2101    }
2102
2103    pub fn copy_branch(
2104        &self,
2105        old_branch: &str,
2106        new_branch: &str,
2107        force: bool,
2108        committer: Vec<u8>,
2109    ) -> Result<()> {
2110        self.copy_or_move_branch(old_branch, new_branch, force, true, committer)
2111    }
2112
2113    /// Find an existing ref (other than `exclude`) that would have a
2114    /// directory/file conflict with creating `new_name`: either an existing ref
2115    /// is a path-prefix of `new_name` (it occupies a directory component
2116    /// `new_name` needs), or `new_name` is a path-prefix of an existing ref
2117    /// (`new_name` would occupy a directory another ref needs). Returns the
2118    /// conflicting ref name.
2119    fn conflicting_ref_for_path(&self, new_name: &str, exclude: &str) -> Result<Option<String>> {
2120        for reference in self.list_refs()? {
2121            let name = &reference.name;
2122            if name == new_name || name == exclude {
2123                continue;
2124            }
2125            // `name` sits above `new_name`: name = refs/heads/r, new = refs/heads/r/q
2126            if new_name.starts_with(&format!("{name}/")) {
2127                return Ok(Some(name.clone()));
2128            }
2129            // `name` sits below `new_name`: new = refs/heads/r, name = refs/heads/r/q
2130            if name.starts_with(&format!("{new_name}/")) {
2131                return Ok(Some(name.clone()));
2132            }
2133        }
2134        Ok(None)
2135    }
2136
2137    fn copy_or_move_branch(
2138        &self,
2139        old_branch: &str,
2140        new_branch: &str,
2141        force: bool,
2142        copy: bool,
2143        committer: Vec<u8>,
2144    ) -> Result<()> {
2145        let old_name = branch_ref_name_for_source(old_branch)?;
2146        let new_name = branch_ref_name(new_branch)?;
2147        if old_name == new_name {
2148            return Ok(());
2149        }
2150        let Some(target) = self.read_ref(&old_name)? else {
2151            return Err(GitError::reference_not_found(format!(
2152                "branch {old_branch}"
2153            )));
2154        };
2155        let RefTarget::Direct(oid) = target else {
2156            return Err(GitError::InvalidFormat(format!(
2157                "branch {old_branch} is symbolic"
2158            )));
2159        };
2160        if !copy
2161            && fs::symlink_metadata(self.reflog_path(&old_name))
2162                .is_ok_and(|metadata| metadata.file_type().is_symlink())
2163        {
2164            return Err(GitError::Transaction(format!(
2165                "reflog for '{old_name}' is a symlink"
2166            )));
2167        }
2168        // Detect a directory/file conflict against some *other* ref before
2169        // mutating anything (git's rename_ref fails up front, leaving the old
2170        // branch intact): e.g. renaming `q` -> `r/q` while `r` exists, or `q` ->
2171        // `r` while `r/x` exists. For renames, the old ref itself is excluded
2172        // because a self-nesting rename (`m` -> `m/m`) is handled by removing it
2173        // first. Copies leave the source ref in place, so the source can
2174        // conflict with the destination.
2175        let conflict_exclude = if copy { "" } else { &old_name };
2176        if let Some(conflict) = self.conflicting_ref_for_path(&new_name, conflict_exclude)? {
2177            return Err(GitError::Transaction(format!(
2178                "'{conflict}' exists; cannot create '{new_name}'"
2179            )));
2180        }
2181        // git's validate_branchname uses refs_ref_exists (RESOLVE_REF_READING):
2182        // a *dangling* symref destination does not "exist", so a rename onto it
2183        // proceeds without --force and overwrites the symref file (t3200 #16).
2184        let dest_entry = self.read_ref(&new_name)?;
2185        let dest_resolves = resolve_ref_peeled(self, &new_name)?.is_some();
2186        if dest_resolves && !force {
2187            return Err(GitError::Transaction(format!(
2188                "branch {new_branch} already exists"
2189            )));
2190        }
2191        // Remove any existing destination ref (direct or symbolic) before
2192        // writing. A dangling symref must be removed as a symref; a real branch
2193        // as a direct ref.
2194        match dest_entry {
2195            Some(RefTarget::Symbolic(_)) => {
2196                self.delete_symbolic_ref(&new_name)?;
2197                self.remove_reflog_file(&new_name);
2198            }
2199            Some(RefTarget::Direct(_)) => {
2200                let _ = self.delete_direct_ref(&new_name, "branch", new_branch)?;
2201                self.remove_reflog_file(&new_name);
2202            }
2203            None => {}
2204        }
2205
2206        // Capture the old reflog before removing anything; it is carried over
2207        // to the new ref.
2208        let mut reflog = self.read_reflog(&old_name)?;
2209        reflog.push(ReflogEntry {
2210            old_oid: oid,
2211            new_oid: oid,
2212            committer,
2213            message: if copy {
2214                format!("Branch: copied {old_name} to {new_name}").into_bytes()
2215            } else {
2216                format!("Branch: renamed {old_name} to {new_name}").into_bytes()
2217            },
2218        });
2219
2220        // A directory/file conflict can occur when the new ref's path nests
2221        // under the old ref (`m` -> `m/m`) or vice-versa; remove the old loose
2222        // ref AND its reflog first so neither file blocks creating the new
2223        // directory under refs/ or logs/refs/ (t3200 #17, #18).
2224        if !copy {
2225            let _ = self.delete_direct_ref(&old_name, "branch", old_branch)?;
2226            self.remove_reflog_file(&old_name);
2227        }
2228
2229        self.write_loose_ref(&Ref {
2230            name: new_name.clone(),
2231            target: RefTarget::Direct(oid),
2232        })?;
2233        self.write_reflog(&new_name, &reflog)?;
2234
2235        if !copy
2236            && matches!(self.read_ref("HEAD")?, Some(RefTarget::Symbolic(head)) if head == old_name)
2237        {
2238            self.write_loose_ref(&Ref {
2239                name: "HEAD".into(),
2240                target: RefTarget::Symbolic(new_name),
2241            })?;
2242        }
2243        Ok(())
2244    }
2245
2246    pub fn create_tag(&self, tag: &str, target: ObjectId) -> Result<TagCreate> {
2247        let name = tag_ref_name(tag)?;
2248        if self.read_ref(&name)?.is_some() {
2249            return Err(GitError::Transaction(format!("tag {tag} already exists")));
2250        }
2251        let mut tx = self.transaction();
2252        tx.update(RefUpdate {
2253            name: name.clone(),
2254            expected: None,
2255            new: RefTarget::Direct(target),
2256            reflog: None,
2257        });
2258        tx.commit()?;
2259        Ok(TagCreate { name, oid: target })
2260    }
2261
2262    pub fn apply_bundle_ref_updates(
2263        &self,
2264        refs: &[BundleRefUpdate],
2265        reflog: Option<BundleRefUpdateReflog>,
2266    ) -> Result<Vec<AppliedBundleRefUpdate>> {
2267        let (updates, applied) = prepare_bundle_ref_updates(refs, reflog.as_ref(), |name, oid| {
2268            if oid.format() != self.format {
2269                return Err(GitError::InvalidObjectId(format!(
2270                    "bundle ref {name} has {} object id for {} repository",
2271                    oid.format().name(),
2272                    self.format.name()
2273                )));
2274            }
2275            self.read_ref(name)
2276        })?;
2277        let mut tx = self.transaction();
2278        for update in updates {
2279            tx.update(update);
2280        }
2281        tx.commit()?;
2282        Ok(applied)
2283    }
2284
2285    pub fn delete_tag(&self, tag: &str) -> Result<TagDelete> {
2286        let name = TagRefNameBuf::from_tag_name_unrestricted(tag)?.into_string();
2287        let oid = self.delete_direct_ref(&name, "tag", tag)?;
2288        self.remove_reflog_file(&name);
2289        Ok(TagDelete { name, oid })
2290    }
2291
2292    pub fn delete_ref(&self, name: &str) -> Result<RefDelete> {
2293        validate_ref_name_for_read(name)?;
2294        let oid = self.delete_direct_ref(name, "ref", name)?;
2295        self.remove_reflog_file(name);
2296        Ok(RefDelete {
2297            name: name.into(),
2298            oid,
2299        })
2300    }
2301
2302    pub fn delete_ref_checked(
2303        &self,
2304        delete: DeleteRef,
2305    ) -> std::result::Result<RefDelete, RefDeleteError> {
2306        validate_ref_name_for_read(&delete.name).map_err(|_| RefDeleteError::InvalidName)?;
2307        if self.uses_reftable().map_err(ref_delete_error_from_git)? {
2308            return self.delete_reftable_ref_checked(delete);
2309        }
2310        self.delete_files_ref_checked(delete)
2311    }
2312
2313    pub fn delete_symbolic_ref(&self, name: &str) -> Result<bool> {
2314        validate_ref_name_for_read(name)?;
2315        if self.uses_reftable()? {
2316            let Some(target) = self.read_ref(name)? else {
2317                return Ok(false);
2318            };
2319            if !matches!(target, RefTarget::Symbolic(_)) {
2320                return Ok(false);
2321            }
2322            self.append_reftable_records(vec![ReftableRefRecord {
2323                name: name.to_string(),
2324                update_index: 0,
2325                value: ReftableRefValue::Deletion,
2326            }])?;
2327            self.remove_reflog_file(name);
2328            return Ok(true);
2329        }
2330        let Some(reference) = self.read_loose_ref(name)? else {
2331            return Ok(false);
2332        };
2333        if !matches!(reference.target, RefTarget::Symbolic(_)) {
2334            return Ok(false);
2335        }
2336        self.delete_loose_ref(name)?;
2337        self.remove_reflog_file(name);
2338        Ok(true)
2339    }
2340
2341    fn delete_direct_ref(&self, name: &str, kind: &str, short_name: &str) -> Result<ObjectId> {
2342        if self.uses_reftable()? {
2343            let Some(target) = self.read_ref(name)? else {
2344                return Err(GitError::reference_not_found(format!(
2345                    "{kind} {short_name}"
2346                )));
2347            };
2348            let RefTarget::Direct(oid) = target else {
2349                return Err(GitError::InvalidFormat(format!(
2350                    "{kind} {short_name} is symbolic"
2351                )));
2352            };
2353            self.append_reftable_records(vec![ReftableRefRecord {
2354                name: name.to_string(),
2355                update_index: 0,
2356                value: ReftableRefValue::Deletion,
2357            }])?;
2358            // git drops the reflog when the ref goes away; tombstone the log
2359            // records so `git reflog` / `git stash list` stop seeing them.
2360            self.remove_reflog_file(name);
2361            return Ok(oid);
2362        }
2363        let target = self
2364            .read_ref(name)?
2365            .ok_or_else(|| GitError::reference_not_found(format!("{kind} {short_name}")))?;
2366        let RefTarget::Direct(oid) = target else {
2367            return Err(GitError::InvalidFormat(format!(
2368                "{kind} {short_name} is symbolic"
2369            )));
2370        };
2371
2372        // Deleting loose first and packed second briefly exposes the stale
2373        // packed value and cannot roll the loose deletion back when the packed
2374        // rewrite fails. Route both representations through the transaction:
2375        // it locks loose and packed state before either becomes observable.
2376        let mut tx = self.transaction();
2377        tx.delete_with_precondition(
2378            name.to_string(),
2379            RefDeletePrecondition::Direct(Some(oid)),
2380            None,
2381        );
2382        tx.commit()?;
2383        Ok(oid)
2384    }
2385
2386    fn delete_reftable_ref_checked(
2387        &self,
2388        delete: DeleteRef,
2389    ) -> std::result::Result<RefDelete, RefDeleteError> {
2390        let target = self
2391            .read_ref(&delete.name)
2392            .map_err(ref_delete_error_from_git)?;
2393        let oid = checked_delete_oid(delete.expected_old, target)?;
2394        self.append_reftable_records(vec![ReftableRefRecord {
2395            name: delete.name.clone(),
2396            update_index: 0,
2397            value: ReftableRefValue::Deletion,
2398        }])
2399        .map_err(ref_delete_error_from_git)?;
2400        // Git unlinks logs/refs/<name> on delete (pruning now-empty dirs); it
2401        // does not keep a deletion entry. Mirror delete_ref / delete_branch.
2402        self.remove_reflog_file(&delete.name);
2403        Ok(RefDelete {
2404            name: delete.name,
2405            oid,
2406        })
2407    }
2408
2409    fn delete_files_ref_checked(
2410        &self,
2411        delete: DeleteRef,
2412    ) -> std::result::Result<RefDelete, RefDeleteError> {
2413        let name = delete.name;
2414        let path = self.ref_path(&name);
2415        let parent = path.parent().ok_or(RefDeleteError::InvalidName)?;
2416        fs::create_dir_all(parent).map_err(RefDeleteError::from)?;
2417
2418        let loose_lock_path = lock_path_for(&path).map_err(|_| RefDeleteError::InvalidName)?;
2419        let _prune_guard = RefDirPruneGuard {
2420            store: self,
2421            name: name.clone(),
2422        };
2423        let loose_lock = DeleteLock::acquire(loose_lock_path)?;
2424
2425        let packed_path = self.storage_dir.join("packed-refs");
2426        let packed_lock_path =
2427            lock_path_for(&packed_path).map_err(|_| RefDeleteError::InvalidName)?;
2428        let mut packed_lock = DeleteLock::acquire(packed_lock_path)?;
2429
2430        let loose_ref = self
2431            .read_loose_ref(&name)
2432            .map_err(ref_delete_error_from_git)?;
2433        let packed_original = match fs::read(&packed_path) {
2434            Ok(bytes) => Some(bytes),
2435            Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
2436            Err(err) => return Err(RefDeleteError::Io(err)),
2437        };
2438        let mut packed_refs = match &packed_original {
2439            Some(bytes) => {
2440                parse_packed_refs(self.format, bytes).map_err(ref_delete_error_from_git)?
2441            }
2442            None => Vec::new(),
2443        };
2444        let packed_index = packed_refs
2445            .iter()
2446            .position(|reference| reference.reference.name == name);
2447
2448        let current = if let Some(reference) = loose_ref.as_ref() {
2449            Some(reference.target.clone())
2450        } else {
2451            packed_index.map(|index| packed_refs[index].reference.target.clone())
2452        };
2453        let oid = checked_delete_oid(delete.expected_old, current)?;
2454
2455        let packed_changed = if let Some(index) = packed_index {
2456            packed_refs.remove(index);
2457            true
2458        } else {
2459            false
2460        };
2461
2462        if packed_changed {
2463            let packed_bytes =
2464                write_packed_refs(&packed_refs).map_err(ref_delete_error_from_git)?;
2465            packed_lock.write_all(&packed_bytes, self.reference_fsync.method_if_enabled())?;
2466            let lock_path = packed_lock.close();
2467            if let Err(err) = fs::rename(&lock_path, &packed_path) {
2468                let _ = fs::remove_file(&lock_path);
2469                return Err(RefDeleteError::Io(err));
2470            }
2471        } else {
2472            packed_lock.remove();
2473        }
2474
2475        if loose_ref.is_some()
2476            && let Err(err) = fs::remove_file(&path)
2477        {
2478            if packed_changed && let Some(bytes) = packed_original.as_ref() {
2479                let _ = restore_file_atomically(&packed_path, bytes);
2480            }
2481            return Err(RefDeleteError::Io(err));
2482        }
2483        loose_lock.remove();
2484
2485        // Git unlinks logs/refs/<name> on delete (pruning now-empty dirs); it
2486        // does not keep a deletion entry. Mirror delete_ref / delete_branch.
2487        self.remove_reflog_file(&name);
2488        Ok(RefDelete { name, oid })
2489    }
2490
2491    fn read_loose_ref(&self, name: &str) -> Result<Option<Ref>> {
2492        let path = self.ref_path(name);
2493        let metadata = match fs::symlink_metadata(&path) {
2494            Ok(metadata) => metadata,
2495            Err(err)
2496                if err.kind() == std::io::ErrorKind::NotFound || err.raw_os_error() == Some(20) =>
2497            {
2498                return Ok(None);
2499            }
2500            Err(err) => return Err(err.into()),
2501        };
2502        if metadata.is_dir() {
2503            return Ok(None);
2504        }
2505        if metadata.file_type().is_symlink() {
2506            if let Ok(target) = fs::read_link(&path) {
2507                let target = target.to_string_lossy();
2508                if target.starts_with("refs/") {
2509                    return Ok(Some(Ref {
2510                        name: name.to_string(),
2511                        target: RefTarget::Symbolic(target.into_owned()),
2512                    }));
2513                }
2514            }
2515            // A legacy ref symlink is meaningful only when its link text is a
2516            // `refs/...` target. If an arbitrary filesystem symlink is broken,
2517            // Git treats the ref as absent instead of letting ENOENT abort an
2518            // otherwise unrelated ref transaction.
2519            if fs::metadata(&path).is_err_and(|err| {
2520                err.kind() == std::io::ErrorKind::NotFound
2521                    || err.kind() == std::io::ErrorKind::NotADirectory
2522            }) {
2523                return Ok(None);
2524            }
2525        }
2526        let bytes = fs::read(path)?;
2527        if loose_ref_bytes_are_reftable_sentinel(name, &bytes) {
2528            return Ok(None);
2529        }
2530        Ok(Some(parse_loose_ref(self.format, name, &bytes)?))
2531    }
2532
2533    fn read_packed_ref(&self, name: &str) -> Result<Option<PackedRef>> {
2534        let path = self.storage_dir.join("packed-refs");
2535        if !path.exists() {
2536            return Ok(None);
2537        }
2538        Ok(parse_packed_refs(self.format, &fs::read(path)?)?
2539            .into_iter()
2540            .find(|reference| reference.reference.name == name))
2541    }
2542
2543    fn read_reftable_ref(&self, name: &str) -> Result<Option<RefTarget>> {
2544        for table in self.reftables()?.into_iter().rev() {
2545            if let Some(record) = table.refs.into_iter().find(|record| record.name == name) {
2546                return reftable_ref_target(record.value);
2547            }
2548        }
2549        Ok(None)
2550    }
2551
2552    fn list_reftable_refs(&self) -> Result<Vec<Ref>> {
2553        self.list_reftable_refs_with_prefix("refs/")
2554    }
2555
2556    fn list_reftable_refs_with_prefix(&self, prefix: &str) -> Result<Vec<Ref>> {
2557        let mut refs = BTreeMap::<String, Ref>::new();
2558        for table in self.reftables()? {
2559            for record in table.refs {
2560                if !record.name.starts_with("refs/") || !record.name.starts_with(prefix) {
2561                    continue;
2562                }
2563                match reftable_ref_target(record.value)? {
2564                    Some(target) => {
2565                        refs.insert(
2566                            record.name.clone(),
2567                            Ref {
2568                                name: record.name,
2569                                target,
2570                            },
2571                        );
2572                    }
2573                    None => {
2574                        refs.remove(&record.name);
2575                    }
2576                }
2577            }
2578        }
2579        Ok(refs.into_values().collect())
2580    }
2581
2582    /// List the live one-level references held by the active ref backend.
2583    ///
2584    /// `FETCH_HEAD` and `MERGE_HEAD` are working-state files rather than
2585    /// migratable refs, so they are deliberately excluded for both backends.
2586    /// Callers which expose a narrower root-ref surface (for example
2587    /// `for-each-ref --include-root-refs`) should apply their own semantic
2588    /// predicate to this backend-independent inventory.
2589    pub fn list_root_refs(&self) -> Result<Vec<Ref>> {
2590        if self.uses_reftable()? {
2591            let mut refs = BTreeMap::<String, Ref>::new();
2592            for table in self.reftables()? {
2593                for record in table.refs {
2594                    if record.name.starts_with("refs/")
2595                        || !is_root_ref_syntax(&record.name)
2596                        || matches!(record.name.as_str(), "FETCH_HEAD" | "MERGE_HEAD")
2597                    {
2598                        continue;
2599                    }
2600                    match reftable_ref_target(record.value)? {
2601                        Some(target) => {
2602                            refs.insert(
2603                                record.name.clone(),
2604                                Ref {
2605                                    name: record.name,
2606                                    target,
2607                                },
2608                            );
2609                        }
2610                        None => {
2611                            refs.remove(&record.name);
2612                        }
2613                    }
2614                }
2615            }
2616            return Ok(refs.into_values().collect());
2617        }
2618
2619        let mut refs = Vec::new();
2620        for entry in fs::read_dir(&self.git_dir)? {
2621            let entry = entry?;
2622            if !entry.file_type()?.is_file() {
2623                continue;
2624            }
2625            let name = entry.file_name().to_string_lossy().into_owned();
2626            if !is_root_ref_syntax(&name) || matches!(name.as_str(), "FETCH_HEAD" | "MERGE_HEAD") {
2627                continue;
2628            }
2629            if let Ok(reference) = parse_loose_ref(self.format, name, &fs::read(entry.path())?) {
2630                refs.push(reference);
2631            }
2632        }
2633        refs.sort_by(|left, right| left.name.cmp(&right.name));
2634        Ok(refs)
2635    }
2636
2637    fn reftables(&self) -> Result<Vec<Reftable>> {
2638        let reftable_dir = self.storage_dir.join("reftable");
2639        let tables_list = reftable_dir.join("tables.list");
2640        for _ in 0..10 {
2641            let text = match fs::read_to_string(&tables_list) {
2642                Ok(text) => text,
2643                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
2644                    if !tables_list.exists() {
2645                        return Ok(Vec::new());
2646                    }
2647                    thread::sleep(Duration::from_millis(10));
2648                    continue;
2649                }
2650                Err(err) => return Err(err.into()),
2651            };
2652            let mut tables = Vec::new();
2653            let mut reload = false;
2654            for raw_line in text.lines() {
2655                let line = raw_line.trim();
2656                if line.is_empty() {
2657                    continue;
2658                }
2659                if line.contains('/')
2660                    || line.contains('\\')
2661                    || Path::new(line).components().count() != 1
2662                {
2663                    return Err(GitError::InvalidPath(format!(
2664                        "invalid reftable table name {line}"
2665                    )));
2666                }
2667                let bytes = match fs::read(reftable_dir.join(line)) {
2668                    Ok(bytes) => bytes,
2669                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
2670                        reload = true;
2671                        break;
2672                    }
2673                    Err(err) => return Err(err.into()),
2674                };
2675                let table = Reftable::parse(&bytes)?;
2676                if table.header.object_format != self.format {
2677                    return Err(GitError::InvalidFormat(format!(
2678                        "reftable {line} has {} object ids in {} repository",
2679                        table.header.object_format.name(),
2680                        self.format.name()
2681                    )));
2682                }
2683                tables.push(table);
2684            }
2685            if reload {
2686                thread::sleep(Duration::from_millis(10));
2687                continue;
2688            }
2689            return Ok(tables);
2690        }
2691        Err(GitError::Io(format!(
2692            "cannot read stable reftable stack {}",
2693            tables_list.display()
2694        )))
2695    }
2696
2697    fn reftable_store_with_storage(&self, storage_dir: PathBuf) -> FileRefStore {
2698        FileRefStore {
2699            git_dir: self.git_dir.clone(),
2700            common_dir: self.common_dir.clone(),
2701            storage_dir,
2702            format: self.format,
2703            ref_storage: self.ref_storage.clone(),
2704            packed_refs_lock_timeout_millis: self.packed_refs_lock_timeout_millis,
2705            reftable_lock_timeout_millis: self.reftable_lock_timeout_millis,
2706            reftable_write_options: self.reftable_write_options,
2707            combine_reftable_logs: self.combine_reftable_logs,
2708            prefer_symlink_refs: self.prefer_symlink_refs,
2709            reference_fsync: self.reference_fsync,
2710            shared_repository: self.shared_repository.clone(),
2711        }
2712    }
2713
2714    /// Directory holding the *shared* (non-per-worktree) reftable stack.
2715    ///
2716    /// Normally this is the common dir, but an `extensions.refStorage` /
2717    /// `GIT_REFERENCE_BACKEND` URI with a `://path` payload relocates the
2718    /// stack to that path (e.g. `reftable:///abs/path`). `storage_dir` cannot
2719    /// stand in for this: for a *linked worktree* with no alternate path it is
2720    /// the per-worktree gitdir, not the shared stack. So resolve the shared
2721    /// location explicitly from the configured backend, mirroring the path
2722    /// resolution in `FileRefStore::new`. (Without this, shared refs under an
2723    /// alternate-path backend are looked up in the empty default `reftable/`.)
2724    fn shared_reftable_storage_dir(&self) -> PathBuf {
2725        match self.ref_storage.backend.as_ref() {
2726            Some((_, Some(path))) => path.clone(),
2727            _ => self.common_dir.clone(),
2728        }
2729    }
2730
2731    fn current_worktree_storage_dir(&self) -> PathBuf {
2732        // In the normal linked-worktree layout `storage_dir` is already the
2733        // worktree's administrative gitdir. Only an explicitly relocated
2734        // reftable backend needs the `worktrees/<id>` suffix below.
2735        if self.storage_dir == self.git_dir {
2736            return self.git_dir.clone();
2737        }
2738        if self.storage_dir != self.common_dir {
2739            if self.git_dir != self.common_dir
2740                && let Some(worktree) = self.git_dir.file_name()
2741            {
2742                return self.storage_dir.join("worktrees").join(worktree);
2743            }
2744            return self.storage_dir.clone();
2745        }
2746        self.git_dir.clone()
2747    }
2748
2749    fn reftable_store_for_ref(&self, name: &str) -> Result<(FileRefStore, String)> {
2750        if let Some((worktree, rewritten)) = reftable_other_worktree_ref(name) {
2751            let storage_dir = self
2752                .shared_reftable_storage_dir()
2753                .join("worktrees")
2754                .join(worktree);
2755            return Ok((
2756                self.reftable_store_with_storage(storage_dir),
2757                rewritten.to_string(),
2758            ));
2759        }
2760        if reftable_current_worktree_ref(name) {
2761            return Ok((
2762                self.reftable_store_with_storage(self.current_worktree_storage_dir()),
2763                name.to_string(),
2764            ));
2765        }
2766        Ok((
2767            self.reftable_store_with_storage(self.shared_reftable_storage_dir()),
2768            name.to_string(),
2769        ))
2770    }
2771
2772    pub fn uses_reftable(&self) -> Result<bool> {
2773        if let Some(value) = self.ref_storage.invalid_value.as_deref() {
2774            return parse_ref_storage_backend_value(value)
2775                .map(|(kind, _)| kind == RefBackendKind::Reftable);
2776        }
2777        Ok(matches!(
2778            self.ref_storage.backend,
2779            Some((RefBackendKind::Reftable, _))
2780        ))
2781    }
2782
2783    pub fn reftable_table_count(&self) -> Result<usize> {
2784        Ok(self.reftable_table_names()?.len())
2785    }
2786
2787    fn append_reftable_records(&self, records: Vec<ReftableRefRecord>) -> Result<()> {
2788        if records.is_empty() {
2789            return Ok(());
2790        }
2791        self.append_reftable_table(records, Vec::new())?;
2792        Ok(())
2793    }
2794
2795    fn next_reftable_update_index(&self, table_names: &[String]) -> Result<u64> {
2796        let reftable_dir = self.storage_dir.join("reftable");
2797        let mut max_update_index = 0;
2798        for name in table_names {
2799            let table = Reftable::parse(&fs::read(reftable_dir.join(name))?)?;
2800            max_update_index = max_update_index.max(table.header.max_update_index);
2801        }
2802        max_update_index
2803            .checked_add(1)
2804            .ok_or_else(|| GitError::InvalidFormat("reftable update index overflow".into()))
2805    }
2806
2807    /// Read the table list (file names) backing the reftable stack, oldest first.
2808    fn reftable_table_names(&self) -> Result<Vec<String>> {
2809        self.reftable_table_names_from(&self.storage_dir.join("reftable").join("tables.list"))
2810    }
2811
2812    fn reftable_table_names_from(&self, tables_list: &Path) -> Result<Vec<String>> {
2813        if !tables_list.exists() {
2814            return Ok(Vec::new());
2815        }
2816        Ok(fs::read_to_string(tables_list)?
2817            .lines()
2818            .map(str::trim)
2819            .filter(|line| !line.is_empty())
2820            .map(str::to_string)
2821            .collect())
2822    }
2823
2824    fn reftable_lock_timeout_millis(&self) -> Result<u64> {
2825        if let Some(timeout_millis) = self.reftable_lock_timeout_millis {
2826            return Ok(timeout_millis);
2827        }
2828        let config_path = self.common_dir.join("config");
2829        let Ok(config) = GitConfig::read(config_path) else {
2830            return Ok(0);
2831        };
2832        Ok(config
2833            .get("reftable", None, "lockTimeout")
2834            .and_then(|value| value.parse::<u64>().ok())
2835            .unwrap_or(0))
2836    }
2837
2838    fn acquire_reftable_list_lock(&self, list_path: PathBuf) -> Result<ReftableListLock> {
2839        let lock_path = lock_path_for(&list_path)?;
2840        let timeout_millis = self.reftable_lock_timeout_millis()?;
2841        ReftableListLock::acquire(list_path, lock_path, timeout_millis)
2842    }
2843
2844    /// Append a single combined ref+log table to the stack, allocating the next
2845    /// update index. Either slice may be empty (a log-only table for an
2846    /// `append_reflog` / `delete-reflog`, or a ref-only table for a plain ref
2847    /// write). Returns the allocated update index.
2848    fn append_reftable_table(
2849        &self,
2850        refs: Vec<ReftableRefRecord>,
2851        logs: Vec<ReftableLogRecord>,
2852    ) -> Result<u64> {
2853        self.append_reftable_table_with_compaction(refs, logs, true)
2854    }
2855
2856    fn append_reftable_table_with_compaction(
2857        &self,
2858        mut refs: Vec<ReftableRefRecord>,
2859        mut logs: Vec<ReftableLogRecord>,
2860        auto_compact: bool,
2861    ) -> Result<u64> {
2862        let reftable_dir = self.storage_dir.join("reftable");
2863        fs::create_dir_all(&reftable_dir)?;
2864        let list_path = reftable_dir.join("tables.list");
2865        let list_lock = self.acquire_reftable_list_lock(list_path.clone())?;
2866        let mut table_names = self.reftable_table_names_from(&list_path)?;
2867        let update_index = self.next_reftable_update_index(&table_names)?;
2868        for record in &mut refs {
2869            record.update_index = update_index;
2870        }
2871        for record in &mut logs {
2872            record.update_index = update_index;
2873        }
2874        let table_name = reftable_table_name(update_index, update_index);
2875        let bytes = Reftable::write_with_options(
2876            self.format,
2877            update_index,
2878            update_index,
2879            &refs,
2880            &logs,
2881            self.reftable_write_options,
2882        )?;
2883        let table_path = reftable_dir.join(&table_name);
2884        write_locked(
2885            &table_path,
2886            &bytes,
2887            self.reference_fsync.method_if_enabled(),
2888        )?;
2889        self.apply_reftable_shared_file_mode(&table_path)?;
2890        table_names.push(table_name);
2891        let mut list = Vec::new();
2892        for name in &table_names {
2893            list.extend_from_slice(name.as_bytes());
2894            list.push(b'\n');
2895        }
2896        list_lock.commit(&list, self.reference_fsync.method_if_enabled())?;
2897        self.apply_reftable_shared_file_mode(&list_path)?;
2898        if auto_compact && logs.is_empty() && table_names.len() > 6 {
2899            self.auto_compact_reftable_stack()?;
2900        }
2901        Ok(update_index)
2902    }
2903
2904    pub fn compact_reftable_stack(&self) -> Result<()> {
2905        let old_names = self.reftable_table_names()?;
2906        self.compact_reftable_stack_range(0, old_names.len(), true)
2907    }
2908
2909    fn compact_reftable_stack_range(
2910        &self,
2911        start: usize,
2912        end: usize,
2913        fail_on_locked_table: bool,
2914    ) -> Result<()> {
2915        let reftable_dir = self.storage_dir.join("reftable");
2916        let list_path = reftable_dir.join("tables.list");
2917        let list_lock = self.acquire_reftable_list_lock(list_path.clone())?;
2918        let old_names = self.reftable_table_names_from(&list_path)?;
2919        if start >= end || end > old_names.len() {
2920            return Ok(());
2921        }
2922        let compact_names = old_names[start..end].to_vec();
2923        if compact_names.is_empty() {
2924            return Ok(());
2925        }
2926        if fail_on_locked_table {
2927            for name in &compact_names {
2928                if reftable_dir.join(format!("{name}.lock")).exists() {
2929                    return Err(GitError::Io(format!(
2930                        "cannot lock references: {}: File exists",
2931                        reftable_dir.join(format!("{name}.lock")).display()
2932                    )));
2933                }
2934            }
2935        }
2936
2937        let mut refs: BTreeMap<String, ReftableRefRecord> = BTreeMap::new();
2938        let mut logs: BTreeMap<(String, u64), ReftableLogRecord> = BTreeMap::new();
2939        let mut min_index = u64::MAX;
2940        let mut max_index = 0u64;
2941        let drop_tombstones = start == 0;
2942        for name in &compact_names {
2943            let table = Reftable::parse(&fs::read(reftable_dir.join(name))?)?;
2944            for record in table.refs {
2945                match record.value {
2946                    ReftableRefValue::Deletion if drop_tombstones => {
2947                        refs.remove(&record.name);
2948                    }
2949                    _ => {
2950                        min_index = min_index.min(record.update_index);
2951                        max_index = max_index.max(record.update_index);
2952                        refs.insert(record.name.clone(), record);
2953                    }
2954                }
2955            }
2956            for record in table.logs {
2957                let key = (record.refname.clone(), record.update_index);
2958                match record.value {
2959                    ReftableLogValue::Deletion if drop_tombstones => {
2960                        logs.remove(&key);
2961                    }
2962                    _ => {
2963                        min_index = min_index.min(record.update_index);
2964                        max_index = max_index.max(record.update_index);
2965                        logs.insert(key, record);
2966                    }
2967                }
2968            }
2969        }
2970
2971        if refs.is_empty() && logs.is_empty() {
2972            min_index = compact_names
2973                .iter()
2974                .filter_map(|name| Reftable::parse(&fs::read(reftable_dir.join(name)).ok()?).ok())
2975                .map(|table| table.header.min_update_index)
2976                .min()
2977                .unwrap_or(1);
2978            max_index = min_index;
2979        }
2980
2981        let table_name = reftable_table_name(min_index, max_index);
2982        let refs = refs.into_values().collect::<Vec<_>>();
2983        let logs = logs.into_values().collect::<Vec<_>>();
2984        let bytes = Reftable::write_with_options(
2985            self.format,
2986            min_index,
2987            max_index,
2988            &refs,
2989            &logs,
2990            self.reftable_write_options,
2991        )?;
2992        let table_path = reftable_dir.join(&table_name);
2993        write_locked(
2994            &table_path,
2995            &bytes,
2996            self.reference_fsync.method_if_enabled(),
2997        )?;
2998        self.apply_reftable_shared_file_mode(&table_path)?;
2999        let mut list = Vec::new();
3000        for name in &old_names[..start] {
3001            list.extend_from_slice(name.as_bytes());
3002            list.push(b'\n');
3003        }
3004        list.extend_from_slice(table_name.as_bytes());
3005        list.push(b'\n');
3006        for name in &old_names[end..] {
3007            list.extend_from_slice(name.as_bytes());
3008            list.push(b'\n');
3009        }
3010        list_lock.commit(&list, self.reference_fsync.method_if_enabled())?;
3011        self.apply_reftable_shared_file_mode(&list_path)?;
3012        for name in compact_names {
3013            if name != table_name {
3014                let _ = fs::remove_file(reftable_dir.join(name));
3015            }
3016        }
3017        Ok(())
3018    }
3019
3020    fn apply_reftable_shared_file_mode(&self, path: &Path) -> Result<()> {
3021        #[cfg(unix)]
3022        {
3023            use std::os::unix::fs::PermissionsExt;
3024
3025            let config_path = self.common_dir.join("config");
3026            let Ok(config) = GitConfig::read(config_path) else {
3027                return Ok(());
3028            };
3029            let Some(value) = config.get("core", None, "sharedRepository") else {
3030                return Ok(());
3031            };
3032            let mode_or = match value {
3033                "1" | "group" | "true" => 0o660,
3034                "2" | "all" | "world" | "everybody" => 0o664,
3035                _ => return Ok(()),
3036            };
3037            let metadata = fs::metadata(path)?;
3038            let old_mode = metadata.permissions().mode();
3039            let mut permissions = metadata.permissions();
3040            permissions.set_mode((old_mode | mode_or) & 0o7777);
3041            fs::set_permissions(path, permissions)?;
3042        }
3043        #[cfg(not(unix))]
3044        {
3045            let _ = path;
3046        }
3047        Ok(())
3048    }
3049
3050    fn auto_compact_reftable_stack(&self) -> Result<()> {
3051        if reftable_autocompaction_disabled() {
3052            return Ok(());
3053        }
3054        let names = self.reftable_table_names()?;
3055        if names.len() <= 1 {
3056            return Ok(());
3057        }
3058        let reftable_dir = self.storage_dir.join("reftable");
3059        let header_overhead = match self.format {
3060            ObjectFormat::Sha1 => 23,
3061            ObjectFormat::Sha256 => 27,
3062        };
3063        let sizes = names
3064            .iter()
3065            .map(|name| {
3066                Ok(fs::metadata(reftable_dir.join(name))?
3067                    .len()
3068                    .saturating_sub(header_overhead))
3069            })
3070            .collect::<Result<Vec<_>>>()?;
3071        let factor = self.reftable_geometric_factor()?;
3072        let Some((start, end)) = reftable_compaction_segment(&sizes, factor) else {
3073            return Ok(());
3074        };
3075
3076        // Git locks a candidate segment newest-to-oldest. If it encounters a
3077        // table already owned by another compactor, it still merges the newer
3078        // suffix when at least two of those tables are available. This is what
3079        // lets writers continue restoring the geometric sequence behind a
3080        // long-lived lock on an older table.
3081        let start = names[start..end]
3082            .iter()
3083            .rposition(|name| reftable_dir.join(format!("{name}.lock")).exists())
3084            .map_or(start, |locked| start + locked + 1);
3085        if end.saturating_sub(start) < 2 {
3086            return Ok(());
3087        }
3088        self.compact_reftable_stack_range(start, end, false)?;
3089        Ok(())
3090    }
3091
3092    fn reftable_geometric_factor(&self) -> Result<u64> {
3093        let config_path = self.common_dir.join("config");
3094        let Ok(config) = GitConfig::read(config_path) else {
3095            return Ok(2);
3096        };
3097        Ok(config
3098            .get("reftable", None, "geometricFactor")
3099            .and_then(|value| value.parse::<u8>().ok())
3100            .filter(|factor| *factor != 0)
3101            .map(u64::from)
3102            .unwrap_or(2))
3103    }
3104
3105    /// Merge the log records for `name` across the whole stack into the reflog
3106    /// entries `git reflog` expects, in *oldest-first* order (the loose-file
3107    /// order callers reverse for display). Later tables in the stack override
3108    /// earlier ones for the same `(refname, update_index)`, deletions mask
3109    /// entries, and the old==new==null existence marker is dropped (it records
3110    /// reflog existence, not a real entry).
3111    fn read_reftable_logs(&self, name: &str) -> Result<Vec<ReflogEntry>> {
3112        // Collect the newest value for each update_index, honoring deletions.
3113        // update_index ascending == chronological order.
3114        let mut by_index: BTreeMap<u64, Option<ReftableLogUpdate>> = BTreeMap::new();
3115        for table in self.reftables()? {
3116            for record in table.logs {
3117                if record.refname != name {
3118                    continue;
3119                }
3120                match record.value {
3121                    ReftableLogValue::Deletion => {
3122                        by_index.insert(record.update_index, None);
3123                    }
3124                    ReftableLogValue::Update(update) => {
3125                        by_index.insert(record.update_index, Some(update));
3126                    }
3127                }
3128            }
3129        }
3130        let null = ObjectId::null(self.format);
3131        let mut entries = Vec::new();
3132        for update in by_index.into_values().flatten() {
3133            // Drop the existence marker (old==new==null): it is not a real entry.
3134            if reftable_log_update_is_empty_marker(&update, null) {
3135                continue;
3136            }
3137            entries.push(reflog_entry_from_reftable(update));
3138        }
3139        Ok(entries)
3140    }
3141
3142    fn reftable_log_exists(&self, name: &str) -> Result<bool> {
3143        let mut by_index: BTreeMap<u64, bool> = BTreeMap::new();
3144        for table in self.reftables()? {
3145            for record in table.logs {
3146                if record.refname != name {
3147                    continue;
3148                }
3149                match record.value {
3150                    ReftableLogValue::Deletion => {
3151                        by_index.insert(record.update_index, false);
3152                    }
3153                    ReftableLogValue::Update(_) => {
3154                        by_index.insert(record.update_index, true);
3155                    }
3156                }
3157            }
3158        }
3159        Ok(by_index.into_values().any(|exists| exists))
3160    }
3161
3162    fn collect_loose_refs(
3163        &self,
3164        dir: &Path,
3165        prefix: &str,
3166        refs: &mut BTreeMap<String, Ref>,
3167    ) -> Result<()> {
3168        for entry in fs::read_dir(dir)? {
3169            let entry = entry?;
3170            let path = entry.path();
3171            let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
3172            if path.is_dir() {
3173                self.collect_loose_refs(&path, &name, refs)?;
3174            } else if !name.ends_with(".lock") {
3175                let bytes = match fs::read(&path) {
3176                    Ok(bytes) => bytes,
3177                    Err(err)
3178                        if entry.file_type()?.is_symlink()
3179                            && (err.kind() == std::io::ErrorKind::NotFound
3180                                || err.kind() == std::io::ErrorKind::NotADirectory) =>
3181                    {
3182                        continue;
3183                    }
3184                    Err(err) => return Err(err.into()),
3185                };
3186                if loose_ref_bytes_are_reftable_sentinel(&name, &bytes) {
3187                    continue;
3188                }
3189                // git marks a loose ref whose content is unparseable, or that
3190                // resolves to the null OID, as REF_ISBROKEN and skips it from
3191                // iteration with a warning instead of aborting the whole walk.
3192                match parse_loose_ref(self.format, name.clone(), &bytes) {
3193                    Ok(reference) if ref_target_is_broken(&reference.target) => {
3194                        warn_broken_ref(&name);
3195                    }
3196                    Ok(reference) => {
3197                        refs.insert(name, reference);
3198                    }
3199                    Err(_) => warn_broken_ref(&name),
3200                }
3201            }
3202        }
3203        Ok(())
3204    }
3205
3206    fn collect_loose_ref_snapshot(
3207        &self,
3208        dir: &Path,
3209        prefix: &str,
3210        entries: &mut BTreeMap<String, RefReadSnapshotValue>,
3211    ) -> Result<()> {
3212        let read_dir = match fs::read_dir(dir) {
3213            Ok(read_dir) => read_dir,
3214            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
3215            Err(err) => return Err(err.into()),
3216        };
3217        for entry in read_dir {
3218            let entry = entry?;
3219            let path = entry.path();
3220            let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
3221            if path.is_dir() {
3222                self.collect_loose_ref_snapshot(&path, &name, entries)?;
3223                continue;
3224            }
3225            if name.ends_with(".lock") {
3226                continue;
3227            }
3228            if entry.file_type()?.is_symlink()
3229                && let Ok(target) = fs::read_link(&path)
3230                && target.to_string_lossy().starts_with("refs/")
3231            {
3232                entries.insert(
3233                    name,
3234                    RefReadSnapshotValue::Target(RefTarget::Symbolic(
3235                        target.to_string_lossy().into_owned(),
3236                    )),
3237                );
3238                continue;
3239            }
3240            let bytes = match fs::read(&path) {
3241                Ok(bytes) => bytes,
3242                Err(err)
3243                    if entry.file_type()?.is_symlink()
3244                        && (err.kind() == std::io::ErrorKind::NotFound
3245                            || err.kind() == std::io::ErrorKind::NotADirectory) =>
3246                {
3247                    continue;
3248                }
3249                Err(err) => return Err(err.into()),
3250            };
3251            if loose_ref_bytes_are_reftable_sentinel(&name, &bytes) {
3252                continue;
3253            }
3254            let value = match parse_loose_ref(self.format, name.clone(), &bytes) {
3255                Ok(reference)
3256                    if validate_ref_name(&name).is_ok()
3257                        && !ref_target_is_broken(&reference.target) =>
3258                {
3259                    RefReadSnapshotValue::Target(reference.target)
3260                }
3261                Ok(_) | Err(_) => RefReadSnapshotValue::Broken,
3262            };
3263            entries.insert(name, value);
3264        }
3265        Ok(())
3266    }
3267
3268    fn collect_loose_refs_with_prefix(
3269        &self,
3270        prefix: &str,
3271        refs: &mut BTreeMap<String, Ref>,
3272    ) -> Result<()> {
3273        self.collect_loose_refs_with_prefix_from_base(&self.storage_dir, prefix, refs)
3274    }
3275
3276    fn collect_current_worktree_loose_refs_with_prefix(
3277        &self,
3278        prefix: &str,
3279        refs: &mut BTreeMap<String, Ref>,
3280    ) -> Result<()> {
3281        let base = self.current_worktree_storage_dir();
3282        if base == self.storage_dir {
3283            return Ok(());
3284        }
3285        for namespace in CURRENT_WORKTREE_REF_PREFIXES {
3286            if ref_prefixes_overlap(prefix, namespace) {
3287                let scan_prefix = if prefix.starts_with(namespace) {
3288                    prefix
3289                } else {
3290                    namespace
3291                };
3292                self.collect_loose_refs_with_prefix_from_base(&base, scan_prefix, refs)?;
3293            }
3294        }
3295        Ok(())
3296    }
3297
3298    fn collect_loose_refs_with_prefix_from_base(
3299        &self,
3300        base: &Path,
3301        prefix: &str,
3302        refs: &mut BTreeMap<String, Ref>,
3303    ) -> Result<()> {
3304        if !safe_ref_prefix_for_directory_scan(prefix) {
3305            return self.collect_all_loose_refs_from_base(base, refs);
3306        }
3307
3308        let trimmed = prefix.trim_end_matches('/');
3309        if prefix.ends_with('/') {
3310            let candidate = base.join(trimmed);
3311            match fs::metadata(&candidate) {
3312                Ok(meta) if meta.is_dir() => {
3313                    self.collect_loose_refs(&candidate, trimmed, refs)?;
3314                    return Ok(());
3315                }
3316                Ok(_) => return Ok(()),
3317                Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
3318                Err(err) => return Err(err.into()),
3319            }
3320        }
3321
3322        let Some((parent_prefix, _)) = trimmed.rsplit_once('/') else {
3323            return self.collect_all_loose_refs_from_base(base, refs);
3324        };
3325        let parent = base.join(parent_prefix);
3326        match fs::metadata(&parent) {
3327            Ok(meta) if meta.is_dir() => self.collect_loose_refs(&parent, parent_prefix, refs),
3328            Ok(_) => Ok(()),
3329            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
3330            Err(err) => Err(err.into()),
3331        }
3332    }
3333
3334    fn collect_loose_ref_names(
3335        &self,
3336        dir: &Path,
3337        prefix: &str,
3338        names: &mut BTreeSet<String>,
3339    ) -> Result<()> {
3340        for entry in fs::read_dir(dir)? {
3341            let entry = entry?;
3342            let path = entry.path();
3343            let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
3344            if path.is_dir() {
3345                self.collect_loose_ref_names(&path, &name, names)?;
3346            } else if !name.ends_with(".lock") {
3347                names.insert(name);
3348            }
3349        }
3350        Ok(())
3351    }
3352
3353    fn collect_loose_ref_names_with_prefix(
3354        &self,
3355        prefix: &str,
3356        names: &mut BTreeSet<String>,
3357    ) -> Result<()> {
3358        self.collect_loose_ref_names_with_prefix_from_base(&self.storage_dir, prefix, names)
3359    }
3360
3361    fn collect_current_worktree_loose_ref_names_with_prefix(
3362        &self,
3363        prefix: &str,
3364        names: &mut BTreeSet<String>,
3365    ) -> Result<()> {
3366        let base = self.current_worktree_storage_dir();
3367        if base == self.storage_dir {
3368            return Ok(());
3369        }
3370        for namespace in CURRENT_WORKTREE_REF_PREFIXES {
3371            if ref_prefixes_overlap(prefix, namespace) {
3372                let scan_prefix = if prefix.starts_with(namespace) {
3373                    prefix
3374                } else {
3375                    namespace
3376                };
3377                self.collect_loose_ref_names_with_prefix_from_base(&base, scan_prefix, names)?;
3378            }
3379        }
3380        Ok(())
3381    }
3382
3383    fn collect_loose_ref_names_with_prefix_from_base(
3384        &self,
3385        base: &Path,
3386        prefix: &str,
3387        names: &mut BTreeSet<String>,
3388    ) -> Result<()> {
3389        if !safe_ref_prefix_for_directory_scan(prefix) {
3390            return self.collect_all_loose_ref_names_from_base(base, names);
3391        }
3392
3393        let trimmed = prefix.trim_end_matches('/');
3394        if prefix.ends_with('/') {
3395            let candidate = base.join(trimmed);
3396            match fs::metadata(&candidate) {
3397                Ok(meta) if meta.is_dir() => {
3398                    self.collect_loose_ref_names(&candidate, trimmed, names)?;
3399                    return Ok(());
3400                }
3401                Ok(_) => return Ok(()),
3402                Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
3403                Err(err) => return Err(err.into()),
3404            }
3405        }
3406
3407        let Some((parent_prefix, _)) = trimmed.rsplit_once('/') else {
3408            return self.collect_all_loose_ref_names_from_base(base, names);
3409        };
3410        let parent = base.join(parent_prefix);
3411        match fs::metadata(&parent) {
3412            Ok(meta) if meta.is_dir() => {
3413                self.collect_loose_ref_names(&parent, parent_prefix, names)
3414            }
3415            Ok(_) => Ok(()),
3416            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
3417            Err(err) => Err(err.into()),
3418        }
3419    }
3420
3421    fn collect_all_loose_ref_names_from_base(
3422        &self,
3423        base: &Path,
3424        names: &mut BTreeSet<String>,
3425    ) -> Result<()> {
3426        let refs_dir = base.join("refs");
3427        if refs_dir.exists() {
3428            self.collect_loose_ref_names(&refs_dir, "refs", names)?;
3429        }
3430        Ok(())
3431    }
3432
3433    fn collect_all_loose_refs_from_base(
3434        &self,
3435        base: &Path,
3436        refs: &mut BTreeMap<String, Ref>,
3437    ) -> Result<()> {
3438        let refs_dir = base.join("refs");
3439        if refs_dir.exists() {
3440            self.collect_loose_refs(&refs_dir, "refs", refs)?;
3441        }
3442        Ok(())
3443    }
3444
3445    fn collect_reflog_names(
3446        &self,
3447        dir: &Path,
3448        prefix: &str,
3449        names: &mut BTreeSet<String>,
3450    ) -> Result<()> {
3451        let Ok(entries) = fs::read_dir(dir) else {
3452            return Ok(());
3453        };
3454        for entry in entries {
3455            let entry = entry?;
3456            let path = entry.path();
3457            let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
3458            if path.is_dir() {
3459                self.collect_reflog_names(&path, &name, names)?;
3460            } else if let Some(name) = name.strip_prefix("logs/") {
3461                names.insert(name.to_string());
3462            }
3463        }
3464        Ok(())
3465    }
3466
3467    fn loose_refs_have_prefix(&self, prefix: &str) -> Result<bool> {
3468        if !prefix.starts_with("refs/") || !prefix.ends_with('/') {
3469            return Ok(self
3470                .list_refs()?
3471                .iter()
3472                .any(|reference| reference.name.starts_with(prefix)));
3473        }
3474        let loose_prefix = prefix.trim_end_matches('/');
3475        let dir = self.common_dir.join(loose_prefix);
3476        match fs::metadata(&dir) {
3477            Ok(meta) if meta.is_dir() => {
3478                let mut refs = BTreeMap::new();
3479                self.collect_loose_refs(&dir, loose_prefix, &mut refs)?;
3480                Ok(!refs.is_empty())
3481            }
3482            Ok(_) => Ok(false),
3483            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
3484            Err(err) => Err(err.into()),
3485        }
3486    }
3487
3488    fn write_loose_ref(&self, reference: &Ref) -> Result<()> {
3489        if self.uses_reftable()? {
3490            let (store, name) = self.reftable_store_for_ref(&reference.name)?;
3491            store.append_reftable_records(vec![ReftableRefRecord {
3492                name,
3493                update_index: 0,
3494                value: reftable_value_from_ref_target(&reference.target),
3495            }])?;
3496            return Ok(());
3497        }
3498        let path = self.ref_path(&reference.name);
3499        let parent = path
3500            .parent()
3501            .ok_or_else(|| GitError::InvalidPath("ref path has no parent".into()))?;
3502        self.shared_repository.create_dir_all(parent)?;
3503        write_locked(
3504            &path,
3505            &write_loose_ref(reference),
3506            self.reference_fsync.method_if_enabled(),
3507        )?;
3508        self.shared_repository.adjust_file(&path)
3509    }
3510
3511    fn delete_loose_ref(&self, name: &str) -> Result<()> {
3512        let path = self.ref_path(name);
3513        let lock_path = lock_path_for(&path)?;
3514        {
3515            let mut file = fs::OpenOptions::new()
3516                .write(true)
3517                .create_new(true)
3518                .open(&lock_path)?;
3519            file.write_all(b"delete\n")?;
3520            if let Some(method) = self.reference_fsync.method_if_enabled() {
3521                sync_reference_file(&file, method)?;
3522            }
3523        }
3524        match fs::remove_file(&path) {
3525            Ok(()) => {
3526                fs::remove_file(lock_path)?;
3527                self.prune_empty_ref_dirs(name);
3528                Ok(())
3529            }
3530            Err(err) => {
3531                let _ = fs::remove_file(lock_path);
3532                Err(GitError::Io(err.to_string()))
3533            }
3534        }
3535    }
3536
3537    /// Remove a loose ref after its value has been committed to packed-refs.
3538    ///
3539    /// Unlike a normal ref deletion, this is only a cleanup of a redundant
3540    /// copy: the packed-refs lock has already provided the durable update. Do
3541    /// not fsync the temporary per-ref lock, which would turn packing a large
3542    /// loose-ref set into one durability barrier per ref. Holding the lock
3543    /// still excludes cooperating writers, and the value check preserves a
3544    /// loose update that raced with creation of the packed snapshot.
3545    fn prune_loose_ref_after_pack(&self, name: &str, packed_oid: &ObjectId) -> Result<bool> {
3546        let path = self.ref_path(name);
3547        let lock_path = lock_path_for(&path)?;
3548        let lock = fs::OpenOptions::new()
3549            .write(true)
3550            .create_new(true)
3551            .open(&lock_path)?;
3552        let outcome = (|| -> Result<bool> {
3553            let bytes = match fs::read(&path) {
3554                Ok(bytes) => bytes,
3555                Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
3556                Err(err) => return Err(err.into()),
3557            };
3558            let reference = match parse_loose_ref(self.format, name.to_string(), &bytes) {
3559                Ok(reference) => reference,
3560                Err(_) => return Ok(false),
3561            };
3562            if reference.target != RefTarget::Direct(*packed_oid) {
3563                return Ok(false);
3564            }
3565            fs::remove_file(&path)?;
3566            Ok(true)
3567        })();
3568        drop(lock);
3569        let cleanup = fs::remove_file(&lock_path);
3570        let deleted = outcome?;
3571        cleanup?;
3572        if deleted {
3573            self.prune_empty_ref_dirs(name);
3574        }
3575        Ok(deleted)
3576    }
3577
3578    /// Remove now-empty parent directories left after deleting a loose ref,
3579    /// stopping at the first-level namespace under `refs/`. git does this so
3580    /// that, e.g., deleting `refs/heads/l/m` lets `refs/heads/l` be created as a
3581    /// file afterwards (t3200 #14), but it keeps `refs/heads` itself around
3582    /// (t1400 #30).
3583    fn prune_empty_ref_dirs(&self, name: &str) {
3584        let boundary = self.ref_prune_boundary(name);
3585        if let Some(parent) = self.ref_path(name).parent() {
3586            prune_empty_dirs_up_to(parent, &boundary);
3587        }
3588    }
3589
3590    fn ref_prune_boundary(&self, name: &str) -> PathBuf {
3591        let base = self.ref_base_dir(name).to_path_buf();
3592        let mut components = name.split('/');
3593        if components.next() == Some("refs")
3594            && let Some(namespace) = components.next()
3595        {
3596            return base.join("refs").join(namespace);
3597        }
3598        base
3599    }
3600
3601    /// Remove a ref's reflog file and prune any empty parent directories it
3602    /// leaves behind under `logs/refs/`, stopping at the `logs/refs` boundary.
3603    /// Without this, deleting `refs/heads/l/m` leaves `logs/refs/heads/l/` and a
3604    /// later `refs/heads/l` cannot create its own `logs/refs/heads/l` reflog
3605    /// file (t3200 #14, #18).
3606    fn remove_reflog_file(&self, name: &str) {
3607        // Reftable repos keep the reflog inside the table stack, not as a loose
3608        // file: deleting a ref must tombstone its log records or `git stash
3609        // list` / `git reflog` keep surfacing them (t0610 'basic: stash').
3610        if matches!(self.uses_reftable(), Ok(true)) {
3611            let _ = self.tombstone_reftable_logs(name);
3612            return;
3613        }
3614        let path = self.reflog_path(name);
3615        let _ = fs::remove_file(&path);
3616        let base = self.ref_base_dir(name).to_path_buf();
3617        let logs_refs_root = base.join("logs").join("refs");
3618        if let Some(parent) = path.parent() {
3619            prune_empty_dirs_up_to(parent, &logs_refs_root);
3620        }
3621    }
3622
3623    /// Mask every live log record for `name` with deletion tombstones, so the
3624    /// reflog reads as absent. Mirrors git unlinking `logs/<name>` on the loose
3625    /// backend; the reftable analogue is an all-tombstone table.
3626    fn tombstone_reftable_logs(&self, name: &str) -> Result<()> {
3627        self.rewrite_reftable_logs(name, &[], false)
3628    }
3629
3630    /// Mirror git's `files_log_ref_write`: when a transaction updates a branch
3631    /// that `HEAD` symbolically points at, the same reflog entry is also written
3632    /// to `logs/HEAD`. Without this, `git reflog` (which reads the HEAD reflog)
3633    /// misses commits/merges/resets done on the checked-out branch.
3634    ///
3635    /// `head_branch` is HEAD's symref target captured **before** the transaction
3636    /// mutated any refs — using the post-apply value would mis-mirror a
3637    /// transaction that re-points HEAD onto the branch it just updated (e.g.
3638    /// rebase's finish step, which manages `logs/HEAD` itself). When the
3639    /// transaction explicitly updates `HEAD` it owns the HEAD reflog and nothing
3640    /// is mirrored.
3641    fn head_reflog_mirror(
3642        head_branch: Option<&str>,
3643        reflogs: &[(String, ReflogEntry)],
3644    ) -> Vec<(String, ReflogEntry)> {
3645        let Some(head_branch) = head_branch else {
3646            return Vec::new();
3647        };
3648        // A transaction that touches HEAD directly is managing the HEAD reflog
3649        // on its own terms (detach, rebase finish, checkout); don't double-write.
3650        if reflogs.iter().any(|(name, _)| name == "HEAD") {
3651            return Vec::new();
3652        }
3653        reflogs
3654            .iter()
3655            .filter(|(name, _)| name == head_branch)
3656            .map(|(_, entry)| ("HEAD".to_string(), entry.clone()))
3657            .collect()
3658    }
3659
3660    /// HEAD's symref target (`refs/heads/<branch>`) if HEAD is symbolic, else
3661    /// `None`. Read once at the start of a transaction commit so the HEAD-reflog
3662    /// mirror reflects the pre-transaction state.
3663    fn head_symref_target(&self) -> Option<String> {
3664        match self.read_ref("HEAD") {
3665            Ok(Some(RefTarget::Symbolic(branch))) => Some(branch),
3666            _ => None,
3667        }
3668    }
3669
3670    /// Convert a reflog entry to its reftable representation, applying the
3671    /// backend's message ceiling. Git truncates transaction log messages to
3672    /// half the configured block size before handing them to the writer so a
3673    /// pathological commit subject cannot make an otherwise-valid ref update
3674    /// fail solely because of its reflog text.
3675    fn reftable_update_from_reflog(&self, entry: &ReflogEntry) -> Result<ReftableLogUpdate> {
3676        let mut update = reftable_update_from_reflog(entry)?;
3677        let block_size = if self.reftable_write_options.block_size == 0 {
3678            ReftableWriteOptions::default().block_size
3679        } else {
3680            self.reftable_write_options.block_size
3681        };
3682        let limit = (block_size / 2) as usize;
3683        if update.message.len() > limit {
3684            let mut end = limit;
3685            while !update.message.is_char_boundary(end) {
3686                end -= 1;
3687            }
3688            update.message.truncate(end);
3689        }
3690        Ok(update)
3691    }
3692
3693    pub fn append_reflog(&self, name: &str, entry: &ReflogEntry) -> Result<()> {
3694        validate_ref_name_for_read(name)?;
3695        if self.uses_reftable()? {
3696            let update = self.reftable_update_from_reflog(entry)?;
3697            self.append_reftable_table(
3698                Vec::new(),
3699                vec![ReftableLogRecord {
3700                    refname: name.to_string(),
3701                    update_index: 0,
3702                    value: ReftableLogValue::Update(update),
3703                }],
3704            )?;
3705            self.auto_compact_reftable_stack()?;
3706            return Ok(());
3707        }
3708        let path = self.reflog_path(name);
3709        let parent = path
3710            .parent()
3711            .ok_or_else(|| GitError::InvalidPath("reflog path has no parent".into()))?;
3712        self.shared_repository.create_dir_all(parent)?;
3713        let mut file = fs::OpenOptions::new()
3714            .create(true)
3715            .append(true)
3716            .open(&path)?;
3717        file.write_all(&entry.to_line())?;
3718        drop(file);
3719        self.shared_repository.adjust_file(&path)
3720    }
3721
3722    /// Append a completed loose-backend transaction's reflogs with one
3723    /// directory preparation per parent. The caller has already selected the
3724    /// files backend and validated every ref name while coalescing the
3725    /// transaction, so repeating backend/config probes and parent creation for
3726    /// each log entry only adds filesystem work to large batches.
3727    fn append_loose_reflogs(&self, reflogs: Vec<(String, ReflogEntry)>) -> Result<()> {
3728        let mut prepared_parents = BTreeSet::new();
3729        for (name, entry) in reflogs {
3730            let path = self.reflog_path(&name);
3731            let parent = path
3732                .parent()
3733                .ok_or_else(|| GitError::InvalidPath("reflog path has no parent".into()))?;
3734            if prepared_parents.insert(parent.to_path_buf()) {
3735                self.shared_repository.create_dir_all(parent)?;
3736            }
3737            let mut file = fs::OpenOptions::new()
3738                .create(true)
3739                .append(true)
3740                .open(&path)?;
3741            file.write_all(&entry.to_line())?;
3742            drop(file);
3743            self.shared_repository.adjust_file(&path)?;
3744        }
3745        Ok(())
3746    }
3747
3748    /// Replace the entire reflog for `name` in the reftable stack with `entries`
3749    /// (chronological, oldest first). Writes a single new table that tombstones
3750    /// every currently-live log update index for `name` and re-adds the desired
3751    /// entries at fresh update indexes that preserve their order. This is the
3752    /// stack-friendly analogue of rewriting a loose `logs/<name>` file — used by
3753    /// `write_reflog` / reflog expiry. An empty `entries` slice clears the
3754    /// reflog (an empty `git reflog`).
3755    fn rewrite_reftable_logs(
3756        &self,
3757        name: &str,
3758        entries: &[ReflogEntry],
3759        preserve_empty: bool,
3760    ) -> Result<()> {
3761        // Gather every update index that currently carries a live log record for
3762        // `name`, so we can mask them with deletion tombstones.
3763        let mut live_indexes: BTreeSet<u64> = BTreeSet::new();
3764        let mut deleted_indexes: BTreeSet<u64> = BTreeSet::new();
3765        for table in self.reftables()? {
3766            for record in table.logs {
3767                if record.refname != name {
3768                    continue;
3769                }
3770                match record.value {
3771                    ReftableLogValue::Deletion => {
3772                        deleted_indexes.insert(record.update_index);
3773                        live_indexes.remove(&record.update_index);
3774                    }
3775                    ReftableLogValue::Update(_) => {
3776                        live_indexes.insert(record.update_index);
3777                        deleted_indexes.remove(&record.update_index);
3778                    }
3779                }
3780            }
3781        }
3782
3783        let table_names = self.reftable_table_names()?;
3784        let base = self.next_reftable_update_index(&table_names)?;
3785        let mut logs: Vec<ReftableLogRecord> = Vec::new();
3786        // Tombstone the old entries at their original update indexes.
3787        for index in &live_indexes {
3788            logs.push(ReftableLogRecord {
3789                refname: name.to_string(),
3790                update_index: *index,
3791                value: ReftableLogValue::Deletion,
3792            });
3793        }
3794        // Re-add the survivors at fresh, monotonically increasing indexes so
3795        // their chronological order is preserved on the next read.
3796        for (offset, entry) in entries.iter().enumerate() {
3797            let update_index = base
3798                .checked_add(offset as u64)
3799                .ok_or_else(|| GitError::InvalidFormat("reftable update index overflow".into()))?;
3800            logs.push(ReftableLogRecord {
3801                refname: name.to_string(),
3802                update_index,
3803                value: ReftableLogValue::Update(self.reftable_update_from_reflog(entry)?),
3804            });
3805        }
3806        if entries.is_empty() && preserve_empty {
3807            let null = ObjectId::null(self.format);
3808            logs.push(ReftableLogRecord {
3809                refname: name.to_string(),
3810                update_index: base,
3811                value: ReftableLogValue::Update(ReftableLogUpdate {
3812                    old_oid: null,
3813                    new_oid: null,
3814                    name: String::new(),
3815                    email: String::new(),
3816                    time: 0,
3817                    tz_offset: 0,
3818                    message: String::new(),
3819                }),
3820            });
3821        }
3822        if logs.is_empty() {
3823            return Ok(());
3824        }
3825        let leave_empty_rewrite_tombstones_separate = entries.is_empty();
3826        if leave_empty_rewrite_tombstones_separate
3827            && !reftable_autocompaction_disabled()
3828            && self.reftable_table_names()?.len() > 1
3829        {
3830            self.compact_reftable_stack()?;
3831        }
3832        self.append_reftable_table_spanning(Vec::new(), logs)?;
3833        if !leave_empty_rewrite_tombstones_separate {
3834            self.auto_compact_reftable_stack()?;
3835        }
3836        Ok(())
3837    }
3838
3839    /// Like [`Self::append_reftable_table`] but the caller has already assigned
3840    /// each record's `update_index`; the new table's header `[min, max]` is set
3841    /// to span them (plus the freshly allocated index for any ref records). Used
3842    /// by reflog rewrites that mix old-index tombstones with new-index entries.
3843    fn append_reftable_table_spanning(
3844        &self,
3845        mut refs: Vec<ReftableRefRecord>,
3846        logs: Vec<ReftableLogRecord>,
3847    ) -> Result<u64> {
3848        let reftable_dir = self.storage_dir.join("reftable");
3849        fs::create_dir_all(&reftable_dir)?;
3850        let list_path = reftable_dir.join("tables.list");
3851        let list_lock = self.acquire_reftable_list_lock(list_path.clone())?;
3852        let mut table_names = self.reftable_table_names_from(&list_path)?;
3853        let alloc_index = self.next_reftable_update_index(&table_names)?;
3854        for record in &mut refs {
3855            record.update_index = alloc_index;
3856        }
3857        // Log tombstones may target update indices from older tables, but the
3858        // new table's range describes the update(s) allocated by this write.
3859        // Git therefore keeps the lower bound at `alloc_index`; including old
3860        // tombstone indices would overlap the preceding table's range and make
3861        // the stack invalid to native reftable readers.
3862        let min_index = alloc_index;
3863        let mut max_index = alloc_index;
3864        for record in &logs {
3865            max_index = max_index.max(record.update_index);
3866        }
3867        for record in &refs {
3868            max_index = max_index.max(record.update_index);
3869        }
3870        let table_name = reftable_table_name(min_index, max_index);
3871        let bytes = Reftable::write_with_options(
3872            self.format,
3873            min_index,
3874            max_index,
3875            &refs,
3876            &logs,
3877            self.reftable_write_options,
3878        )?;
3879        let table_path = reftable_dir.join(&table_name);
3880        write_locked(
3881            &table_path,
3882            &bytes,
3883            self.reference_fsync.method_if_enabled(),
3884        )?;
3885        self.apply_reftable_shared_file_mode(&table_path)?;
3886        table_names.push(table_name);
3887        let mut list = Vec::new();
3888        for name in &table_names {
3889            list.extend_from_slice(name.as_bytes());
3890            list.push(b'\n');
3891        }
3892        list_lock.commit(&list, self.reference_fsync.method_if_enabled())?;
3893        self.apply_reftable_shared_file_mode(&list_path)?;
3894        Ok(max_index)
3895    }
3896
3897    fn ref_path(&self, name: &str) -> PathBuf {
3898        self.ref_base_dir(name).join(name)
3899    }
3900
3901    fn display_ref_path(&self, name: &str) -> String {
3902        self.display_storage_path(&self.ref_path(name))
3903    }
3904
3905    fn display_storage_path(&self, path: &Path) -> String {
3906        if let Ok(relative) = path.strip_prefix(&self.git_dir) {
3907            let prefix = self
3908                .git_dir
3909                .file_name()
3910                .and_then(|name| name.to_str())
3911                .unwrap_or(".git");
3912            return format!("{prefix}/{}", relative.to_string_lossy().replace('\\', "/"));
3913        }
3914        path.to_string_lossy().into_owned()
3915    }
3916
3917    fn reflog_path(&self, name: &str) -> PathBuf {
3918        self.ref_base_dir(name).join("logs").join(name)
3919    }
3920
3921    fn ref_base_dir(&self, name: &str) -> PathBuf {
3922        if current_worktree_ref(name) {
3923            self.current_worktree_storage_dir()
3924        } else {
3925            self.storage_dir.clone()
3926        }
3927    }
3928
3929    fn check_ref_directory_conflict_targeted(&self, name: &str) -> Result<()> {
3930        match self.refname_directory_conflict(name)? {
3931            Some(conflict) => Err(ref_directory_conflict_error(name, &conflict)),
3932            None => Ok(()),
3933        }
3934    }
3935
3936    /// Return the existing ref that would directory/file-conflict with creating
3937    /// `name` (an ancestor occupying a needed file, or a descendant occupying a
3938    /// needed directory), or `None` when `name` is creatable. Checks loose and
3939    /// packed refs. Exposed so `update-ref --stdin --batch-updates` can reject a
3940    /// single conflicting update without aborting the rest of the batch.
3941    pub fn refname_directory_conflict(&self, name: &str) -> Result<Option<String>> {
3942        let components = name.split('/').collect::<Vec<_>>();
3943        let mut ancestors = Vec::new();
3944        for index in 1..components.len() {
3945            let ancestor = components[..index].join("/");
3946            if self.loose_ref_file_exists_for_conflict(&ancestor)? {
3947                return Ok(Some(ancestor));
3948            }
3949            ancestors.push(ancestor);
3950        }
3951        let child_prefix = format!("{name}/");
3952        if let Some(existing) = self.first_loose_ref_name_with_prefix(&child_prefix)? {
3953            return Ok(Some(existing));
3954        }
3955        if let Some(existing) =
3956            self.first_packed_ref_directory_conflict(&ancestors, &child_prefix)?
3957        {
3958            return Ok(Some(existing));
3959        }
3960        Ok(None)
3961    }
3962
3963    fn loose_ref_file_exists_for_conflict(&self, name: &str) -> Result<bool> {
3964        let path = self.ref_path(name);
3965        match fs::symlink_metadata(path) {
3966            Ok(meta) if meta.is_dir() => Ok(false),
3967            Ok(meta)
3968                if meta.file_type().is_symlink()
3969                    && fs::metadata(self.ref_path(name)).is_ok_and(|target| target.is_dir()) =>
3970            {
3971                Ok(false)
3972            }
3973            Ok(_) => {
3974                let path = self.ref_path(name);
3975                if let Ok(bytes) = fs::read(path)
3976                    && loose_ref_bytes_are_reftable_sentinel(name, &bytes)
3977                {
3978                    return Ok(false);
3979                }
3980                Ok(true)
3981            }
3982            Err(err)
3983                if err.kind() == std::io::ErrorKind::NotFound
3984                    || err.kind() == std::io::ErrorKind::NotADirectory =>
3985            {
3986                Ok(false)
3987            }
3988            Err(err) => Err(err.into()),
3989        }
3990    }
3991
3992    fn first_packed_ref_directory_conflict(
3993        &self,
3994        ancestors: &[String],
3995        child_prefix: &str,
3996    ) -> Result<Option<String>> {
3997        let packed_path = self.storage_dir.join("packed-refs");
3998        let text = match fs::read_to_string(packed_path) {
3999            Ok(text) => text,
4000            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
4001            Err(err) => return Err(err.into()),
4002        };
4003        for raw_line in text.lines() {
4004            let line = raw_line.trim_end();
4005            if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
4006                continue;
4007            }
4008            let (_, name) = line
4009                .split_once(' ')
4010                .ok_or_else(|| packed_refs_unexpected_line(line))?;
4011            if ancestors.iter().any(|ancestor| ancestor == name) || name.starts_with(child_prefix) {
4012                return Ok(Some(name.to_owned()));
4013            }
4014        }
4015        Ok(None)
4016    }
4017
4018    fn first_loose_ref_name_with_prefix(&self, prefix: &str) -> Result<Option<String>> {
4019        if !prefix.starts_with("refs/") || !prefix.ends_with('/') {
4020            return Ok(None);
4021        }
4022        let trimmed = prefix.trim_end_matches('/');
4023        let dir = self.ref_base_dir(trimmed).join(trimmed);
4024        self.first_loose_ref_name_in_dir(&dir, trimmed)
4025    }
4026
4027    fn first_loose_ref_name_in_dir(&self, dir: &Path, prefix: &str) -> Result<Option<String>> {
4028        let entries = match fs::read_dir(dir) {
4029            Ok(entries) => entries,
4030            Err(err)
4031                if err.kind() == std::io::ErrorKind::NotFound
4032                    || err.kind() == std::io::ErrorKind::NotADirectory =>
4033            {
4034                return Ok(None);
4035            }
4036            Err(err) => return Err(err.into()),
4037        };
4038        for entry in entries {
4039            let entry = entry?;
4040            let path = entry.path();
4041            let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
4042            if path.is_dir() {
4043                if let Some(found) = self.first_loose_ref_name_in_dir(&path, &name)? {
4044                    return Ok(Some(found));
4045                }
4046            } else if !name.ends_with(".lock") {
4047                return Ok(Some(name));
4048            }
4049        }
4050        Ok(None)
4051    }
4052}
4053
4054fn reftable_ref_target(value: ReftableRefValue) -> Result<Option<RefTarget>> {
4055    match value {
4056        ReftableRefValue::Deletion => Ok(None),
4057        ReftableRefValue::Direct(oid) | ReftableRefValue::Peeled { target: oid, .. } => {
4058            Ok(Some(RefTarget::Direct(oid)))
4059        }
4060        ReftableRefValue::Symbolic(target) => Ok(Some(RefTarget::Symbolic(target))),
4061    }
4062}
4063
4064fn reftable_value_from_ref_target(target: &RefTarget) -> ReftableRefValue {
4065    match target {
4066        RefTarget::Direct(oid) => ReftableRefValue::Direct(*oid),
4067        RefTarget::Symbolic(target) => ReftableRefValue::Symbolic(target.clone()),
4068    }
4069}
4070
4071/// Reconstruct a `ReflogEntry`'s flat committer line from a reftable log update.
4072///
4073/// git's reftable backend stores the identity split into `name`/`email`/`time`/
4074/// `tz_offset` (refs/reftable-backend.c::fill_reftable_log_record) and rebuilds
4075/// `Name <email> time tz` on read. The loose reflog committer field is exactly
4076/// that string, so the entry round-trips byte-for-byte with the loose backend.
4077fn reflog_entry_from_reftable(update: ReftableLogUpdate) -> ReflogEntry {
4078    let committer = format!(
4079        "{} <{}> {} {}",
4080        update.name,
4081        update.email,
4082        update.time,
4083        format_reflog_tz(update.tz_offset),
4084    );
4085    // git stores reflog messages with a trailing newline in the reftable record
4086    // (refs/reftable-backend.c passes `u->msg`, which carries the `\n`). A loose
4087    // `ReflogEntry.message` is the newline-free form, so strip the single
4088    // trailing `\n` we add on write.
4089    let mut message = update.message.into_bytes();
4090    if message.last() == Some(&b'\n') {
4091        message.pop();
4092    }
4093    ReflogEntry {
4094        old_oid: update.old_oid,
4095        new_oid: update.new_oid,
4096        committer: committer.into_bytes(),
4097        message,
4098    }
4099}
4100
4101fn reftable_log_update_is_empty_marker(update: &ReftableLogUpdate, null: ObjectId) -> bool {
4102    update.old_oid == null && update.new_oid == null
4103}
4104
4105/// Split a flat reflog committer line (`Name <email> <seconds> <±HHMM>`) plus the
4106/// entry's oids/message into the reftable log update fields.
4107fn reftable_update_from_reflog(entry: &ReflogEntry) -> Result<ReftableLogUpdate> {
4108    let committer = std::str::from_utf8(&entry.committer)
4109        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
4110    let (name, email, time, tz_offset) = split_committer_ident(committer)?;
4111    let mut message = std::str::from_utf8(&entry.message)
4112        .map_err(|err| GitError::InvalidFormat(err.to_string()))?
4113        .to_string();
4114    // git stores reflog messages with a trailing newline in reftable records;
4115    // `%gs` and the loose backend strip it. Add it back so git renders the
4116    // message identically across backends.
4117    if !message.ends_with('\n') {
4118        message.push('\n');
4119    }
4120    Ok(ReftableLogUpdate {
4121        old_oid: entry.old_oid,
4122        new_oid: entry.new_oid,
4123        name,
4124        email,
4125        time,
4126        tz_offset,
4127        message,
4128    })
4129}
4130
4131/// Parse `Name <email> <seconds> <±HHMM>` into the reftable log fields. Mirrors
4132/// git's `split_ident_line` semantics for the committer line: the display name
4133/// is everything before ` <`, the email is between the angle brackets, then the
4134/// unix timestamp and the signed `HHMM` timezone follow.
4135fn split_committer_ident(committer: &str) -> Result<(String, String, u64, i16)> {
4136    let open = committer.find(" <").ok_or_else(|| {
4137        GitError::InvalidFormat("reflog committer is missing email opener".into())
4138    })?;
4139    let name = committer[..open].to_string();
4140    let after_open = open + 2;
4141    let close = committer[after_open..].find('>').ok_or_else(|| {
4142        GitError::InvalidFormat("reflog committer is missing email closer".into())
4143    })?;
4144    let email = committer[after_open..after_open + close].to_string();
4145    let rest = committer[after_open + close + 1..].trim();
4146    let (time_str, tz_str) = rest.split_once(' ').ok_or_else(|| {
4147        GitError::InvalidFormat("reflog committer is missing timestamp/timezone".into())
4148    })?;
4149    let time = time_str
4150        .trim()
4151        .parse::<u64>()
4152        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
4153    let tz_offset = parse_reflog_tz(tz_str.trim())?;
4154    Ok((name, email, time, tz_offset))
4155}
4156
4157/// Format a reftable `tz_offset` (a signed `HHMM` value) as git renders it in a
4158/// committer line, e.g. `120 -> "+0200"`, `-300 -> "-0500"`.
4159fn format_reflog_tz(tz_offset: i16) -> String {
4160    let sign = if tz_offset < 0 { '-' } else { '+' };
4161    let magnitude = tz_offset.unsigned_abs();
4162    format!("{sign}{magnitude:04}")
4163}
4164
4165/// Parse a `±HHMM` timezone token into the raw signed `HHMM` value git stores in
4166/// a reftable log record (refs/reftable-backend.c parses it the same way).
4167fn parse_reflog_tz(tz: &str) -> Result<i16> {
4168    let (sign, digits) = match tz.strip_prefix('-') {
4169        Some(rest) => (-1i16, rest),
4170        None => (1i16, tz.strip_prefix('+').unwrap_or(tz)),
4171    };
4172    let magnitude = digits
4173        .parse::<i16>()
4174        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
4175    Ok(sign * magnitude)
4176}
4177
4178/// Build a reftable file name in git's exact `0x%012x-0x%012x-%08x.ref` shape
4179/// (reftable/stack.c::format_name). git's `table_has_valid_name` (reftable/fsck.c)
4180/// parses all three dash-separated components as hex, so the suffix MUST be a
4181/// pure 8-hex-digit token — a non-hex disambiguator like `-sley-<nanos>` makes
4182/// `git fsck` reject the table with `badReftableTableName`.
4183fn reftable_table_name(min_update_index: u64, max_update_index: u64) -> String {
4184    let nanos = SystemTime::now()
4185        .duration_since(UNIX_EPOCH)
4186        .map(|duration| duration.as_nanos())
4187        .unwrap_or(0);
4188    // Mix the process id in so concurrent writers in the same nanosecond still
4189    // pick distinct names; truncate to 32 bits to match git's `%08x`.
4190    let salt = (nanos as u64) ^ (u64::from(std::process::id()) << 16);
4191    format!(
4192        "0x{min_update_index:012x}-0x{max_update_index:012x}-{:08x}.ref",
4193        salt as u32
4194    )
4195}
4196
4197fn reftable_autocompaction_disabled() -> bool {
4198    std::env::var("GIT_TEST_REFTABLE_AUTOCOMPACTION")
4199        .is_ok_and(|value| value.eq_ignore_ascii_case("false"))
4200}
4201
4202/// Select the oldest-first, end-exclusive table range that must be merged to
4203/// restore the geometric size progression used by Git's reftable stack.
4204fn reftable_compaction_segment(sizes: &[u64], factor: u64) -> Option<(usize, usize)> {
4205    if sizes.len() <= 1 {
4206        return None;
4207    }
4208    let factor = if factor == 0 { 2 } else { factor };
4209
4210    let mut pivot = None;
4211    let mut end = 0;
4212    for index in (1..sizes.len()).rev() {
4213        if sizes[index - 1] < sizes[index].saturating_mul(factor) {
4214            pivot = Some(index);
4215            end = index + 1;
4216            break;
4217        }
4218    }
4219    let mut index = pivot?;
4220    let mut bytes = sizes[index];
4221    let mut start = index;
4222    while index > 0 {
4223        let current = bytes;
4224        bytes = bytes.saturating_add(sizes[index - 1]);
4225        if sizes[index - 1] < current.saturating_mul(factor) {
4226            start = index - 1;
4227        }
4228        index -= 1;
4229    }
4230    (start < end).then_some((start, end))
4231}
4232
4233/// Whether `name` parses as a valid reftable file name the way git's
4234/// `table_has_valid_name` (reftable/fsck.c) does: three hex tokens separated by
4235/// `-`, ending in `.ref` (or `.log`). Used to keep sley's generated names from
4236/// regressing into a shape `git fsck` would reject.
4237#[cfg(test)]
4238fn reftable_table_name_is_valid(name: &str) -> bool {
4239    fn hex_prefix(s: &str) -> Option<&str> {
4240        // strtoull(base 16) skips an optional leading 0x and consumes hex digits.
4241        let body = s
4242            .strip_prefix("0x")
4243            .or_else(|| s.strip_prefix("0X"))
4244            .unwrap_or(s);
4245        let consumed = body
4246            .find(|c: char| !c.is_ascii_hexdigit())
4247            .unwrap_or(body.len());
4248        if consumed == 0 {
4249            return None;
4250        }
4251        Some(&body[consumed..])
4252    }
4253    let Some(rest) = hex_prefix(name) else {
4254        return false;
4255    };
4256    let Some(rest) = rest.strip_prefix('-') else {
4257        return false;
4258    };
4259    let Some(rest) = hex_prefix(rest) else {
4260        return false;
4261    };
4262    let Some(rest) = rest.strip_prefix('-') else {
4263        return false;
4264    };
4265    let Some(rest) = hex_prefix(rest) else {
4266        return false;
4267    };
4268    rest == ".ref" || rest == ".log"
4269}
4270
4271fn repository_common_dir(git_dir: &Path) -> PathBuf {
4272    if let Some(common_dir) = std::env::var_os("GIT_COMMON_DIR") {
4273        return PathBuf::from(common_dir);
4274    }
4275    let commondir = git_dir.join("commondir");
4276    if let Ok(value) = fs::read_to_string(&commondir) {
4277        let path = PathBuf::from(value.trim());
4278        let common = if path.is_absolute() {
4279            path
4280        } else {
4281            git_dir.join(path)
4282        };
4283        return fs::canonicalize(&common).unwrap_or(common);
4284    }
4285    git_dir.to_path_buf()
4286}
4287
4288fn log_all_ref_updates_matches(name: &str, value: &str) -> bool {
4289    if value.eq_ignore_ascii_case("always") {
4290        return true;
4291    }
4292    if !sley_config::parse_config_bool(value).unwrap_or(false) {
4293        return false;
4294    }
4295    name == "HEAD"
4296        || name.starts_with("refs/heads/")
4297        || name.starts_with("refs/remotes/")
4298        || name.starts_with("refs/notes/")
4299}
4300
4301/// The phase a [`ReferenceTransactionHook`] is invoked for, mirroring the
4302/// `state` argument git passes to the `reference-transaction` hook
4303/// (`refs.c:run_transaction_hook`).
4304#[derive(Clone, Copy, PartialEq, Eq, Debug)]
4305pub enum RefTransactionPhase {
4306    /// Before references are locked. A nonzero hook exit aborts the
4307    /// transaction with `in 'preparing' phase, update aborted ...`.
4308    Preparing,
4309    /// After references are locked but before they are written. A nonzero
4310    /// hook exit aborts with `in 'prepared' phase, update aborted ...`.
4311    Prepared,
4312    /// After every ref change has landed. The hook's exit status is ignored.
4313    Committed,
4314    /// When a prepared transaction is rolled back. The exit status is ignored.
4315    Aborted,
4316}
4317
4318impl RefTransactionPhase {
4319    /// The literal `state` string git feeds as `argv[1]` to the hook.
4320    pub fn as_str(self) -> &'static str {
4321        match self {
4322            RefTransactionPhase::Preparing => "preparing",
4323            RefTransactionPhase::Prepared => "prepared",
4324            RefTransactionPhase::Committed => "committed",
4325            RefTransactionPhase::Aborted => "aborted",
4326        }
4327    }
4328}
4329
4330/// One queued ref change as the `reference-transaction` hook sees it: the
4331/// `<old-value> SP <new-value> SP <refname>` triple git writes to the hook's
4332/// stdin (`refs.c:transaction_hook_feed_stdin`). `old_value`/`new_value` are
4333/// already rendered the way git renders them — a 40/64-hex OID, the string
4334/// `ref:<target>` for a symref, or the all-zeros OID when the side is absent.
4335#[derive(Clone, Debug)]
4336pub struct RefTransactionHookUpdate {
4337    pub old_value: String,
4338    pub new_value: String,
4339    pub refname: String,
4340}
4341
4342/// A handler the file backend invokes at each phase of a ref transaction so the
4343/// CLI layer can run the project's `reference-transaction` hook. Implemented in
4344/// `sley-cli`; the backend stays oblivious to how (or whether) a hook script is
4345/// found and executed.
4346///
4347/// `run` returns `Ok(true)` to mean "the hook ran and requested an abort"
4348/// (nonzero exit in a `preparing`/`prepared` phase), `Ok(false)` to mean
4349/// "proceed" (hook absent, succeeded, or a non-abortable phase), and `Err` only
4350/// for an I/O failure spawning the hook. The backend turns an abort request in
4351/// the prepare phases into the git-shaped `in '<phase>' phase, update aborted by
4352/// the reference-transaction hook` failure.
4353pub trait ReferenceTransactionHook {
4354    fn run(&self, phase: RefTransactionPhase, updates: &[RefTransactionHookUpdate])
4355    -> Result<bool>;
4356}
4357
4358pub struct FileRefTransaction<'a> {
4359    store: &'a FileRefStore,
4360    changes: Vec<QueuedRefChange>,
4361    hook: Option<&'a dyn ReferenceTransactionHook>,
4362}
4363
4364/// One queued update inside a [`FileRefTransaction`], carrying the
4365/// compare-and-swap precondition to enforce under lock.
4366struct QueuedUpdate {
4367    name: String,
4368    precondition: RefPrecondition,
4369    new: RefTarget,
4370    reflog: Option<ReflogEntry>,
4371}
4372
4373struct QueuedDelete {
4374    name: String,
4375    precondition: RefDeletePrecondition,
4376}
4377
4378enum QueuedRefChange {
4379    Update(QueuedUpdate),
4380    Delete(QueuedDelete),
4381}
4382
4383/// The compare-and-delete precondition checked for a queued ref delete.
4384#[derive(Debug, Clone, PartialEq, Eq)]
4385pub enum RefDeletePrecondition {
4386    /// Any existing direct or symbolic ref may be deleted.
4387    Any,
4388    /// The ref's immediate target must match exactly.
4389    Immediate(RefTarget),
4390    /// The ref must be direct. When an object id is supplied, it must match.
4391    Direct(Option<ObjectId>),
4392    /// The ref may be symbolic, but its peeled direct target must match.
4393    Peeled(ObjectId),
4394}
4395
4396impl<'a> FileRefTransaction<'a> {
4397    /// Attach the `reference-transaction` hook handler this transaction fires at
4398    /// each phase. Without one the transaction behaves exactly as before (no
4399    /// hook is run). This is the single point through which every ref-write path
4400    /// — `update-ref`, `symbolic-ref`, `update-ref --stdin`, push — gets hook
4401    /// coverage, so a new write site cannot silently skip the hook.
4402    pub fn with_hook(mut self, hook: &'a dyn ReferenceTransactionHook) -> Self {
4403        self.hook = Some(hook);
4404        self
4405    }
4406
4407    /// Queue a ref update whose precondition comes from [`RefUpdate::expected`]
4408    /// (`None` = no check; `Some(target)` = the ref must currently match
4409    /// `target`). For create-only or match-or-create semantics use
4410    /// [`update_to`](FileRefTransaction::update_to).
4411    pub fn update(&mut self, update: RefUpdate) {
4412        self.changes.push(QueuedRefChange::Update(QueuedUpdate {
4413            name: update.name,
4414            precondition: RefPrecondition::from_expected(update.expected),
4415            new: update.new,
4416            reflog: update.reflog,
4417        }));
4418    }
4419
4420    /// Queue a ref update with an explicit compare-and-swap [`RefPrecondition`]
4421    /// (e.g. [`MustNotExist`](RefPrecondition::MustNotExist) for create-only, or
4422    /// [`ExistingMustMatch`](RefPrecondition::ExistingMustMatch) for
4423    /// match-or-create). The precondition is re-verified while the ref is
4424    /// locked.
4425    pub fn update_to(
4426        &mut self,
4427        name: impl Into<String>,
4428        new: RefTarget,
4429        precondition: RefPrecondition,
4430        reflog: Option<ReflogEntry>,
4431    ) {
4432        self.changes.push(QueuedRefChange::Update(QueuedUpdate {
4433            name: name.into(),
4434            precondition,
4435            new,
4436            reflog,
4437        }));
4438    }
4439
4440    /// Queue a direct ref delete using the historical checked-delete shape.
4441    ///
4442    /// `expected_old = None` means "delete any direct ref"; `Some(oid)` means
4443    /// the direct ref must currently point at that object id.
4444    pub fn delete(&mut self, delete: DeleteRef) {
4445        self.delete_with_precondition(
4446            delete.name,
4447            RefDeletePrecondition::Direct(delete.expected_old),
4448            delete.reflog,
4449        );
4450    }
4451
4452    /// Queue a ref delete with an explicit direct/symbolic precondition.
4453    ///
4454    /// `_reflog` is accepted for API compatibility but ignored: git unlinks the
4455    /// reflog on delete rather than writing a deletion entry, so a
4456    /// caller-supplied deletion message has no on-disk effect.
4457    pub fn delete_with_precondition(
4458        &mut self,
4459        name: impl Into<String>,
4460        precondition: RefDeletePrecondition,
4461        _reflog: Option<DeleteRefReflog>,
4462    ) {
4463        self.changes.push(QueuedRefChange::Delete(QueuedDelete {
4464            name: name.into(),
4465            precondition,
4466        }));
4467    }
4468
4469    /// Commit all queued updates and deletes atomically and durably.
4470    ///
4471    /// All ref changes succeed together or none take effect. For the loose-ref
4472    /// backend the sequence is:
4473    ///
4474    /// 1. Preserve the historical update-only coalescing behavior. Mixed
4475    ///    transactions reject duplicate ref names so a delete and write cannot
4476    ///    target the same ref ambiguously.
4477    /// 2. Take an exclusive `<ref>.lock` file for every ref up front, and lock
4478    ///    `packed-refs` before checked deletes can inspect or rewrite it.
4479    /// 3. Re-verify every precondition *while holding the locks*, closing
4480    ///    the check-then-write race that a pre-lock verification would leave open.
4481    /// 4. Stage every write, delete marker, and packed-refs rewrite.
4482    /// 5. Rename/remove staged paths, rolling back already-applied paths if a
4483    ///    later step fails.
4484    ///
4485    /// If any step fails, every path already changed in this commit is restored
4486    /// to the exact bytes it held beforehand (or removed if it did not exist),
4487    /// and all outstanding lock files are deleted. Reflog entries are appended
4488    /// only after every ref change has landed.
4489    pub fn commit(self) -> Result<()> {
4490        if env::var_os("GIT_QUARANTINE_PATH").is_some() {
4491            return Err(GitError::Transaction(
4492                "ref updates forbidden inside quarantine environment".into(),
4493            ));
4494        }
4495        let FileRefTransaction {
4496            store,
4497            changes,
4498            hook,
4499        } = self;
4500        let changes = coalesce_ref_changes(changes)?;
4501        // Derive the `<old> <new> <refname>` lines the reference-transaction
4502        // hook sees, in the same coalesced order the writes apply. This is the
4503        // single place the hook is fed, so loose, packed, and symref updates all
4504        // flow through one firing path (git's run_transaction_hook).
4505        let hook_updates = hook.map(|_| hook_updates_for_changes(store.format, &changes));
4506        if let (Some(hook), Some(updates)) = (hook, hook_updates.as_ref())
4507            && hook.run(RefTransactionPhase::Preparing, updates)?
4508        {
4509            return Err(ref_transaction_hook_abort(RefTransactionPhase::Preparing));
4510        }
4511        if store.uses_reftable()? {
4512            store.commit_reftable(changes)
4513        } else {
4514            store.commit_loose_hooked(changes, hook, hook_updates.as_deref())
4515        }
4516    }
4517}
4518
4519/// The git-shaped fatal raised when the `reference-transaction` hook requests an
4520/// abort in the `preparing`/`prepared` phase (`refs.c:abort_by_ref_transaction_hook`).
4521fn ref_transaction_hook_abort(phase: RefTransactionPhase) -> GitError {
4522    GitError::Transaction(format!(
4523        "in '{}' phase, update aborted by the reference-transaction hook",
4524        phase.as_str()
4525    ))
4526}
4527
4528/// Render the per-update hook lines for a coalesced change set, matching git's
4529/// `transaction_hook_feed_stdin`: the old side is `null_oid` when no old value
4530/// was required, `ref:<target>` for a symref precondition, else the expected
4531/// OID; the new side is `null_oid` for a delete, `ref:<target>` for a new
4532/// symref, else the new OID.
4533fn hook_updates_for_changes(
4534    format: ObjectFormat,
4535    changes: &[CoalescedRefChange],
4536) -> Vec<RefTransactionHookUpdate> {
4537    let zero = ObjectId::null(format).to_string();
4538    changes
4539        .iter()
4540        .map(|change| match change {
4541            CoalescedRefChange::Update(update) => RefTransactionHookUpdate {
4542                old_value: hook_old_value(&zero, &update.precondition),
4543                new_value: hook_target_value(&zero, Some(&update.new)),
4544                refname: update.name.clone(),
4545            },
4546            CoalescedRefChange::Delete(delete) => RefTransactionHookUpdate {
4547                old_value: hook_delete_old_value(&zero, &delete.precondition),
4548                new_value: zero.clone(),
4549                refname: delete.name.clone(),
4550            },
4551        })
4552        .collect()
4553}
4554
4555/// The hook's `<old-value>` for an update: git prints `null_oid` unless the
4556/// caller supplied an old value (`REF_HAVE_OLD`), in which case it is the
4557/// expected target (a `ref:` for a symref expectation, else the OID).
4558fn hook_old_value(zero: &str, precondition: &RefPrecondition) -> String {
4559    match precondition {
4560        RefPrecondition::Any | RefPrecondition::MustExist => zero.to_string(),
4561        RefPrecondition::MustNotExist => zero.to_string(),
4562        RefPrecondition::MustExistAndMatch(target) | RefPrecondition::ExistingMustMatch(target) => {
4563            hook_target_value(zero, Some(target))
4564        }
4565    }
4566}
4567
4568/// The hook's `<old-value>` for a delete: git renders the supplied old OID, or
4569/// `null_oid` when none was required.
4570fn hook_delete_old_value(zero: &str, precondition: &RefDeletePrecondition) -> String {
4571    match precondition {
4572        RefDeletePrecondition::Any => zero.to_string(),
4573        RefDeletePrecondition::Immediate(target) => hook_target_value(zero, Some(target)),
4574        RefDeletePrecondition::Direct(Some(oid)) | RefDeletePrecondition::Peeled(oid) => {
4575            oid.to_string()
4576        }
4577        RefDeletePrecondition::Direct(None) => zero.to_string(),
4578    }
4579}
4580
4581/// Render a [`RefTarget`] the way git renders a hook value: `ref:<target>` for a
4582/// symref, the bare OID for a direct ref, or `null_oid` when absent.
4583fn hook_target_value(zero: &str, target: Option<&RefTarget>) -> String {
4584    match target {
4585        None => zero.to_string(),
4586        Some(RefTarget::Direct(oid)) => oid.to_string(),
4587        Some(RefTarget::Symbolic(name)) => format!("ref:{name}"),
4588    }
4589}
4590
4591impl FileRefStore {
4592    fn commit_reftable(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
4593        let routed = self.route_reftable_changes(changes)?;
4594        if routed.len() != 1 || routed[0].0.storage_dir != self.storage_dir {
4595            for (store, changes) in routed {
4596                store.commit_reftable_local(changes)?;
4597            }
4598            return Ok(());
4599        }
4600        let mut routed = routed;
4601        if let Some((_, changes)) = routed.pop() {
4602            self.commit_reftable_local(changes)
4603        } else {
4604            Ok(())
4605        }
4606    }
4607
4608    fn commit_reftable_local(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
4609        // Capture HEAD's symref target only when there is a reflog to mirror.
4610        let has_reflogs = changes.iter().any(|change| {
4611            matches!(change, CoalescedRefChange::Update(update) if !update.reflog.is_empty())
4612        });
4613        let head_branch = if has_reflogs {
4614            self.head_symref_target()
4615        } else {
4616            None
4617        };
4618        if changes
4619            .iter()
4620            .any(|change| matches!(change, CoalescedRefChange::Update(_)))
4621        {
4622            let mut names = self
4623                .list_refs()?
4624                .into_iter()
4625                .map(|reference| reference.name)
4626                .collect::<BTreeSet<_>>();
4627            for change in &changes {
4628                if let CoalescedRefChange::Update(update) = change {
4629                    names.insert(update.name.clone());
4630                }
4631            }
4632            for change in &changes {
4633                if let CoalescedRefChange::Update(update) = change {
4634                    check_ref_directory_conflict_in_names(&update.name, &names)?;
4635                }
4636            }
4637        }
4638        let mut records = Vec::with_capacity(changes.len());
4639        let mut reflogs = Vec::new();
4640        let mut delete_names = Vec::new();
4641        for change in changes {
4642            match change {
4643                CoalescedRefChange::Update(update) => {
4644                    if !matches!(update.precondition, RefPrecondition::Any) {
4645                        let current = self.read_ref(&update.name)?;
4646                        if !update.precondition.is_satisfied_by(current.as_ref()) {
4647                            return Err(GitError::Transaction(
4648                                update.precondition.describe(&update.name),
4649                            ));
4650                        }
4651                    }
4652                    records.push(ReftableRefRecord {
4653                        name: update.name.clone(),
4654                        update_index: 0,
4655                        value: reftable_value_from_ref_target(&update.new),
4656                    });
4657                    for entry in update.reflog {
4658                        reflogs.push((update.name.clone(), entry));
4659                    }
4660                }
4661                CoalescedRefChange::Delete(delete) => {
4662                    let current = self.read_ref(&delete.name)?;
4663                    // Enforce the precondition; git unlinks logs/refs/<name> on
4664                    // delete rather than appending a deletion reflog entry, so the
4665                    // returned OID is unused.
4666                    verify_delete_precondition(
4667                        self,
4668                        &delete.name,
4669                        current.as_ref(),
4670                        &delete.precondition,
4671                    )?;
4672                    records.push(ReftableRefRecord {
4673                        name: delete.name.clone(),
4674                        update_index: 0,
4675                        value: ReftableRefValue::Deletion,
4676                    });
4677                    delete_names.push(delete.name.clone());
4678                }
4679            }
4680        }
4681        if self.combine_reftable_logs || self.git_dir != self.common_dir {
4682            let head_mirror = Self::head_reflog_mirror(head_branch.as_deref(), &reflogs);
4683            reflogs.extend(head_mirror);
4684            let log_records = reflogs
4685                .into_iter()
4686                .map(|(name, entry)| {
4687                    Ok(ReftableLogRecord {
4688                        refname: name,
4689                        update_index: 0,
4690                        value: ReftableLogValue::Update(self.reftable_update_from_reflog(&entry)?),
4691                    })
4692                })
4693                .collect::<Result<Vec<_>>>()?;
4694            self.append_reftable_table_with_compaction(records, log_records, false)?;
4695            for name in &delete_names {
4696                self.remove_reflog_file(name);
4697            }
4698            // Shared stacks use the native combined ref+log transaction shape
4699            // for update-ref and should auto-compact at the transaction
4700            // boundary. Per-worktree stacks currently retain their append-only
4701            // shape: compacting a two-table worktree stack here would collapse
4702            // a geometric sequence Git leaves intact.
4703            if self.storage_dir == self.shared_reftable_storage_dir() {
4704                self.auto_compact_reftable_stack()?;
4705            }
4706            return Ok(());
4707        }
4708        self.append_reftable_records(records)?;
4709        // Git unlinks logs/refs/<name> (pruning now-empty dirs) on delete; do
4710        // this before appending update reflogs so a delete+recreate does not race
4711        // the new ref's reflog file.
4712        for name in &delete_names {
4713            self.remove_reflog_file(name);
4714        }
4715        let head_mirror = Self::head_reflog_mirror(head_branch.as_deref(), &reflogs);
4716        reflogs.extend(head_mirror);
4717        for (name, entry) in reflogs {
4718            self.append_reflog(&name, &entry)?;
4719        }
4720        Ok(())
4721    }
4722
4723    fn route_reftable_changes(
4724        &self,
4725        changes: Vec<CoalescedRefChange>,
4726    ) -> Result<Vec<(FileRefStore, Vec<CoalescedRefChange>)>> {
4727        let mut grouped = BTreeMap::<PathBuf, (FileRefStore, Vec<CoalescedRefChange>)>::new();
4728        for change in changes {
4729            let name = match &change {
4730                CoalescedRefChange::Update(update) => update.name.as_str(),
4731                CoalescedRefChange::Delete(delete) => delete.name.as_str(),
4732            };
4733            let (store, rewritten) = self.reftable_store_for_ref(name)?;
4734            let rewritten_change = match change {
4735                CoalescedRefChange::Update(mut update) => {
4736                    update.name = rewritten;
4737                    CoalescedRefChange::Update(update)
4738                }
4739                CoalescedRefChange::Delete(mut delete) => {
4740                    delete.name = rewritten;
4741                    CoalescedRefChange::Delete(delete)
4742                }
4743            };
4744            grouped
4745                .entry(store.storage_dir.clone())
4746                .or_insert_with(|| (store, Vec::new()))
4747                .1
4748                .push(rewritten_change);
4749        }
4750        Ok(grouped.into_values().collect())
4751    }
4752
4753    /// Atomic, all-or-nothing commit for the loose-ref backend. See
4754    /// [`FileRefTransaction::commit`] for the full ordering and rollback rules.
4755    #[allow(dead_code)]
4756    fn commit_loose(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
4757        self.commit_loose_hooked(changes, None, None)
4758    }
4759
4760    /// As [`commit_loose`](Self::commit_loose) but firing the
4761    /// `reference-transaction` hook at `prepared` (after every ref is locked and
4762    /// staged, before any rename) and `committed` (after every change lands).
4763    /// A nonzero hook exit in the `prepared` phase rolls the staged changes back
4764    /// and surfaces the git-shaped abort error.
4765    fn commit_loose_hooked(
4766        &self,
4767        changes: Vec<CoalescedRefChange>,
4768        hook: Option<&dyn ReferenceTransactionHook>,
4769        hook_updates: Option<&[RefTransactionHookUpdate]>,
4770    ) -> Result<()> {
4771        // Capture HEAD's symref target only when there is a reflog to mirror.
4772        let has_reflogs = changes.iter().any(|change| {
4773            matches!(change, CoalescedRefChange::Update(update) if !update.reflog.is_empty())
4774        });
4775        let head_branch = if has_reflogs {
4776            self.head_symref_target()
4777        } else {
4778            None
4779        };
4780        let has_delete = changes
4781            .iter()
4782            .any(|change| matches!(change, CoalescedRefChange::Delete(_)));
4783        let update_count = changes
4784            .iter()
4785            .filter(|change| matches!(change, CoalescedRefChange::Update(_)))
4786            .count();
4787        let targeted_conflict_check = update_count == 1 && !has_delete;
4788        let conflict_names = if update_count > 0 && !targeted_conflict_check {
4789            let mut names = self
4790                .list_refs()?
4791                .into_iter()
4792                .map(|reference| reference.name)
4793                .collect::<BTreeSet<_>>();
4794            for change in &changes {
4795                if let CoalescedRefChange::Update(update) = change {
4796                    names.insert(update.name.clone());
4797                }
4798            }
4799            Some(names)
4800        } else {
4801            None
4802        };
4803        let mut pending = Vec::with_capacity(changes.len() + usize::from(has_delete));
4804        let mut prepared_parents = BTreeSet::new();
4805        // Acquire every lock first; bail (releasing what we hold) on any failure.
4806        for change in &changes {
4807            let name = change.name();
4808            if matches!(change, CoalescedRefChange::Update(_)) {
4809                let conflict_result = if targeted_conflict_check {
4810                    self.check_ref_directory_conflict_targeted(name)
4811                } else if let Some(conflict_names) = conflict_names.as_ref() {
4812                    check_ref_directory_conflict_in_names(name, conflict_names)
4813                } else {
4814                    Ok(())
4815                };
4816                if let Err(err) = conflict_result {
4817                    release_pending_locks(&pending);
4818                    return Err(err);
4819                }
4820            }
4821            let path = self.ref_path(name);
4822            let parent = path
4823                .parent()
4824                .ok_or_else(|| GitError::InvalidPath("ref path has no parent".into()))?;
4825            if prepared_parents.insert(parent.to_path_buf())
4826                && let Err(err) = fs::create_dir_all(parent)
4827            {
4828                release_pending_locks(&pending);
4829                if err.kind() == std::io::ErrorKind::NotADirectory {
4830                    return Err(ref_directory_conflict_error(
4831                        name,
4832                        &parent_to_ref_name(&self.ref_base_dir(name), parent),
4833                    ));
4834                }
4835                return Err(GitError::Io(err.to_string()));
4836            }
4837            let lock_path = match lock_path_for(&path) {
4838                Ok(lock_path) => lock_path,
4839                Err(err) => {
4840                    release_pending_locks(&pending);
4841                    return Err(err);
4842                }
4843            };
4844            let action = match change {
4845                CoalescedRefChange::Update(update)
4846                    if self.prefer_symlink_refs && matches!(update.new, RefTarget::Symbolic(_)) =>
4847                {
4848                    let RefTarget::Symbolic(target) = &update.new else {
4849                        unreachable!("guard requires a symbolic target")
4850                    };
4851                    PendingPathAction::WriteSymbolic {
4852                        target: target.clone(),
4853                        fallback_contents: write_loose_ref(&Ref {
4854                            name: update.name.clone(),
4855                            target: update.new.clone(),
4856                        }),
4857                    }
4858                }
4859                CoalescedRefChange::Update(update) => PendingPathAction::Write {
4860                    contents: write_loose_ref(&Ref {
4861                        name: update.name.clone(),
4862                        target: update.new.clone(),
4863                    }),
4864                },
4865                CoalescedRefChange::Delete(_) => PendingPathAction::Delete,
4866            };
4867            let mut lock_file = match fs::OpenOptions::new()
4868                .write(true)
4869                .create_new(true)
4870                .open(&lock_path)
4871            {
4872                Ok(file) => file,
4873                Err(err) => {
4874                    release_pending_locks(&pending);
4875                    return Err(GitError::Io(format!("could not lock ref {name}: {err}")));
4876                }
4877            };
4878            // The lock file is private until it is renamed into place, so it
4879            // is safe to stage its bytes as soon as the lock is acquired. This
4880            // keeps the all-locks-before-apply and rollback guarantees while
4881            // avoiding a second open/truncate pass over large transactions.
4882            let stage_result = match &action {
4883                PendingPathAction::Write { contents } => lock_file.write_all(contents),
4884                PendingPathAction::WriteSymbolic { .. } => Ok(()),
4885                PendingPathAction::Delete => lock_file.write_all(b"delete\n"),
4886                PendingPathAction::ReleaseLock => Ok(()),
4887            };
4888            drop(lock_file);
4889            if let Err(err) = stage_result {
4890                let _ = fs::remove_file(&lock_path);
4891                release_pending_locks(&pending);
4892                return Err(err.into());
4893            }
4894            let staged = !matches!(action, PendingPathAction::WriteSymbolic { .. });
4895            pending.push(PendingPathChange {
4896                name: name.to_string(),
4897                display_path: self.display_ref_path(name),
4898                path,
4899                lock_path,
4900                original: OriginalPathState::Missing,
4901                action,
4902                staged,
4903            });
4904        }
4905
4906        let packed_path = self.storage_dir.join("packed-refs");
4907        let mut packed_refs = Vec::new();
4908        let mut use_packed_snapshot = false;
4909        if has_delete {
4910            let packed_lock_path = match lock_path_for(&packed_path) {
4911                Ok(lock_path) => lock_path,
4912                Err(err) => {
4913                    release_pending_locks(&pending);
4914                    return Err(err);
4915                }
4916            };
4917            if let Err(err) = acquire_path_lock_with_timeout(
4918                &packed_lock_path,
4919                self.packed_refs_lock_timeout_millis,
4920            ) {
4921                release_pending_locks(&pending);
4922                return Err(err);
4923            }
4924            let packed_original = match fs::read(&packed_path) {
4925                Ok(bytes) => Some(bytes),
4926                Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
4927                Err(err) => {
4928                    release_pending_locks(&pending);
4929                    let _ = fs::remove_file(&packed_lock_path);
4930                    return Err(GitError::Io(err.to_string()));
4931                }
4932            };
4933            packed_refs = match &packed_original {
4934                Some(bytes) => match parse_packed_refs(self.format, bytes) {
4935                    Ok(refs) => refs,
4936                    Err(err) => {
4937                        release_pending_locks(&pending);
4938                        let _ = fs::remove_file(&packed_lock_path);
4939                        return Err(err);
4940                    }
4941                },
4942                None => Vec::new(),
4943            };
4944            use_packed_snapshot = true;
4945            pending.push(PendingPathChange {
4946                name: "packed-refs".into(),
4947                display_path: self.display_storage_path(&packed_path),
4948                path: packed_path.clone(),
4949                lock_path: packed_lock_path,
4950                original: packed_original
4951                    .as_ref()
4952                    .map_or(OriginalPathState::Missing, |bytes| {
4953                        OriginalPathState::File(bytes.clone())
4954                    }),
4955                action: PendingPathAction::ReleaseLock,
4956                staged: false,
4957            });
4958        } else if packed_path.exists() {
4959            packed_refs = parse_packed_refs(self.format, &fs::read(&packed_path)?)?;
4960            use_packed_snapshot = true;
4961        }
4962        let packed_ref_targets = packed_refs
4963            .iter()
4964            .map(|reference| {
4965                (
4966                    reference.reference.name.clone(),
4967                    reference.reference.target.clone(),
4968                )
4969            })
4970            .collect::<HashMap<_, _>>();
4971
4972        // Verify expectations under lock, then capture prior on-disk state for
4973        // rollback. Mixed transactions read packed refs from the snapshot held
4974        // behind packed-refs.lock so deletes cannot race a packed rewrite.
4975        let mut reflogs = Vec::new();
4976        let mut delete_names = BTreeSet::new();
4977        for index in 0..changes.len() {
4978            match &changes[index] {
4979                CoalescedRefChange::Update(update) => {
4980                    let (current, has_loose) = if use_packed_snapshot {
4981                        match self.read_locked_ref_state(&update.name, &packed_ref_targets) {
4982                            Ok(state) => (state.current, state.has_loose),
4983                            Err(err) => {
4984                                release_pending_locks(&pending);
4985                                return Err(err);
4986                            }
4987                        }
4988                    } else {
4989                        match self.read_loose_ref(&update.name) {
4990                            Ok(loose) => {
4991                                let has_loose = loose.is_some();
4992                                (loose.map(|reference| reference.target), has_loose)
4993                            }
4994                            Err(err) => {
4995                                release_pending_locks(&pending);
4996                                return Err(err);
4997                            }
4998                        }
4999                    };
5000                    if !matches!(update.precondition, RefPrecondition::Any)
5001                        && !update.precondition.is_satisfied_by(current.as_ref())
5002                    {
5003                        release_pending_locks(&pending);
5004                        return Err(GitError::Transaction(
5005                            update.precondition.describe(&update.name),
5006                        ));
5007                    }
5008                    pending[index].original = if has_loose {
5009                        match read_original_path_state(&pending[index].path) {
5010                            Ok(original) => original,
5011                            Err(err) => {
5012                                release_pending_locks(&pending);
5013                                return Err(err);
5014                            }
5015                        }
5016                    } else {
5017                        OriginalPathState::Missing
5018                    };
5019                    if matches!(pending[index].original, OriginalPathState::Missing)
5020                        && current.as_ref() == Some(&update.new)
5021                    {
5022                        pending[index].action = PendingPathAction::ReleaseLock;
5023                    }
5024                    for entry in &update.reflog {
5025                        reflogs.push((update.name.clone(), entry.clone()));
5026                    }
5027                }
5028                CoalescedRefChange::Delete(delete) => {
5029                    let state = match self.read_locked_ref_state(&delete.name, &packed_ref_targets)
5030                    {
5031                        Ok(state) => state,
5032                        Err(err) => {
5033                            release_pending_locks(&pending);
5034                            return Err(err);
5035                        }
5036                    };
5037                    // Enforce the delete precondition under lock; the returned
5038                    // OID is unused because git unlinks logs/refs/<name> on
5039                    // delete rather than appending a deletion reflog entry.
5040                    if let Err(err) = verify_delete_precondition(
5041                        self,
5042                        &delete.name,
5043                        state.current.as_ref(),
5044                        &delete.precondition,
5045                    ) {
5046                        release_pending_locks(&pending);
5047                        return Err(err);
5048                    }
5049                    pending[index].original = if state.has_loose {
5050                        match read_original_path_state(&pending[index].path) {
5051                            Ok(original) => original,
5052                            Err(err) => {
5053                                release_pending_locks(&pending);
5054                                return Err(err);
5055                            }
5056                        }
5057                    } else {
5058                        OriginalPathState::Missing
5059                    };
5060                    delete_names.insert(delete.name.clone());
5061                }
5062            }
5063        }
5064
5065        if has_delete {
5066            let old_len = packed_refs.len();
5067            packed_refs.retain(|reference| !delete_names.contains(&reference.reference.name));
5068            if packed_refs.len() != old_len {
5069                // Git prepares a packed-refs rewrite in an O_EXCL temporary
5070                // named `packed-refs.new` while holding `packed-refs.lock`.
5071                // Refuse the transaction before applying any loose deletion if
5072                // that staging path is already occupied.
5073                let packed_new_path = self.storage_dir.join("packed-refs.new");
5074                match fs::OpenOptions::new()
5075                    .write(true)
5076                    .create_new(true)
5077                    .open(&packed_new_path)
5078                {
5079                    Ok(file) => {
5080                        drop(file);
5081                        let _ = fs::remove_file(&packed_new_path);
5082                    }
5083                    Err(err) => {
5084                        release_pending_locks(&pending);
5085                        return Err(GitError::Io(format!(
5086                            "Unable to create '{}': {err}",
5087                            packed_new_path.display()
5088                        )));
5089                    }
5090                }
5091                let packed_bytes = match write_packed_refs(&packed_refs) {
5092                    Ok(bytes) => bytes,
5093                    Err(err) => {
5094                        release_pending_locks(&pending);
5095                        return Err(err);
5096                    }
5097                };
5098                if let Some(packed) = pending.last_mut() {
5099                    packed.action = PendingPathAction::Write {
5100                        contents: packed_bytes,
5101                    };
5102                }
5103            }
5104        }
5105
5106        // Stage every new value or delete marker into its lock file. Nothing has
5107        // been renamed or removed yet, so on failure we only drop lock files.
5108        for change in &mut pending {
5109            if let Err(err) = stage_pending_change(change) {
5110                release_pending_locks(&pending);
5111                return Err(err);
5112            }
5113        }
5114
5115        // git fires the `prepared` hook once every ref is locked, before any
5116        // value is renamed into place. A nonzero exit drops the staged lock
5117        // files (no on-disk change happened yet) and aborts.
5118        if let (Some(hook), Some(updates)) = (hook, hook_updates)
5119            && hook.run(RefTransactionPhase::Prepared, updates)?
5120        {
5121            release_pending_locks(&pending);
5122            return Err(ref_transaction_hook_abort(RefTransactionPhase::Prepared));
5123        }
5124
5125        // Apply each staged path change; on failure restore paths already
5126        // changed and drop the remaining lock files.
5127        for index in 0..pending.len() {
5128            if let Err(err) = maybe_fail_loose_commit_action(index) {
5129                rollback_after_apply(&pending, index);
5130                return Err(err);
5131            }
5132            if let Err(err) = apply_pending_change(&pending[index]) {
5133                rollback_after_apply(&pending, index + 1);
5134                return Err(err);
5135            }
5136            if matches!(
5137                pending[index].action,
5138                PendingPathAction::Write { .. } | PendingPathAction::WriteSymbolic { .. }
5139            ) && let Err(err) = self.shared_repository.adjust_file(&pending[index].path)
5140            {
5141                rollback_after_apply(&pending, index + 1);
5142                return Err(err);
5143            }
5144            if matches!(pending[index].action, PendingPathAction::Delete) {
5145                self.prune_empty_ref_dirs(&pending[index].name);
5146            }
5147        }
5148
5149        // Git unlinks logs/refs/<name> (and prunes now-empty log dirs) on delete;
5150        // do this before appending update reflogs so a delete+recreate in the
5151        // same direction does not race the new ref's reflog file.
5152        for name in &delete_names {
5153            self.remove_reflog_file(name);
5154        }
5155        // git's `files_log_ref_write` mirrors a checked-out branch's reflog
5156        // entry into logs/HEAD; `head_branch` was captured before any mutation.
5157        let head_mirror = Self::head_reflog_mirror(head_branch.as_deref(), &reflogs);
5158        reflogs.extend(head_mirror);
5159        // All refs are durable; append reflogs last, matching git's ordering.
5160        self.append_loose_reflogs(reflogs)?;
5161        // git fires the `committed` hook once every ref change has landed. Its
5162        // exit status is ignored — the transaction has already succeeded.
5163        if let (Some(hook), Some(updates)) = (hook, hook_updates) {
5164            hook.run(RefTransactionPhase::Committed, updates)?;
5165        }
5166        Ok(())
5167    }
5168
5169    fn read_locked_ref_state(
5170        &self,
5171        name: &str,
5172        packed_refs: &HashMap<String, RefTarget>,
5173    ) -> Result<LockedRefState> {
5174        let loose = self.read_loose_ref(name)?;
5175        let current = if let Some(reference) = loose.as_ref() {
5176            Some(reference.target.clone())
5177        } else {
5178            packed_refs.get(name).cloned()
5179        };
5180        Ok(LockedRefState {
5181            current,
5182            has_loose: loose.is_some(),
5183        })
5184    }
5185}
5186
5187struct LockedRefState {
5188    current: Option<RefTarget>,
5189    has_loose: bool,
5190}
5191
5192enum CoalescedRefChange {
5193    Update(CoalescedRefUpdate),
5194    Delete(CoalescedRefDelete),
5195}
5196
5197impl CoalescedRefChange {
5198    fn name(&self) -> &str {
5199        match self {
5200            Self::Update(update) => &update.name,
5201            Self::Delete(delete) => &delete.name,
5202        }
5203    }
5204}
5205
5206/// A ref update with all writes that targeted the same name folded together.
5207struct CoalescedRefUpdate {
5208    name: String,
5209    precondition: RefPrecondition,
5210    new: RefTarget,
5211    reflog: Vec<ReflogEntry>,
5212}
5213
5214struct CoalescedRefDelete {
5215    name: String,
5216    precondition: RefDeletePrecondition,
5217}
5218
5219fn coalesce_ref_changes(changes: Vec<QueuedRefChange>) -> Result<Vec<CoalescedRefChange>> {
5220    let has_delete = changes
5221        .iter()
5222        .any(|change| matches!(change, QueuedRefChange::Delete(_)));
5223    if !has_delete {
5224        let updates = changes
5225            .into_iter()
5226            .map(|change| match change {
5227                QueuedRefChange::Update(update) => update,
5228                QueuedRefChange::Delete(_) => unreachable!("has_delete was false"),
5229            })
5230            .collect::<Vec<_>>();
5231        return coalesce_ref_updates(updates).map(|updates| {
5232            updates
5233                .into_iter()
5234                .map(CoalescedRefChange::Update)
5235                .collect()
5236        });
5237    }
5238
5239    let mut seen = BTreeSet::new();
5240    let mut coalesced = Vec::with_capacity(changes.len());
5241    for change in changes {
5242        let name = match &change {
5243            QueuedRefChange::Update(update) => &update.name,
5244            QueuedRefChange::Delete(delete) => &delete.name,
5245        };
5246        match &change {
5247            QueuedRefChange::Update(update) => validate_ref_name_for_update(&update.name)?,
5248            QueuedRefChange::Delete(delete) => validate_ref_name_for_read(&delete.name)?,
5249        }
5250        if !seen.insert(name.clone()) {
5251            return Err(GitError::Transaction(format!(
5252                "ref {name} appears more than once in transaction"
5253            )));
5254        }
5255        coalesced.push(match change {
5256            QueuedRefChange::Update(update) => CoalescedRefChange::Update(CoalescedRefUpdate {
5257                name: update.name,
5258                precondition: update.precondition,
5259                new: update.new,
5260                reflog: update.reflog.into_iter().collect(),
5261            }),
5262            QueuedRefChange::Delete(delete) => CoalescedRefChange::Delete(CoalescedRefDelete {
5263                name: delete.name,
5264                precondition: delete.precondition,
5265            }),
5266        });
5267    }
5268    Ok(coalesced)
5269}
5270
5271/// Fold repeated updates to the same ref into one, preserving first-seen order.
5272/// The last queued value wins, reflog entries accumulate in order, and the
5273/// precondition is taken from the first update (the state the caller
5274/// asserted before any change in this transaction).
5275fn coalesce_ref_updates(updates: Vec<QueuedUpdate>) -> Result<Vec<CoalescedRefUpdate>> {
5276    let mut order: Vec<String> = Vec::new();
5277    let mut by_name: HashMap<String, CoalescedRefUpdate> = HashMap::new();
5278    for update in updates {
5279        validate_ref_name_for_update(&update.name)?;
5280        match by_name.get_mut(&update.name) {
5281            Some(existing) => {
5282                existing.new = update.new;
5283                if let Some(entry) = update.reflog {
5284                    existing.reflog.push(entry);
5285                }
5286            }
5287            None => {
5288                order.push(update.name.clone());
5289                by_name.insert(
5290                    update.name.clone(),
5291                    CoalescedRefUpdate {
5292                        name: update.name,
5293                        precondition: update.precondition,
5294                        new: update.new,
5295                        reflog: update.reflog.into_iter().collect(),
5296                    },
5297                );
5298            }
5299        }
5300    }
5301    let mut coalesced = Vec::with_capacity(order.len());
5302    for name in order {
5303        if let Some(update) = by_name.remove(&name) {
5304            coalesced.push(update);
5305        }
5306    }
5307    Ok(coalesced)
5308}
5309
5310/// A staged path change: the target path, its lock file, and exact original
5311/// filesystem representation for rollback.
5312struct PendingPathChange {
5313    name: String,
5314    display_path: String,
5315    path: PathBuf,
5316    lock_path: PathBuf,
5317    original: OriginalPathState,
5318    action: PendingPathAction,
5319    staged: bool,
5320}
5321
5322enum OriginalPathState {
5323    Missing,
5324    File(Vec<u8>),
5325    Symlink(PathBuf),
5326}
5327
5328enum PendingPathAction {
5329    Write {
5330        contents: Vec<u8>,
5331    },
5332    WriteSymbolic {
5333        target: String,
5334        fallback_contents: Vec<u8>,
5335    },
5336    Delete,
5337    ReleaseLock,
5338}
5339
5340struct RefDirPruneGuard<'a> {
5341    store: &'a FileRefStore,
5342    name: String,
5343}
5344
5345impl Drop for RefDirPruneGuard<'_> {
5346    fn drop(&mut self) {
5347        self.store.prune_empty_ref_dirs(&self.name);
5348    }
5349}
5350
5351struct DeleteLock {
5352    path: PathBuf,
5353    file: Option<fs::File>,
5354    active: bool,
5355}
5356
5357impl DeleteLock {
5358    fn acquire(path: PathBuf) -> std::result::Result<Self, RefDeleteError> {
5359        match fs::OpenOptions::new()
5360            .write(true)
5361            .create_new(true)
5362            .open(&path)
5363        {
5364            Ok(file) => Ok(Self {
5365                path,
5366                file: Some(file),
5367                active: true,
5368            }),
5369            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
5370                Err(RefDeleteError::Locked)
5371            }
5372            Err(err) => Err(RefDeleteError::Io(err)),
5373        }
5374    }
5375
5376    fn write_all(
5377        &mut self,
5378        bytes: &[u8],
5379        fsync_method: Option<ReferenceFsyncMethod>,
5380    ) -> std::result::Result<(), RefDeleteError> {
5381        let Some(file) = self.file.as_mut() else {
5382            return Err(RefDeleteError::Io(std::io::Error::other(
5383                "lock file is already closed",
5384            )));
5385        };
5386        file.set_len(0)?;
5387        file.write_all(bytes)?;
5388        if let Some(method) = fsync_method {
5389            sync_reference_file(file, method)?;
5390        }
5391        Ok(())
5392    }
5393
5394    fn close(mut self) -> PathBuf {
5395        self.active = false;
5396        let _ = self.file.take();
5397        self.path.clone()
5398    }
5399
5400    fn remove(mut self) {
5401        self.active = false;
5402        let _ = self.file.take();
5403        let _ = fs::remove_file(&self.path);
5404    }
5405}
5406
5407impl Drop for DeleteLock {
5408    fn drop(&mut self) {
5409        if self.active {
5410            let _ = self.file.take();
5411            let _ = fs::remove_file(&self.path);
5412        }
5413    }
5414}
5415
5416struct ReftableListLock {
5417    list_path: PathBuf,
5418    lock_path: PathBuf,
5419    file: Option<fs::File>,
5420    active: bool,
5421}
5422
5423impl ReftableListLock {
5424    fn acquire(list_path: PathBuf, lock_path: PathBuf, timeout_millis: u64) -> Result<Self> {
5425        let start = SystemTime::now();
5426        loop {
5427            match fs::OpenOptions::new()
5428                .write(true)
5429                .create_new(true)
5430                .open(&lock_path)
5431            {
5432                Ok(file) => {
5433                    return Ok(Self {
5434                        list_path,
5435                        lock_path,
5436                        file: Some(file),
5437                        active: true,
5438                    });
5439                }
5440                Err(err)
5441                    if err.kind() == std::io::ErrorKind::AlreadyExists && timeout_millis > 0 =>
5442                {
5443                    let elapsed = start
5444                        .elapsed()
5445                        .unwrap_or_else(|_| Duration::from_millis(timeout_millis + 1));
5446                    if elapsed.as_millis() as u64 >= timeout_millis {
5447                        return Err(GitError::Io(format!(
5448                            "cannot lock references: {}: File exists",
5449                            lock_path.display()
5450                        )));
5451                    }
5452                    thread::sleep(Duration::from_millis(50));
5453                }
5454                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
5455                    return Err(GitError::Io(format!(
5456                        "cannot lock references: {}: File exists",
5457                        lock_path.display()
5458                    )));
5459                }
5460                Err(err) => return Err(err.into()),
5461            }
5462        }
5463    }
5464
5465    fn commit(mut self, bytes: &[u8], fsync_method: Option<ReferenceFsyncMethod>) -> Result<()> {
5466        let Some(mut file) = self.file.take() else {
5467            return Err(GitError::Io("reftable list lock is already closed".into()));
5468        };
5469        file.set_len(0)?;
5470        file.write_all(bytes)?;
5471        if let Some(method) = fsync_method {
5472            sync_reference_file(&file, method)?;
5473        }
5474        drop(file);
5475        fs::rename(&self.lock_path, &self.list_path)
5476            .map_err(|err| GitError::Io(err.to_string()))?;
5477        self.active = false;
5478        Ok(())
5479    }
5480}
5481
5482impl Drop for ReftableListLock {
5483    fn drop(&mut self) {
5484        if self.active {
5485            let _ = self.file.take();
5486            let _ = fs::remove_file(&self.lock_path);
5487        }
5488    }
5489}
5490
5491fn checked_delete_oid(
5492    expected: Option<ObjectId>,
5493    current: Option<RefTarget>,
5494) -> std::result::Result<ObjectId, RefDeleteError> {
5495    let Some(current) = current else {
5496        return Err(RefDeleteError::NotFound);
5497    };
5498    let RefTarget::Direct(actual) = current else {
5499        return Err(RefDeleteError::ExpectedMismatch {
5500            expected,
5501            actual: None,
5502        });
5503    };
5504    if let Some(expected_oid) = expected
5505        && expected_oid != actual
5506    {
5507        return Err(RefDeleteError::ExpectedMismatch {
5508            expected: Some(expected_oid),
5509            actual: Some(actual),
5510        });
5511    }
5512    Ok(actual)
5513}
5514
5515/// Verify a queued/checked delete may proceed, dying on a precondition
5516/// mismatch. Git unlinks the reflog on delete (it never writes a deletion
5517/// entry), so this validates only — the peeled OID is no longer plumbed out.
5518/// `peeled_oid_for_delete` is still invoked where the precondition requires the
5519/// peeled value, so a broken/unpeelable ref is still reported.
5520fn verify_delete_precondition(
5521    store: &FileRefStore,
5522    name: &str,
5523    current: Option<&RefTarget>,
5524    precondition: &RefDeletePrecondition,
5525) -> Result<()> {
5526    let Some(current) = current else {
5527        return Err(GitError::Transaction(format!("ref {name} not found")));
5528    };
5529    match precondition {
5530        RefDeletePrecondition::Any => {
5531            peeled_oid_for_delete(store, current)?;
5532            Ok(())
5533        }
5534        RefDeletePrecondition::Immediate(expected) if current == expected => {
5535            peeled_oid_for_delete(store, current)?;
5536            Ok(())
5537        }
5538        RefDeletePrecondition::Immediate(_) => Err(delete_precondition_mismatch(name)),
5539        RefDeletePrecondition::Direct(expected) => {
5540            let RefTarget::Direct(actual) = current else {
5541                return Err(delete_precondition_mismatch(name));
5542            };
5543            if let Some(expected) = expected
5544                && expected != actual
5545            {
5546                return Err(delete_precondition_mismatch(name));
5547            }
5548            Ok(())
5549        }
5550        RefDeletePrecondition::Peeled(expected) => {
5551            let actual = peeled_oid_for_delete(store, current)?;
5552            if actual == Some(*expected) {
5553                Ok(())
5554            } else {
5555                Err(delete_precondition_mismatch(name))
5556            }
5557        }
5558    }
5559}
5560
5561fn peeled_oid_for_delete(store: &FileRefStore, target: &RefTarget) -> Result<Option<ObjectId>> {
5562    match target {
5563        RefTarget::Direct(oid) => Ok(Some(*oid)),
5564        RefTarget::Symbolic(name) => resolve_ref_peeled(store, name),
5565    }
5566}
5567
5568fn delete_precondition_mismatch(name: &str) -> GitError {
5569    GitError::Transaction(format!("expected ref {name} to match"))
5570}
5571
5572fn ref_delete_error_from_git(err: GitError) -> RefDeleteError {
5573    match err {
5574        GitError::InvalidPath(_) => RefDeleteError::InvalidName,
5575        GitError::NotFound(_) => RefDeleteError::NotFound,
5576        GitError::Io(message) if message.contains("File exists") => RefDeleteError::Locked,
5577        GitError::Io(message) if message.contains("could not lock") => RefDeleteError::Locked,
5578        GitError::Transaction(message) if message.contains("could not lock") => {
5579            RefDeleteError::Locked
5580        }
5581        other => RefDeleteError::Io(std::io::Error::other(other.to_string())),
5582    }
5583}
5584
5585fn read_original_path_state(path: &Path) -> Result<OriginalPathState> {
5586    let metadata = match fs::symlink_metadata(path) {
5587        Ok(metadata) => metadata,
5588        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
5589            return Ok(OriginalPathState::Missing);
5590        }
5591        Err(err) => return Err(GitError::Io(err.to_string())),
5592    };
5593    if metadata.file_type().is_symlink() {
5594        return fs::read_link(path)
5595            .map(OriginalPathState::Symlink)
5596            .map_err(|err| GitError::Io(err.to_string()));
5597    }
5598    if metadata.is_dir() {
5599        return Ok(OriginalPathState::Missing);
5600    }
5601    match fs::read(path) {
5602        Ok(bytes) => Ok(OriginalPathState::File(bytes)),
5603        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(OriginalPathState::Missing),
5604        Err(err) => Err(GitError::Io(err.to_string())),
5605    }
5606}
5607
5608/// Recursively remove an empty directory tree at `path` (git's
5609/// `remove_empty_directories`). A no-op when `path` is absent or is a file; an
5610/// error if any directory in the tree contains a non-directory entry.
5611fn remove_empty_dir_tree(path: &Path) -> std::io::Result<()> {
5612    let meta = match fs::symlink_metadata(path) {
5613        Ok(meta) => meta,
5614        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
5615        Err(err) => return Err(err),
5616    };
5617    if !meta.is_dir() {
5618        return Ok(());
5619    }
5620    for entry in fs::read_dir(path)? {
5621        let entry = entry?;
5622        if entry.file_type()?.is_dir() {
5623            remove_empty_dir_tree(&entry.path())?;
5624        } else {
5625            return Err(std::io::Error::other("directory not empty"));
5626        }
5627    }
5628    fs::remove_dir(path)
5629}
5630
5631fn stage_lock_file(lock_path: &Path, contents: &[u8]) -> Result<()> {
5632    let mut file = fs::OpenOptions::new()
5633        .write(true)
5634        .truncate(true)
5635        .open(lock_path)?;
5636    file.write_all(contents)?;
5637    Ok(())
5638}
5639
5640fn stage_pending_change(change: &mut PendingPathChange) -> Result<()> {
5641    if change.staged {
5642        return Ok(());
5643    }
5644    match &change.action {
5645        PendingPathAction::Write { contents } => stage_lock_file(&change.lock_path, contents)?,
5646        PendingPathAction::WriteSymbolic {
5647            target,
5648            fallback_contents,
5649        } => stage_symbolic_lock_file(&change.lock_path, target, fallback_contents)?,
5650        PendingPathAction::Delete => stage_lock_file(&change.lock_path, b"delete\n")?,
5651        PendingPathAction::ReleaseLock => {}
5652    }
5653    change.staged = true;
5654    Ok(())
5655}
5656
5657fn apply_pending_change(change: &PendingPathChange) -> Result<()> {
5658    match &change.action {
5659        PendingPathAction::Write { .. } | PendingPathAction::WriteSymbolic { .. } => {
5660            match fs::rename(&change.lock_path, &change.path) {
5661                Ok(()) => Ok(()),
5662                Err(first_err) => {
5663                    // Usually the destination does not exist, so avoid a stat
5664                    // for every update. On the rare empty-directory conflict,
5665                    // preserve git's remove_empty_directories behavior and
5666                    // retry the rename after clearing the tree.
5667                    match fs::symlink_metadata(&change.path) {
5668                        Ok(meta) if meta.is_dir() => {
5669                            remove_empty_dir_tree(&change.path).map_err(|err| {
5670                                if err.kind() == std::io::ErrorKind::Other
5671                                    && err.to_string() == "directory not empty"
5672                                {
5673                                    GitError::Transaction(format!(
5674                                        "cannot lock ref '{}': there is a non-empty directory '{}' blocking reference '{}'",
5675                                        change.name, change.display_path, change.name
5676                                    ))
5677                                } else {
5678                                    GitError::Io(err.to_string())
5679                                }
5680                            })?;
5681                            fs::rename(&change.lock_path, &change.path)
5682                                .map_err(|err| GitError::Io(err.to_string()))
5683                        }
5684                        _ => Err(GitError::Io(first_err.to_string())),
5685                    }
5686                }
5687            }
5688        }
5689        PendingPathAction::Delete => {
5690            if !matches!(change.original, OriginalPathState::Missing) {
5691                fs::remove_file(&change.path).map_err(|err| GitError::Io(err.to_string()))?;
5692            }
5693            fs::remove_file(&change.lock_path).map_err(|err| GitError::Io(err.to_string()))
5694        }
5695        PendingPathAction::ReleaseLock => {
5696            fs::remove_file(&change.lock_path).map_err(|err| GitError::Io(err.to_string()))
5697        }
5698    }
5699}
5700
5701fn stage_symbolic_lock_file(
5702    lock_path: &Path,
5703    target: &str,
5704    fallback_contents: &[u8],
5705) -> Result<()> {
5706    #[cfg(unix)]
5707    {
5708        let _ = fallback_contents;
5709        fs::remove_file(lock_path)?;
5710        std::os::unix::fs::symlink(target, lock_path)?;
5711        Ok(())
5712    }
5713    #[cfg(not(unix))]
5714    {
5715        let _ = target;
5716        stage_lock_file(lock_path, fallback_contents)
5717    }
5718}
5719
5720/// Delete every still-held lock file. Used when a transaction aborts before any
5721/// path change, so nothing on disk has changed yet.
5722fn release_pending_locks(pending: &[PendingPathChange]) {
5723    for change in pending {
5724        let _ = fs::remove_file(&change.lock_path);
5725    }
5726}
5727
5728/// Roll back after `applied` path changes have already landed: restore each to
5729/// its captured bytes (or remove it if it did not previously exist), then drop
5730/// the lock files that have not yet been applied.
5731fn rollback_after_apply(pending: &[PendingPathChange], applied: usize) {
5732    for change in pending.iter().take(applied) {
5733        if matches!(change.action, PendingPathAction::ReleaseLock) {
5734            let _ = fs::remove_file(&change.lock_path);
5735            continue;
5736        }
5737        match &change.original {
5738            OriginalPathState::File(bytes) => {
5739                let _ = restore_file_atomically(&change.path, bytes);
5740            }
5741            OriginalPathState::Symlink(target) => {
5742                let _ = restore_symlink_atomically(&change.path, &change.lock_path, target);
5743            }
5744            OriginalPathState::Missing => {
5745                let _ = fs::remove_file(&change.path);
5746            }
5747        }
5748        let _ = fs::remove_file(&change.lock_path);
5749    }
5750    for change in pending.iter().skip(applied) {
5751        let _ = fs::remove_file(&change.lock_path);
5752    }
5753}
5754
5755#[cfg(unix)]
5756fn restore_symlink_atomically(path: &Path, lock_path: &Path, target: &Path) -> std::io::Result<()> {
5757    let _ = fs::remove_file(lock_path);
5758    std::os::unix::fs::symlink(target, lock_path)?;
5759    fs::rename(lock_path, path)
5760}
5761
5762#[cfg(windows)]
5763fn restore_symlink_atomically(path: &Path, lock_path: &Path, target: &Path) -> std::io::Result<()> {
5764    let _ = fs::remove_file(lock_path);
5765    std::os::windows::fs::symlink_file(target, lock_path)?;
5766    fs::rename(lock_path, path)
5767}
5768
5769#[cfg(not(any(unix, windows)))]
5770fn restore_symlink_atomically(
5771    _path: &Path,
5772    _lock_path: &Path,
5773    _target: &Path,
5774) -> std::io::Result<()> {
5775    Err(std::io::Error::new(
5776        std::io::ErrorKind::Unsupported,
5777        "symbolic reference rollback is unsupported on this platform",
5778    ))
5779}
5780
5781#[cfg(test)]
5782thread_local! {
5783    static FAIL_LOOSE_COMMIT_ACTION: std::cell::Cell<Option<usize>> =
5784        const { std::cell::Cell::new(None) };
5785    static REFERENCE_FSYNC_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
5786    static FAIL_REFERENCE_FSYNC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
5787}
5788
5789#[cfg(test)]
5790fn set_fail_loose_commit_action_for_test(index: Option<usize>) {
5791    FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.set(index));
5792}
5793
5794#[cfg(test)]
5795fn maybe_fail_loose_commit_action(index: usize) -> Result<()> {
5796    let should_fail = FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.get() == Some(index));
5797    if should_fail {
5798        FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.set(None));
5799        return Err(GitError::Io(format!(
5800            "injected loose ref transaction failure at action {index}"
5801        )));
5802    }
5803    Ok(())
5804}
5805
5806#[cfg(not(test))]
5807fn maybe_fail_loose_commit_action(_index: usize) -> Result<()> {
5808    Ok(())
5809}
5810
5811/// Best-effort atomic restore of `path` to `bytes` during rollback, reusing the
5812/// write-to-temp-then-rename dance so a crash mid-rollback cannot truncate a ref.
5813fn restore_file_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
5814    if let Some(parent) = path.parent() {
5815        fs::create_dir_all(parent)?;
5816    }
5817    write_locked(path, bytes, None)
5818}
5819
5820#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
5821pub struct FullRefName<'a> {
5822    name: &'a str,
5823}
5824
5825impl<'a> FullRefName<'a> {
5826    pub fn new(name: &'a str) -> Result<Self> {
5827        validate_ref_name(name)?;
5828        Ok(Self { name })
5829    }
5830
5831    pub fn as_str(&self) -> &str {
5832        self.name
5833    }
5834
5835    pub fn into_str(self) -> &'a str {
5836        self.name
5837    }
5838
5839    pub fn to_owned(&self) -> FullRefNameBuf {
5840        FullRefNameBuf {
5841            name: self.name.to_string(),
5842        }
5843    }
5844
5845    pub fn as_branch(&self) -> Result<BranchRefName<'a>> {
5846        BranchRefName::from_full_ref(*self)
5847    }
5848
5849    pub fn as_tag(&self) -> Result<TagRefName<'a>> {
5850        TagRefName::from_full_ref(*self)
5851    }
5852
5853    pub fn as_remote(&self) -> Result<RemoteRefName<'a>> {
5854        RemoteRefName::from_full_ref(*self)
5855    }
5856}
5857
5858impl AsRef<str> for FullRefName<'_> {
5859    fn as_ref(&self) -> &str {
5860        self.as_str()
5861    }
5862}
5863
5864impl fmt::Display for FullRefName<'_> {
5865    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5866        f.write_str(self.as_str())
5867    }
5868}
5869
5870#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
5871pub struct FullRefNameBuf {
5872    name: String,
5873}
5874
5875impl FullRefNameBuf {
5876    pub fn new(name: impl Into<String>) -> Result<Self> {
5877        let name = name.into();
5878        validate_ref_name(&name)?;
5879        Ok(Self { name })
5880    }
5881
5882    pub fn as_ref_name(&self) -> FullRefName<'_> {
5883        FullRefName { name: &self.name }
5884    }
5885
5886    pub fn as_str(&self) -> &str {
5887        &self.name
5888    }
5889
5890    pub fn into_string(self) -> String {
5891        self.name
5892    }
5893
5894    pub fn as_branch(&self) -> Result<BranchRefName<'_>> {
5895        self.as_ref_name().as_branch()
5896    }
5897
5898    pub fn as_tag(&self) -> Result<TagRefName<'_>> {
5899        self.as_ref_name().as_tag()
5900    }
5901
5902    pub fn as_remote(&self) -> Result<RemoteRefName<'_>> {
5903        self.as_ref_name().as_remote()
5904    }
5905}
5906
5907impl AsRef<str> for FullRefNameBuf {
5908    fn as_ref(&self) -> &str {
5909        self.as_str()
5910    }
5911}
5912
5913impl Borrow<str> for FullRefNameBuf {
5914    fn borrow(&self) -> &str {
5915        self.as_str()
5916    }
5917}
5918
5919impl Deref for FullRefNameBuf {
5920    type Target = str;
5921
5922    fn deref(&self) -> &Self::Target {
5923        self.as_str()
5924    }
5925}
5926
5927impl fmt::Display for FullRefNameBuf {
5928    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5929        f.write_str(self.as_str())
5930    }
5931}
5932
5933#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
5934pub struct BranchRefName<'a> {
5935    name: &'a str,
5936}
5937
5938impl<'a> BranchRefName<'a> {
5939    pub const PREFIX: &'static str = "refs/heads/";
5940
5941    pub fn from_full(name: &'a str) -> Result<Self> {
5942        let full = FullRefName::new(name)?;
5943        Self::from_full_ref(full)
5944    }
5945
5946    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
5947        validate_namespaced_ref(name.as_str(), Self::PREFIX, "branch")?;
5948        Ok(Self {
5949            name: name.into_str(),
5950        })
5951    }
5952
5953    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
5954        FullRefName { name: self.name }
5955    }
5956
5957    pub fn as_str(&self) -> &str {
5958        self.name
5959    }
5960
5961    pub fn branch_name(&self) -> &str {
5962        self.short_name()
5963    }
5964
5965    pub fn short_name(&self) -> &str {
5966        &self.name[Self::PREFIX.len()..]
5967    }
5968
5969    pub fn into_str(self) -> &'a str {
5970        self.name
5971    }
5972
5973    pub fn to_owned(&self) -> BranchRefNameBuf {
5974        BranchRefNameBuf {
5975            name: self.name.to_string(),
5976        }
5977    }
5978}
5979
5980impl AsRef<str> for BranchRefName<'_> {
5981    fn as_ref(&self) -> &str {
5982        self.as_str()
5983    }
5984}
5985
5986impl fmt::Display for BranchRefName<'_> {
5987    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5988        f.write_str(self.as_str())
5989    }
5990}
5991
5992impl<'a> From<BranchRefName<'a>> for FullRefName<'a> {
5993    fn from(name: BranchRefName<'a>) -> Self {
5994        name.as_full_ref_name()
5995    }
5996}
5997
5998#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
5999pub struct BranchRefNameBuf {
6000    name: String,
6001}
6002
6003impl BranchRefNameBuf {
6004    pub fn from_branch_name(branch: &str) -> Result<Self> {
6005        validate_short_ref_name("branch", branch)?;
6006        let name = format!("{}{}", BranchRefName::PREFIX, branch);
6007        Self::from_full(name)
6008    }
6009
6010    pub fn from_full(name: impl Into<String>) -> Result<Self> {
6011        let name = name.into();
6012        BranchRefName::from_full(&name)?;
6013        Ok(Self { name })
6014    }
6015
6016    pub fn as_ref_name(&self) -> BranchRefName<'_> {
6017        BranchRefName { name: &self.name }
6018    }
6019
6020    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
6021        FullRefName { name: &self.name }
6022    }
6023
6024    pub fn as_str(&self) -> &str {
6025        &self.name
6026    }
6027
6028    pub fn branch_name(&self) -> &str {
6029        self.short_name()
6030    }
6031
6032    pub fn short_name(&self) -> &str {
6033        &self.name[BranchRefName::PREFIX.len()..]
6034    }
6035
6036    pub fn into_string(self) -> String {
6037        self.name
6038    }
6039}
6040
6041impl AsRef<str> for BranchRefNameBuf {
6042    fn as_ref(&self) -> &str {
6043        self.as_str()
6044    }
6045}
6046
6047impl Borrow<str> for BranchRefNameBuf {
6048    fn borrow(&self) -> &str {
6049        self.as_str()
6050    }
6051}
6052
6053impl Deref for BranchRefNameBuf {
6054    type Target = str;
6055
6056    fn deref(&self) -> &Self::Target {
6057        self.as_str()
6058    }
6059}
6060
6061impl fmt::Display for BranchRefNameBuf {
6062    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6063        f.write_str(self.as_str())
6064    }
6065}
6066
6067impl From<BranchRefNameBuf> for FullRefNameBuf {
6068    fn from(name: BranchRefNameBuf) -> Self {
6069        Self { name: name.name }
6070    }
6071}
6072
6073#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
6074pub struct TagRefName<'a> {
6075    name: &'a str,
6076}
6077
6078impl<'a> TagRefName<'a> {
6079    pub const PREFIX: &'static str = "refs/tags/";
6080
6081    pub fn from_full(name: &'a str) -> Result<Self> {
6082        let full = FullRefName::new(name)?;
6083        Self::from_full_ref(full)
6084    }
6085
6086    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
6087        validate_namespaced_ref(name.as_str(), Self::PREFIX, "tag")?;
6088        Ok(Self {
6089            name: name.into_str(),
6090        })
6091    }
6092
6093    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
6094        FullRefName { name: self.name }
6095    }
6096
6097    pub fn as_str(&self) -> &str {
6098        self.name
6099    }
6100
6101    pub fn tag_name(&self) -> &str {
6102        self.short_name()
6103    }
6104
6105    pub fn short_name(&self) -> &str {
6106        &self.name[Self::PREFIX.len()..]
6107    }
6108
6109    pub fn into_str(self) -> &'a str {
6110        self.name
6111    }
6112
6113    pub fn to_owned(&self) -> TagRefNameBuf {
6114        TagRefNameBuf {
6115            name: self.name.to_string(),
6116        }
6117    }
6118}
6119
6120impl AsRef<str> for TagRefName<'_> {
6121    fn as_ref(&self) -> &str {
6122        self.as_str()
6123    }
6124}
6125
6126impl fmt::Display for TagRefName<'_> {
6127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6128        f.write_str(self.as_str())
6129    }
6130}
6131
6132impl<'a> From<TagRefName<'a>> for FullRefName<'a> {
6133    fn from(name: TagRefName<'a>) -> Self {
6134        name.as_full_ref_name()
6135    }
6136}
6137
6138#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
6139pub struct TagRefNameBuf {
6140    name: String,
6141}
6142
6143impl TagRefNameBuf {
6144    pub fn from_tag_name(tag: &str) -> Result<Self> {
6145        // Mirror git's check_tag_ref(): reject a leading '-' or the literal
6146        // "HEAD", then validate refs/tags/<tag> with check_refname_format().
6147        if tag.starts_with('-') || tag == "HEAD" {
6148            return Err(GitError::InvalidPath(format!("invalid tag name {tag}")));
6149        }
6150        Self::from_tag_name_unrestricted(tag)
6151    }
6152
6153    /// Build `refs/tags/<tag>` validating only the refname format, without the
6154    /// creation-only restrictions (leading `-`, literal `HEAD`). Git's delete
6155    /// path does not run check_tag_ref(), so a tag literally named `HEAD` can
6156    /// still be removed.
6157    pub fn from_tag_name_unrestricted(tag: &str) -> Result<Self> {
6158        let name = format!("{}{}", TagRefName::PREFIX, tag);
6159        check_refname_format(&name, false)?;
6160        Ok(Self { name })
6161    }
6162
6163    pub fn from_full(name: impl Into<String>) -> Result<Self> {
6164        let name = name.into();
6165        TagRefName::from_full(&name)?;
6166        Ok(Self { name })
6167    }
6168
6169    pub fn as_ref_name(&self) -> TagRefName<'_> {
6170        TagRefName { name: &self.name }
6171    }
6172
6173    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
6174        FullRefName { name: &self.name }
6175    }
6176
6177    pub fn as_str(&self) -> &str {
6178        &self.name
6179    }
6180
6181    pub fn tag_name(&self) -> &str {
6182        self.short_name()
6183    }
6184
6185    pub fn short_name(&self) -> &str {
6186        &self.name[TagRefName::PREFIX.len()..]
6187    }
6188
6189    pub fn into_string(self) -> String {
6190        self.name
6191    }
6192}
6193
6194impl AsRef<str> for TagRefNameBuf {
6195    fn as_ref(&self) -> &str {
6196        self.as_str()
6197    }
6198}
6199
6200impl Borrow<str> for TagRefNameBuf {
6201    fn borrow(&self) -> &str {
6202        self.as_str()
6203    }
6204}
6205
6206impl Deref for TagRefNameBuf {
6207    type Target = str;
6208
6209    fn deref(&self) -> &Self::Target {
6210        self.as_str()
6211    }
6212}
6213
6214impl fmt::Display for TagRefNameBuf {
6215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6216        f.write_str(self.as_str())
6217    }
6218}
6219
6220impl From<TagRefNameBuf> for FullRefNameBuf {
6221    fn from(name: TagRefNameBuf) -> Self {
6222        Self { name: name.name }
6223    }
6224}
6225
6226#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
6227pub struct RemoteRefName<'a> {
6228    name: &'a str,
6229}
6230
6231impl<'a> RemoteRefName<'a> {
6232    pub const PREFIX: &'static str = "refs/remotes/";
6233
6234    pub fn from_full(name: &'a str) -> Result<Self> {
6235        let full = FullRefName::new(name)?;
6236        Self::from_full_ref(full)
6237    }
6238
6239    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
6240        validate_namespaced_ref(name.as_str(), Self::PREFIX, "remote")?;
6241        Ok(Self {
6242            name: name.into_str(),
6243        })
6244    }
6245
6246    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
6247        FullRefName { name: self.name }
6248    }
6249
6250    pub fn as_str(&self) -> &str {
6251        self.name
6252    }
6253
6254    pub fn short_name(&self) -> &str {
6255        &self.name[Self::PREFIX.len()..]
6256    }
6257
6258    pub fn remote_name(&self) -> &str {
6259        match self.short_name().split_once('/') {
6260            Some((remote, _branch)) => remote,
6261            None => self.short_name(),
6262        }
6263    }
6264
6265    pub fn remote_branch(&self) -> Option<&str> {
6266        self.short_name()
6267            .split_once('/')
6268            .map(|(_remote, branch)| branch)
6269    }
6270
6271    pub fn into_str(self) -> &'a str {
6272        self.name
6273    }
6274
6275    pub fn to_owned(&self) -> RemoteRefNameBuf {
6276        RemoteRefNameBuf {
6277            name: self.name.to_string(),
6278        }
6279    }
6280}
6281
6282impl AsRef<str> for RemoteRefName<'_> {
6283    fn as_ref(&self) -> &str {
6284        self.as_str()
6285    }
6286}
6287
6288impl fmt::Display for RemoteRefName<'_> {
6289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6290        f.write_str(self.as_str())
6291    }
6292}
6293
6294impl<'a> From<RemoteRefName<'a>> for FullRefName<'a> {
6295    fn from(name: RemoteRefName<'a>) -> Self {
6296        name.as_full_ref_name()
6297    }
6298}
6299
6300#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
6301pub struct RemoteRefNameBuf {
6302    name: String,
6303}
6304
6305impl RemoteRefNameBuf {
6306    pub fn from_short_name(name: &str) -> Result<Self> {
6307        validate_short_ref_name("remote ref", name)?;
6308        let name = format!("{}{}", RemoteRefName::PREFIX, name);
6309        Self::from_full(name)
6310    }
6311
6312    pub fn from_remote_branch(remote: &str, branch: &str) -> Result<Self> {
6313        validate_remote_name(remote)?;
6314        validate_short_ref_name("remote branch", branch)?;
6315        let name = format!("{}{}/{}", RemoteRefName::PREFIX, remote, branch);
6316        Self::from_full(name)
6317    }
6318
6319    pub fn from_full(name: impl Into<String>) -> Result<Self> {
6320        let name = name.into();
6321        RemoteRefName::from_full(&name)?;
6322        Ok(Self { name })
6323    }
6324
6325    pub fn as_ref_name(&self) -> RemoteRefName<'_> {
6326        RemoteRefName { name: &self.name }
6327    }
6328
6329    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
6330        FullRefName { name: &self.name }
6331    }
6332
6333    pub fn as_str(&self) -> &str {
6334        &self.name
6335    }
6336
6337    pub fn short_name(&self) -> &str {
6338        &self.name[RemoteRefName::PREFIX.len()..]
6339    }
6340
6341    pub fn remote_name(&self) -> &str {
6342        match self.short_name().split_once('/') {
6343            Some((remote, _branch)) => remote,
6344            None => self.short_name(),
6345        }
6346    }
6347
6348    pub fn remote_branch(&self) -> Option<&str> {
6349        self.short_name()
6350            .split_once('/')
6351            .map(|(_remote, branch)| branch)
6352    }
6353
6354    pub fn into_string(self) -> String {
6355        self.name
6356    }
6357}
6358
6359impl AsRef<str> for RemoteRefNameBuf {
6360    fn as_ref(&self) -> &str {
6361        self.as_str()
6362    }
6363}
6364
6365impl Borrow<str> for RemoteRefNameBuf {
6366    fn borrow(&self) -> &str {
6367        self.as_str()
6368    }
6369}
6370
6371impl Deref for RemoteRefNameBuf {
6372    type Target = str;
6373
6374    fn deref(&self) -> &Self::Target {
6375        self.as_str()
6376    }
6377}
6378
6379impl fmt::Display for RemoteRefNameBuf {
6380    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6381        f.write_str(self.as_str())
6382    }
6383}
6384
6385impl From<RemoteRefNameBuf> for FullRefNameBuf {
6386    fn from(name: RemoteRefNameBuf) -> Self {
6387        Self { name: name.name }
6388    }
6389}
6390
6391pub fn branch_ref_name(branch: &str) -> Result<String> {
6392    BranchRefNameBuf::from_branch_name(branch).map(BranchRefNameBuf::into_string)
6393}
6394
6395pub fn branch_ref_name_for_read(branch: &str) -> Result<String> {
6396    let name = format!("{}{}", BranchRefName::PREFIX, branch);
6397    if validate_ref_name(&name).is_err() {
6398        if name.contains("..") {
6399            validate_ref_path_safe_for_read(&name)?;
6400        } else {
6401            return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
6402        }
6403    }
6404    Ok(name)
6405}
6406
6407pub fn branch_ref_name_for_source(branch: &str) -> Result<String> {
6408    if branch.starts_with("--") {
6409        return Err(GitError::InvalidPath(format!(
6410            "invalid branch name {branch}"
6411        )));
6412    }
6413    let name = format!("{}{}", BranchRefName::PREFIX, branch);
6414    check_refname_format(&name, false)?;
6415    Ok(name)
6416}
6417
6418pub fn tag_ref_name(tag: &str) -> Result<String> {
6419    TagRefNameBuf::from_tag_name(tag).map(TagRefNameBuf::into_string)
6420}
6421
6422fn sync_reference_file(file: &fs::File, method: ReferenceFsyncMethod) -> std::io::Result<()> {
6423    #[cfg(test)]
6424    {
6425        REFERENCE_FSYNC_CALLS.with(|calls| calls.set(calls.get() + 1));
6426        let fail = FAIL_REFERENCE_FSYNC.with(|fail| fail.replace(false));
6427        if fail {
6428            return Err(std::io::Error::other("injected reference fsync failure"));
6429        }
6430    }
6431    match method {
6432        ReferenceFsyncMethod::WriteoutOnly => file.sync_data(),
6433        ReferenceFsyncMethod::Fsync | ReferenceFsyncMethod::Batch => file.sync_all(),
6434    }
6435}
6436
6437fn write_locked(
6438    path: &Path,
6439    bytes: &[u8],
6440    fsync_method: Option<ReferenceFsyncMethod>,
6441) -> Result<()> {
6442    write_locked_with_timeout(path, bytes, 0, fsync_method)
6443}
6444
6445fn write_locked_with_timeout(
6446    path: &Path,
6447    bytes: &[u8],
6448    timeout_millis: u64,
6449    fsync_method: Option<ReferenceFsyncMethod>,
6450) -> Result<()> {
6451    let lock_path = lock_path_for(path)?;
6452    let start = SystemTime::now();
6453    loop {
6454        match fs::OpenOptions::new()
6455            .write(true)
6456            .create_new(true)
6457            .open(&lock_path)
6458        {
6459            Ok(mut file) => {
6460                let write_result = file.write_all(bytes).and_then(|()| match fsync_method {
6461                    Some(method) => sync_reference_file(&file, method),
6462                    None => Ok(()),
6463                });
6464                if let Err(err) = write_result {
6465                    drop(file);
6466                    let _ = fs::remove_file(&lock_path);
6467                    return Err(err.into());
6468                }
6469                break;
6470            }
6471            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists && timeout_millis > 0 => {
6472                let elapsed = start
6473                    .elapsed()
6474                    .unwrap_or_else(|_| Duration::from_millis(timeout_millis + 1));
6475                if elapsed.as_millis() as u64 >= timeout_millis {
6476                    return Err(GitError::Io(format!(
6477                        "could not lock {}: File exists",
6478                        path.display()
6479                    )));
6480                }
6481                thread::sleep(Duration::from_millis(50));
6482            }
6483            Err(err) => return Err(err.into()),
6484        }
6485    }
6486    match fs::rename(&lock_path, path) {
6487        Ok(()) => Ok(()),
6488        Err(err) => {
6489            let _ = fs::remove_file(lock_path);
6490            Err(GitError::Io(err.to_string()))
6491        }
6492    }
6493}
6494
6495fn acquire_path_lock_with_timeout(path: &Path, timeout_millis: u64) -> Result<()> {
6496    let start = SystemTime::now();
6497    loop {
6498        match fs::OpenOptions::new()
6499            .write(true)
6500            .create_new(true)
6501            .open(path)
6502        {
6503            Ok(file) => {
6504                drop(file);
6505                return Ok(());
6506            }
6507            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists && timeout_millis > 0 => {
6508                let elapsed = start
6509                    .elapsed()
6510                    .unwrap_or_else(|_| Duration::from_millis(timeout_millis + 1));
6511                if elapsed.as_millis() as u64 >= timeout_millis {
6512                    return Err(GitError::Io(format!(
6513                        "Unable to create '{}': File exists",
6514                        path.display()
6515                    )));
6516                }
6517                thread::sleep(Duration::from_millis(50));
6518            }
6519            Err(err) => {
6520                return Err(GitError::Io(format!(
6521                    "Unable to create '{}': {err}",
6522                    path.display()
6523                )));
6524            }
6525        }
6526    }
6527}
6528
6529fn lock_path_for(path: &Path) -> Result<PathBuf> {
6530    let file_name = path
6531        .file_name()
6532        .ok_or_else(|| GitError::InvalidPath("ref path has no filename".into()))?;
6533    let mut lock_name = file_name.to_os_string();
6534    lock_name.push(".lock");
6535    Ok(path.with_file_name(lock_name))
6536}
6537
6538/// Validate a ref name using git's `check_refname_format` rules.
6539pub fn check_refname_format(name: &str, allow_onelevel: bool) -> Result<()> {
6540    if name.is_empty()
6541        || name == "@"
6542        || name.starts_with('/')
6543        || name.ends_with('/')
6544        || name.ends_with('.')
6545        || name.contains("..")
6546        || name.contains("//")
6547        || name.contains("@{")
6548        || (!allow_onelevel && !name.contains('/'))
6549    {
6550        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
6551    }
6552    for component in name.split('/') {
6553        if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") {
6554            return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
6555        }
6556        for (idx, byte) in component.bytes().enumerate() {
6557            if byte <= b' '
6558                || byte == 0x7f
6559                || matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
6560            {
6561                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
6562            }
6563            if byte == b'.' && component.as_bytes().get(idx + 1) == Some(&b'.') {
6564                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
6565            }
6566            if byte == b'@' && component.as_bytes().get(idx + 1) == Some(&b'{') {
6567                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
6568            }
6569        }
6570    }
6571    Ok(())
6572}
6573
6574/// Validate a symbolic ref name (HEAD, one-level pseudo-refs, or `refs/...`).
6575pub fn validate_symref_name(name: &str) -> Result<()> {
6576    if name == "HEAD" {
6577        return Ok(());
6578    }
6579    check_refname_format(name, true)
6580}
6581
6582/// Validate a symbolic ref target (one-level pseudo-refs or `refs/...`).
6583pub fn validate_symref_target(name: &str) -> Result<()> {
6584    check_refname_format(name, true)
6585}
6586
6587/// Follow symbolic ref chains until a direct OID is reached.
6588/// Remove empty directories starting at `start` and walking up toward
6589/// `boundary`, stopping at the first non-empty directory or when `boundary` is
6590/// reached (exclusive). `boundary` itself is never removed.
6591fn prune_empty_dirs_up_to(start: &Path, boundary: &Path) {
6592    let mut dir = start.to_path_buf();
6593    while dir.starts_with(boundary) && dir != *boundary {
6594        if fs::remove_dir(&dir).is_err() {
6595            break;
6596        }
6597        dir = match dir.parent() {
6598            Some(parent) => parent.to_path_buf(),
6599            None => break,
6600        };
6601    }
6602}
6603
6604fn packable_loose_ref_name(name: &str) -> bool {
6605    name.starts_with("refs/")
6606        && !name.starts_with("refs/bisect/")
6607        && !name.starts_with("refs/worktree/")
6608        && !name.starts_with("refs/rewritten/")
6609}
6610
6611fn pack_refs_auto_required_for(packed_path: &Path, loose_count: usize) -> Result<bool> {
6612    let packed_size = match fs::metadata(packed_path) {
6613        Ok(meta) => meta.len() as usize,
6614        Err(err) if err.kind() == std::io::ErrorKind::NotFound => 0,
6615        Err(err) => return Err(err.into()),
6616    };
6617    let estimated_packed_refs = packed_size / 100;
6618    let log2 = if estimated_packed_refs == 0 {
6619        0
6620    } else {
6621        usize::BITS as usize - estimated_packed_refs.leading_zeros() as usize - 1
6622    };
6623    let limit = (log2 * 5).max(16);
6624    Ok(loose_count >= limit)
6625}
6626
6627pub fn resolve_ref_peeled(store: &FileRefStore, name: &str) -> Result<Option<ObjectId>> {
6628    let mut current = name.to_string();
6629    for _ in 0..16 {
6630        match store.read_ref(&current)? {
6631            Some(RefTarget::Direct(oid)) => return Ok(Some(oid)),
6632            Some(RefTarget::Symbolic(next)) => {
6633                if validate_ref_name_for_read(&next).is_err() {
6634                    return Ok(None);
6635                }
6636                current = next;
6637            }
6638            None => return Ok(None),
6639        }
6640    }
6641    Ok(None)
6642}
6643
6644pub fn validate_ref_name_for_read(name: &str) -> Result<()> {
6645    if validate_ref_name(name).is_ok() {
6646        return Ok(());
6647    }
6648    if is_root_ref_syntax(name) {
6649        return Ok(());
6650    }
6651    if check_refname_format(name, true).is_ok() {
6652        return Ok(());
6653    }
6654    validate_ref_path_safe_for_read(name)
6655}
6656
6657pub fn validate_ref_name_for_update(name: &str) -> Result<()> {
6658    if validate_ref_name(name).is_ok() {
6659        return Ok(());
6660    }
6661    if is_root_ref_syntax(name) {
6662        return Ok(());
6663    }
6664    check_refname_format(name, true)
6665}
6666
6667/// git's `refname_is_safe` (refs.c): the gate applied when *deleting* a ref
6668/// (`transaction_refname_valid` with a null new-oid). It is stricter than the
6669/// create-time `check_refname_format(_, REFNAME_ALLOW_ONELEVEL)`:
6670///   - a name under `refs/` is safe when the remainder is non-empty, has no
6671///     leading/trailing `/`, and does not escape `refs/` (`..`, absolute,
6672///     backslash component);
6673///   - any other (one-level) name is safe only when every byte is an uppercase
6674///     ASCII letter or `_` — the pseudo-ref shape (`HEAD`, `ORIG_HEAD`).
6675///
6676/// So a one-level name like `my-private-file` is *creatable* (`update-ref
6677/// my-private-file <oid>`) yet refused for deletion (`update-ref -d
6678/// my-private-file` → "refusing to update ref with bad name"), which is what
6679/// keeps `update-ref -d` from unlinking arbitrary files inside `.git`.
6680pub fn refname_is_safe(refname: &str) -> bool {
6681    if let Some(rest) = refname.strip_prefix("refs/") {
6682        if rest.is_empty() || rest.starts_with('/') || rest.ends_with('/') || rest.contains('\\') {
6683            return false;
6684        }
6685        let path = Path::new(rest);
6686        !path.is_absolute()
6687            && !path.components().any(|component| {
6688                matches!(
6689                    component,
6690                    std::path::Component::ParentDir
6691                        | std::path::Component::Prefix(_)
6692                        | std::path::Component::RootDir
6693                )
6694            })
6695    } else {
6696        !refname.is_empty() && refname.bytes().all(|b| b.is_ascii_uppercase() || b == b'_')
6697    }
6698}
6699
6700/// git's is_root_ref_syntax (refs.c): a ref name made only of uppercase ASCII,
6701/// `-`, and `_` (e.g. HEAD, FETCH_HEAD, MERGE_HEAD). Such names live in the
6702/// per-worktree gitdir rather than the common refs/ tree. An empty name is not
6703/// root-ref syntax.
6704fn is_root_ref_syntax(name: &str) -> bool {
6705    !name.is_empty()
6706        && name
6707            .bytes()
6708            .all(|b| b.is_ascii_uppercase() || b == b'-' || b == b'_')
6709}
6710
6711const CURRENT_WORKTREE_REF_PREFIXES: &[&str] =
6712    &["refs/bisect/", "refs/worktree/", "refs/rewritten/"];
6713
6714fn current_worktree_ref(name: &str) -> bool {
6715    is_root_ref_syntax(name)
6716        || CURRENT_WORKTREE_REF_PREFIXES
6717            .iter()
6718            .any(|prefix| name.starts_with(prefix))
6719}
6720
6721fn ref_prefixes_overlap(prefix: &str, namespace: &str) -> bool {
6722    let namespace = namespace.trim_end_matches('/');
6723    prefix == "refs/"
6724        || prefix == namespace
6725        || prefix.starts_with(&format!("{namespace}/"))
6726        || namespace.starts_with(prefix.trim_end_matches('/'))
6727}
6728
6729fn reftable_current_worktree_ref(name: &str) -> bool {
6730    current_worktree_ref(name)
6731}
6732
6733fn reftable_other_worktree_ref(name: &str) -> Option<(&str, &str)> {
6734    let rest = name.strip_prefix("worktrees/")?;
6735    let (worktree, rewritten) = rest.split_once('/')?;
6736    if worktree.is_empty() || rewritten.is_empty() {
6737        return None;
6738    }
6739    Some((worktree, rewritten))
6740}
6741
6742pub fn validate_ref_name(name: &str) -> Result<()> {
6743    if name == "HEAD" {
6744        return Ok(());
6745    }
6746    if !name.starts_with("refs/") || check_refname_format(name, false).is_err() {
6747        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
6748    }
6749    Ok(())
6750}
6751
6752fn validate_ref_path_safe_for_read(name: &str) -> Result<()> {
6753    let path = Path::new(name);
6754    if !name.starts_with("refs/")
6755        || name.starts_with('/')
6756        || name.contains('\\')
6757        || path.is_absolute()
6758        || path.components().any(|component| {
6759            matches!(
6760                component,
6761                std::path::Component::ParentDir | std::path::Component::Prefix(_)
6762            )
6763        })
6764    {
6765        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
6766    }
6767    Ok(())
6768}
6769
6770fn safe_ref_prefix_for_directory_scan(prefix: &str) -> bool {
6771    let path = Path::new(prefix);
6772    prefix.starts_with("refs/")
6773        && !prefix.starts_with('/')
6774        && !prefix.contains('\\')
6775        && !path.is_absolute()
6776        && !path.components().any(|component| {
6777            matches!(
6778                component,
6779                std::path::Component::ParentDir | std::path::Component::Prefix(_)
6780            )
6781        })
6782}
6783
6784fn warn_broken_ref_name(name: &str) {
6785    eprintln!("warning: ignoring ref with broken name {name}");
6786}
6787
6788fn warn_broken_ref(name: &str) {
6789    eprintln!("warning: ignoring broken ref {name}");
6790}
6791
6792/// A direct ref resolving to the null OID is broken (git's `REF_ISBROKEN`).
6793fn ref_target_is_broken(target: &RefTarget) -> bool {
6794    matches!(target, RefTarget::Direct(oid) if oid.is_null())
6795}
6796
6797fn ref_directory_conflict_error(new_ref: &str, existing_ref: &str) -> GitError {
6798    GitError::Transaction(format!(
6799        "cannot lock ref '{new_ref}': '{existing_ref}' exists; cannot create '{new_ref}'"
6800    ))
6801}
6802
6803fn check_ref_directory_conflict_in_names(name: &str, names: &BTreeSet<String>) -> Result<()> {
6804    let components = name.split('/').collect::<Vec<_>>();
6805    for index in 1..components.len() {
6806        let ancestor = components[..index].join("/");
6807        if names.contains(&ancestor) {
6808            return Err(ref_directory_conflict_error(name, &ancestor));
6809        }
6810    }
6811    let child_prefix = format!("{name}/");
6812    if let Some(existing) = names.range(child_prefix.clone()..).next()
6813        && existing.starts_with(&child_prefix)
6814    {
6815        return Err(ref_directory_conflict_error(name, existing));
6816    }
6817    Ok(())
6818}
6819
6820fn parent_to_ref_name(base: &Path, parent: &Path) -> String {
6821    match parent.strip_prefix(base) {
6822        Ok(suffix) => suffix.to_string_lossy().replace('\\', "/"),
6823        Err(_) => parent.to_string_lossy().into_owned(),
6824    }
6825}
6826
6827fn validate_namespaced_ref(name: &str, prefix: &str, kind: &str) -> Result<()> {
6828    validate_ref_name(name)?;
6829    if name
6830        .strip_prefix(prefix)
6831        .is_none_or(|short_name| short_name.is_empty())
6832    {
6833        return Err(GitError::InvalidPath(format!(
6834            "invalid {kind} ref name {name}"
6835        )));
6836    }
6837    Ok(())
6838}
6839
6840fn validate_short_ref_name(kind: &str, name: &str) -> Result<()> {
6841    if name.is_empty()
6842        || name.starts_with('-')
6843        || name.starts_with('/')
6844        || name.ends_with('/')
6845        || name.contains(' ')
6846        || name.contains('\\')
6847    {
6848        return Err(GitError::InvalidPath(format!("invalid {kind} name {name}")));
6849    }
6850    Ok(())
6851}
6852
6853fn validate_remote_name(remote: &str) -> Result<()> {
6854    validate_short_ref_name("remote", remote)?;
6855    if remote.contains('/') {
6856        return Err(GitError::InvalidPath(format!(
6857            "invalid remote name {remote}"
6858        )));
6859    }
6860    Ok(())
6861}
6862
6863fn prepare_bundle_ref_updates<F>(
6864    refs: &[BundleRefUpdate],
6865    reflog: Option<&BundleRefUpdateReflog>,
6866    mut read_ref: F,
6867) -> Result<(Vec<RefUpdate>, Vec<AppliedBundleRefUpdate>)>
6868where
6869    F: FnMut(&str, &ObjectId) -> Result<Option<RefTarget>>,
6870{
6871    let mut seen = BTreeSet::new();
6872    let mut updates = Vec::with_capacity(refs.len());
6873    let mut applied = Vec::with_capacity(refs.len());
6874    for bundle_ref in refs {
6875        validate_ref_name(&bundle_ref.name)?;
6876        if !seen.insert(bundle_ref.name.clone()) {
6877            return Err(GitError::Transaction(format!(
6878                "duplicate bundle ref {}",
6879                bundle_ref.name
6880            )));
6881        }
6882        let old_oid = match read_ref(&bundle_ref.name, &bundle_ref.oid)? {
6883            Some(RefTarget::Direct(oid)) => Some(oid),
6884            Some(RefTarget::Symbolic(target)) => {
6885                return Err(GitError::Transaction(format!(
6886                    "bundle ref {} would overwrite symbolic ref {target}",
6887                    bundle_ref.name
6888                )));
6889            }
6890            None => None,
6891        };
6892        let reflog = match reflog {
6893            Some(reflog) => Some(ReflogEntry {
6894                old_oid: match &old_oid {
6895                    Some(oid) => *oid,
6896                    None => null_oid(bundle_ref.oid.format())?,
6897                },
6898                new_oid: bundle_ref.oid,
6899                committer: reflog.committer.clone(),
6900                message: reflog.message.clone(),
6901            }),
6902            None => None,
6903        };
6904        updates.push(RefUpdate {
6905            name: bundle_ref.name.clone(),
6906            expected: old_oid.map(RefTarget::Direct),
6907            new: RefTarget::Direct(bundle_ref.oid),
6908            reflog,
6909        });
6910        applied.push(AppliedBundleRefUpdate {
6911            name: bundle_ref.name.clone(),
6912            old_oid,
6913            new_oid: bundle_ref.oid,
6914        });
6915    }
6916    Ok((updates, applied))
6917}
6918
6919fn null_oid(format: ObjectFormat) -> Result<ObjectId> {
6920    Ok(ObjectId::null(format))
6921}
6922
6923#[cfg(test)]
6924mod tests {
6925    use super::*;
6926    use std::sync::atomic::{AtomicU64, Ordering};
6927
6928    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
6929
6930    #[test]
6931    fn refname_patterns_match_git_wildcards_and_case_policy() {
6932        assert!(refname_pattern_matches_case(
6933            "release/*",
6934            "release/1",
6935            false
6936        ));
6937        assert!(refname_pattern_matches_case("V[1-3].?", "v2.0", true));
6938        assert!(!refname_pattern_matches_case("V[1-3].?", "v2.0", false));
6939        assert!(refname_pattern_matches_case(
6940            r"release\*",
6941            "release*",
6942            false
6943        ));
6944        assert!(refname_pattern_matches_case("release[", "release[", false));
6945    }
6946
6947    #[test]
6948    fn core_fsync_reference_component_matches_git_groups_and_negation() {
6949        assert!(!core_fsync_includes_reference("none"));
6950        assert!(!core_fsync_includes_reference("objects,index"));
6951        assert!(!core_fsync_includes_reference("-reference"));
6952        assert!(core_fsync_includes_reference("reference"));
6953        assert!(core_fsync_includes_reference("ref"));
6954        assert!(core_fsync_includes_reference("committed"));
6955        assert!(core_fsync_includes_reference("added"));
6956        assert!(core_fsync_includes_reference("all"));
6957        // Git accumulates positive and negative masks, then applies positives
6958        // last, so an explicitly included component wins in either order.
6959        assert!(core_fsync_includes_reference("reference,-reference"));
6960        assert!(core_fsync_includes_reference("-reference,reference"));
6961    }
6962
6963    #[test]
6964    fn reference_fsync_policy_controls_reference_barriers() {
6965        let disabled_dir = temp_git_dir();
6966        REFERENCE_FSYNC_CALLS.with(|calls| calls.set(0));
6967        FileRefStore::new(&disabled_dir, ObjectFormat::Sha1)
6968            .with_reference_fsync_config(Some("none"), Some("fsync"))
6969            .write_packed_refs(&[])
6970            .expect("disabled reference write");
6971        assert_eq!(REFERENCE_FSYNC_CALLS.with(std::cell::Cell::get), 0);
6972        fs::remove_dir_all(disabled_dir).expect("remove disabled repository");
6973
6974        let enabled_dir = temp_git_dir();
6975        REFERENCE_FSYNC_CALLS.with(|calls| calls.set(0));
6976        FileRefStore::new(&enabled_dir, ObjectFormat::Sha1)
6977            .with_reference_fsync_config(Some("reference"), Some("fsync"))
6978            .write_packed_refs(&[])
6979            .expect("enabled reference write");
6980        assert_eq!(REFERENCE_FSYNC_CALLS.with(std::cell::Cell::get), 1);
6981        fs::remove_dir_all(enabled_dir).expect("remove enabled repository");
6982    }
6983
6984    #[test]
6985    fn reference_fsync_failure_aborts_locked_write_and_cleans_lock() {
6986        let git_dir = temp_git_dir();
6987        REFERENCE_FSYNC_CALLS.with(|calls| calls.set(0));
6988        FAIL_REFERENCE_FSYNC.with(|fail| fail.set(true));
6989        let err = FileRefStore::new(&git_dir, ObjectFormat::Sha1)
6990            .with_reference_fsync_config(Some("reference"), Some("fsync"))
6991            .write_packed_refs(&[])
6992            .expect_err("injected fsync failure must abort");
6993        assert!(err.to_string().contains("injected reference fsync failure"));
6994        assert!(!git_dir.join("packed-refs").exists());
6995        assert!(!git_dir.join("packed-refs.lock").exists());
6996        assert_eq!(REFERENCE_FSYNC_CALLS.with(std::cell::Cell::get), 1);
6997        fs::remove_dir_all(git_dir).expect("remove failed repository");
6998    }
6999
7000    #[test]
7001    fn loose_ref_round_trips_direct() {
7002        let oid = "ce013625030ba8dba906f756967f9e9ca394464a";
7003        let reference = parse_loose_ref(ObjectFormat::Sha1, "refs/heads/main", oid.as_bytes())
7004            .expect("test operation should succeed");
7005        assert_eq!(write_loose_ref(&reference), format!("{oid}\n").into_bytes());
7006    }
7007
7008    #[test]
7009    fn loose_fetch_head_reads_first_object_id() {
7010        let oid = ObjectId::from_hex(
7011            ObjectFormat::Sha1,
7012            "ce013625030ba8dba906f756967f9e9ca394464a",
7013        )
7014        .expect("test operation should succeed");
7015        let bytes = b"ce013625030ba8dba906f756967f9e9ca394464a\t\tbranch 'main' of ../sub\n";
7016        let reference = parse_loose_ref(ObjectFormat::Sha1, "FETCH_HEAD", bytes)
7017            .expect("test operation should succeed");
7018        assert_eq!(reference.target, RefTarget::Direct(oid));
7019    }
7020
7021    #[test]
7022    fn symref_names_allow_onelevel_pseudo_refs() {
7023        for name in ["NOTHEAD", "FOO", "ORIG_HEAD", "TEST_SYMREF"] {
7024            validate_symref_name(name).expect("symref name should be valid");
7025        }
7026        assert!(validate_ref_name("NOTHEAD").is_err());
7027        assert!(validate_symref_target("refs/heads/foo").is_ok());
7028        assert!(validate_symref_target("ORIG_HEAD").is_ok());
7029        assert!(validate_symref_target("foo..bar").is_err());
7030    }
7031
7032    #[test]
7033    fn resolve_ref_peeled_follows_symref_chains() {
7034        let git_dir = temp_git_dir();
7035        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7036        let oid = ObjectId::from_hex(
7037            ObjectFormat::Sha1,
7038            "ce013625030ba8dba906f756967f9e9ca394464a",
7039        )
7040        .expect("test operation should succeed");
7041        let mut tx = store.transaction();
7042        tx.update(RefUpdate {
7043            name: "refs/heads/target".into(),
7044            expected: None,
7045            new: RefTarget::Direct(oid),
7046            reflog: None,
7047        });
7048        tx.commit().expect("seed target ref");
7049        let mut tx = store.transaction();
7050        tx.update(RefUpdate {
7051            name: "refs/heads/alias".into(),
7052            expected: None,
7053            new: RefTarget::Symbolic("refs/heads/target".into()),
7054            reflog: None,
7055        });
7056        tx.commit().expect("seed alias ref");
7057        let mut tx = store.transaction();
7058        tx.update(RefUpdate {
7059            name: "ORIG_HEAD".into(),
7060            expected: None,
7061            new: RefTarget::Symbolic("refs/heads/alias".into()),
7062            reflog: None,
7063        });
7064        tx.commit().expect("seed ORIG_HEAD symref");
7065        assert_eq!(
7066            resolve_ref_peeled(&store, "ORIG_HEAD").expect("resolve ORIG_HEAD"),
7067            Some(oid)
7068        );
7069        let _ = fs::remove_dir_all(git_dir);
7070    }
7071
7072    #[test]
7073    fn symref_directory_conflict_is_reported_gracefully() {
7074        let git_dir = temp_git_dir();
7075        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7076        let oid = ObjectId::from_hex(
7077            ObjectFormat::Sha1,
7078            "ce013625030ba8dba906f756967f9e9ca394464a",
7079        )
7080        .expect("test operation should succeed");
7081        let mut tx = store.transaction();
7082        tx.update(RefUpdate {
7083            name: "refs/heads/df".into(),
7084            expected: None,
7085            new: RefTarget::Direct(oid),
7086            reflog: None,
7087        });
7088        tx.commit().expect("seed branch ref");
7089
7090        let mut tx = store.transaction();
7091        tx.update(RefUpdate {
7092            name: "refs/heads/df/conflict".into(),
7093            expected: None,
7094            new: RefTarget::Symbolic("refs/heads/df".into()),
7095            reflog: None,
7096        });
7097        let err = tx.commit().expect_err("child ref should conflict");
7098        assert!(
7099            matches!(err, GitError::Transaction(message) if message.contains(
7100            "cannot lock ref 'refs/heads/df/conflict'"
7101        ) && message.contains("refs/heads/df"))
7102        );
7103        let _ = fs::remove_dir_all(git_dir);
7104    }
7105
7106    #[test]
7107    fn transaction_checks_expected_value() {
7108        let oid = ObjectId::from_hex(
7109            ObjectFormat::Sha1,
7110            "ce013625030ba8dba906f756967f9e9ca394464a",
7111        )
7112        .expect("test operation should succeed");
7113        let mut store = RefStore::new();
7114        let mut tx = store.transaction();
7115        tx.update(RefUpdate {
7116            name: "refs/heads/main".into(),
7117            expected: None,
7118            new: RefTarget::Direct(oid),
7119            reflog: None,
7120        });
7121        tx.commit().expect("test operation should succeed");
7122        assert_eq!(store.get("refs/heads/main"), Some(&RefTarget::Direct(oid)));
7123    }
7124
7125    #[test]
7126    fn packed_refs_parse_peeled_refs() {
7127        let packed = b"# pack-refs with: peeled fully-peeled sorted \n\
7128ce013625030ba8dba906f756967f9e9ca394464a refs/tags/v1\n\
7129^e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n";
7130        let refs =
7131            parse_packed_refs(ObjectFormat::Sha1, packed).expect("test operation should succeed");
7132        assert_eq!(refs.len(), 1);
7133        assert_eq!(refs[0].reference.name, "refs/tags/v1");
7134        assert_eq!(
7135            refs[0]
7136                .peeled
7137                .as_ref()
7138                .expect("test operation should succeed")
7139                .to_hex(),
7140            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
7141        );
7142    }
7143
7144    #[test]
7145    fn packed_refs_write_sorted_with_peeled_refs() {
7146        let head_oid = ObjectId::from_hex(
7147            ObjectFormat::Sha1,
7148            "ce013625030ba8dba906f756967f9e9ca394464a",
7149        )
7150        .expect("test operation should succeed");
7151        let tag_oid = ObjectId::from_hex(
7152            ObjectFormat::Sha1,
7153            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
7154        )
7155        .expect("test operation should succeed");
7156        let peeled_oid = ObjectId::from_hex(
7157            ObjectFormat::Sha1,
7158            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7159        )
7160        .expect("test operation should succeed");
7161        let refs = vec![
7162            PackedRef {
7163                reference: Ref {
7164                    name: "refs/tags/v1".into(),
7165                    target: RefTarget::Direct(tag_oid),
7166                },
7167                peeled: Some(peeled_oid),
7168            },
7169            PackedRef {
7170                reference: Ref {
7171                    name: "refs/heads/main".into(),
7172                    target: RefTarget::Direct(head_oid),
7173                },
7174                peeled: None,
7175            },
7176        ];
7177        let bytes = write_packed_refs(&refs).expect("test operation should succeed");
7178        let expected = format!(
7179            "# pack-refs with: peeled fully-peeled sorted \n\
7180{head_oid} refs/heads/main\n\
7181{tag_oid} refs/tags/v1\n\
7182^{peeled_oid}\n"
7183        );
7184        assert_eq!(
7185            String::from_utf8(bytes.clone()).expect("test operation should succeed"),
7186            expected
7187        );
7188        let parsed =
7189            parse_packed_refs(ObjectFormat::Sha1, &bytes).expect("test operation should succeed");
7190        assert_eq!(parsed[0], refs[1]);
7191        assert_eq!(parsed[1], refs[0]);
7192    }
7193
7194    #[test]
7195    fn full_ref_name_validates_and_round_trips_owned() {
7196        let full = FullRefName::new("refs/heads/main").expect("valid full branch ref");
7197        assert_eq!(full.as_str(), "refs/heads/main");
7198        assert_eq!(full.to_string(), "refs/heads/main");
7199        assert_eq!(full.to_owned().into_string(), "refs/heads/main");
7200
7201        let head = FullRefNameBuf::new("HEAD").expect("valid HEAD ref");
7202        assert_eq!(head.as_ref_name().into_str(), "HEAD");
7203
7204        assert!(FullRefName::new("main").is_err());
7205        assert!(FullRefNameBuf::new("refs/heads/bad.lock").is_err());
7206    }
7207
7208    #[test]
7209    fn branch_ref_name_helpers_validate_short_and_full_names() {
7210        let branch =
7211            BranchRefNameBuf::from_branch_name("feature/topic").expect("valid branch short name");
7212        assert_eq!(branch.as_str(), "refs/heads/feature/topic");
7213        assert_eq!(branch.branch_name(), "feature/topic");
7214        assert_eq!(
7215            branch.as_full_ref_name().as_str(),
7216            "refs/heads/feature/topic"
7217        );
7218        assert_eq!(
7219            branch_ref_name("feature/topic").expect("valid branch short name"),
7220            branch.as_str()
7221        );
7222
7223        let borrowed = BranchRefName::from_full("refs/heads/main").expect("valid full branch ref");
7224        assert_eq!(borrowed.branch_name(), "main");
7225        assert_eq!(borrowed.to_owned().into_string(), "refs/heads/main");
7226        assert_eq!(
7227            FullRefName::new("refs/heads/main")
7228                .expect("valid full branch ref")
7229                .as_branch()
7230                .expect("full ref is a branch")
7231                .branch_name(),
7232            "main"
7233        );
7234
7235        assert!(BranchRefName::from_full("refs/tags/main").is_err());
7236        assert!(BranchRefName::from_full("refs/heads").is_err());
7237        assert!(BranchRefNameBuf::from_branch_name("-bad").is_err());
7238    }
7239
7240    #[test]
7241    fn tag_ref_name_helpers_validate_short_and_full_names() {
7242        let tag = TagRefNameBuf::from_tag_name("v1.0").expect("valid tag short name");
7243        assert_eq!(tag.as_str(), "refs/tags/v1.0");
7244        assert_eq!(tag.tag_name(), "v1.0");
7245        assert_eq!(tag.as_full_ref_name().as_str(), "refs/tags/v1.0");
7246        assert_eq!(
7247            tag_ref_name("v1.0").expect("valid tag short name"),
7248            tag.as_str()
7249        );
7250
7251        let borrowed = TagRefName::from_full("refs/tags/release/1").expect("valid full tag ref");
7252        assert_eq!(borrowed.tag_name(), "release/1");
7253        assert_eq!(borrowed.to_owned().into_string(), "refs/tags/release/1");
7254        assert_eq!(
7255            FullRefName::new("refs/tags/release/1")
7256                .expect("valid full tag ref")
7257                .as_tag()
7258                .expect("full ref is a tag")
7259                .tag_name(),
7260            "release/1"
7261        );
7262
7263        assert!(TagRefName::from_full("refs/heads/v1.0").is_err());
7264        assert!(TagRefName::from_full("refs/tags").is_err());
7265        assert!(TagRefNameBuf::from_tag_name("bad tag").is_err());
7266    }
7267
7268    #[test]
7269    fn remote_ref_name_helpers_validate_namespace_and_components() {
7270        let remote = RemoteRefNameBuf::from_remote_branch("origin", "feature/topic")
7271            .expect("valid remote branch ref");
7272        assert_eq!(remote.as_str(), "refs/remotes/origin/feature/topic");
7273        assert_eq!(remote.short_name(), "origin/feature/topic");
7274        assert_eq!(remote.remote_name(), "origin");
7275        assert_eq!(remote.remote_branch(), Some("feature/topic"));
7276        assert_eq!(
7277            remote.as_full_ref_name().as_str(),
7278            "refs/remotes/origin/feature/topic"
7279        );
7280
7281        let head =
7282            RemoteRefName::from_full("refs/remotes/origin/HEAD").expect("valid remote HEAD ref");
7283        assert_eq!(head.remote_name(), "origin");
7284        assert_eq!(head.remote_branch(), Some("HEAD"));
7285        assert_eq!(
7286            FullRefName::new("refs/remotes/upstream/main")
7287                .expect("valid full remote ref")
7288                .as_remote()
7289                .expect("full ref is remote-tracking")
7290                .remote_name(),
7291            "upstream"
7292        );
7293
7294        let short =
7295            RemoteRefNameBuf::from_short_name("origin/main").expect("valid remote short ref");
7296        assert_eq!(short.as_str(), "refs/remotes/origin/main");
7297
7298        assert!(RemoteRefName::from_full("refs/heads/origin/main").is_err());
7299        assert!(RemoteRefName::from_full("refs/remotes/").is_err());
7300        assert!(RemoteRefNameBuf::from_remote_branch("origin/fork", "main").is_err());
7301    }
7302
7303    #[test]
7304    fn file_ref_store_writes_ref_and_reflog() {
7305        let git_dir = temp_git_dir();
7306        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7307        let oid = ObjectId::from_hex(
7308            ObjectFormat::Sha1,
7309            "ce013625030ba8dba906f756967f9e9ca394464a",
7310        )
7311        .expect("test operation should succeed");
7312        let mut tx = store.transaction();
7313        tx.update(RefUpdate {
7314            name: "refs/heads/main".into(),
7315            expected: None,
7316            new: RefTarget::Direct(oid),
7317            reflog: Some(ReflogEntry {
7318                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
7319                new_oid: oid,
7320                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
7321                message: b"update by test".to_vec(),
7322            }),
7323        });
7324        tx.commit().expect("test operation should succeed");
7325        assert_eq!(
7326            store
7327                .read_ref("refs/heads/main")
7328                .expect("test operation should succeed"),
7329            Some(RefTarget::Direct(oid))
7330        );
7331        let log = store
7332            .read_reflog("refs/heads/main")
7333            .expect("test operation should succeed");
7334        assert_eq!(log.len(), 1);
7335        assert_eq!(log[0].message, b"update by test");
7336        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7337    }
7338
7339    #[test]
7340    fn file_ref_store_applies_bundle_refs_with_reflog() {
7341        let git_dir = temp_git_dir();
7342        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7343        let old_main = ObjectId::from_hex(
7344            ObjectFormat::Sha1,
7345            "ce013625030ba8dba906f756967f9e9ca394464a",
7346        )
7347        .expect("test operation should succeed");
7348        let new_main = ObjectId::from_hex(
7349            ObjectFormat::Sha1,
7350            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7351        )
7352        .expect("test operation should succeed");
7353        let tag_oid = ObjectId::from_hex(
7354            ObjectFormat::Sha1,
7355            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
7356        )
7357        .expect("test operation should succeed");
7358        let mut tx = store.transaction();
7359        tx.update(RefUpdate {
7360            name: "refs/heads/main".into(),
7361            expected: None,
7362            new: RefTarget::Direct(old_main.clone()),
7363            reflog: None,
7364        });
7365        tx.commit().expect("test operation should succeed");
7366
7367        let applied = store
7368            .apply_bundle_ref_updates(
7369                &[
7370                    BundleRefUpdate {
7371                        name: "refs/heads/main".into(),
7372                        oid: new_main.clone(),
7373                    },
7374                    BundleRefUpdate {
7375                        name: "refs/tags/v1.0".into(),
7376                        oid: tag_oid,
7377                    },
7378                ],
7379                Some(BundleRefUpdateReflog {
7380                    committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
7381                    message: b"bundle: import refs".to_vec(),
7382                }),
7383            )
7384            .expect("test operation should succeed");
7385
7386        assert_eq!(
7387            applied,
7388            vec![
7389                AppliedBundleRefUpdate {
7390                    name: "refs/heads/main".into(),
7391                    old_oid: Some(old_main.clone()),
7392                    new_oid: new_main.clone(),
7393                },
7394                AppliedBundleRefUpdate {
7395                    name: "refs/tags/v1.0".into(),
7396                    old_oid: None,
7397                    new_oid: tag_oid,
7398                }
7399            ]
7400        );
7401        assert_eq!(
7402            store
7403                .read_ref("refs/heads/main")
7404                .expect("test operation should succeed"),
7405            Some(RefTarget::Direct(new_main.clone()))
7406        );
7407        assert_eq!(
7408            store
7409                .read_ref("refs/tags/v1.0")
7410                .expect("test operation should succeed"),
7411            Some(RefTarget::Direct(tag_oid))
7412        );
7413        let main_log = store
7414            .read_reflog("refs/heads/main")
7415            .expect("test operation should succeed");
7416        assert_eq!(main_log.len(), 1);
7417        assert_eq!(main_log[0].old_oid, old_main);
7418        assert_eq!(main_log[0].new_oid, new_main);
7419        assert_eq!(main_log[0].message, b"bundle: import refs");
7420        let tag_log = store
7421            .read_reflog("refs/tags/v1.0")
7422            .expect("test operation should succeed");
7423        assert_eq!(tag_log.len(), 1);
7424        assert_eq!(
7425            tag_log[0].old_oid,
7426            zero_oid(ObjectFormat::Sha1).expect("test operation should succeed")
7427        );
7428        assert_eq!(tag_log[0].new_oid, tag_oid);
7429        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7430    }
7431
7432    #[test]
7433    fn file_ref_store_rejects_bad_bundle_ref_before_writing() {
7434        let git_dir = temp_git_dir();
7435        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7436        let oid = ObjectId::from_hex(
7437            ObjectFormat::Sha1,
7438            "ce013625030ba8dba906f756967f9e9ca394464a",
7439        )
7440        .expect("test operation should succeed");
7441
7442        let result = store.apply_bundle_ref_updates(
7443            &[
7444                BundleRefUpdate {
7445                    name: "refs/heads/main".into(),
7446                    oid,
7447                },
7448                BundleRefUpdate {
7449                    name: "refs/heads/bad.lock".into(),
7450                    oid,
7451                },
7452            ],
7453            None,
7454        );
7455
7456        assert!(result.is_err());
7457        assert_eq!(
7458            store
7459                .read_ref("refs/heads/main")
7460                .expect("test operation should succeed"),
7461            None
7462        );
7463        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7464    }
7465
7466    #[test]
7467    fn file_ref_store_rejects_bundle_ref_over_symbolic_ref() {
7468        let git_dir = temp_git_dir();
7469        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7470        let oid = ObjectId::from_hex(
7471            ObjectFormat::Sha1,
7472            "ce013625030ba8dba906f756967f9e9ca394464a",
7473        )
7474        .expect("test operation should succeed");
7475        let mut tx = store.transaction();
7476        tx.update(RefUpdate {
7477            name: "refs/heads/main".into(),
7478            expected: None,
7479            new: RefTarget::Symbolic("refs/heads/base".into()),
7480            reflog: None,
7481        });
7482        tx.commit().expect("test operation should succeed");
7483
7484        let result = store.apply_bundle_ref_updates(
7485            &[BundleRefUpdate {
7486                name: "refs/heads/main".into(),
7487                oid,
7488            }],
7489            None,
7490        );
7491
7492        assert!(result.is_err());
7493        assert_eq!(
7494            store
7495                .read_ref("refs/heads/main")
7496                .expect("test operation should succeed"),
7497            Some(RefTarget::Symbolic("refs/heads/base".into()))
7498        );
7499        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7500    }
7501
7502    #[test]
7503    fn file_ref_store_expires_reflog_entries_by_timestamp() {
7504        let git_dir = temp_git_dir();
7505        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7506        let first = ObjectId::from_hex(
7507            ObjectFormat::Sha1,
7508            "ce013625030ba8dba906f756967f9e9ca394464a",
7509        )
7510        .expect("test operation should succeed");
7511        let second = ObjectId::from_hex(
7512            ObjectFormat::Sha1,
7513            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7514        )
7515        .expect("test operation should succeed");
7516        let mut tx = store.transaction();
7517        tx.update(RefUpdate {
7518            name: "refs/heads/main".into(),
7519            expected: None,
7520            new: RefTarget::Direct(first.clone()),
7521            reflog: Some(ReflogEntry {
7522                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
7523                new_oid: first.clone(),
7524                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
7525                message: b"old".to_vec(),
7526            }),
7527        });
7528        tx.update(RefUpdate {
7529            name: "refs/heads/main".into(),
7530            expected: None,
7531            new: RefTarget::Direct(second.clone()),
7532            reflog: Some(ReflogEntry {
7533                old_oid: first,
7534                new_oid: second.clone(),
7535                committer: b"Git Rs <sley@example.invalid> 100 +0000".to_vec(),
7536                message: b"new".to_vec(),
7537            }),
7538        });
7539        tx.commit().expect("test operation should succeed");
7540
7541        let removed = store
7542            .expire_reflog_older_than("refs/heads/main", 50)
7543            .expect("test operation should succeed");
7544        assert_eq!(removed, 1);
7545        let log = store
7546            .read_reflog("refs/heads/main")
7547            .expect("test operation should succeed");
7548        assert_eq!(log.len(), 1);
7549        assert_eq!(log[0].new_oid, second);
7550        assert_eq!(log[0].message, b"new");
7551        assert!(
7552            !git_dir
7553                .join("logs")
7554                .join("refs")
7555                .join("heads")
7556                .join("main.lock")
7557                .exists()
7558        );
7559        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7560    }
7561
7562    #[test]
7563    fn file_ref_store_creates_branch() {
7564        let git_dir = temp_git_dir();
7565        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7566        let oid = ObjectId::from_hex(
7567            ObjectFormat::Sha1,
7568            "ce013625030ba8dba906f756967f9e9ca394464a",
7569        )
7570        .expect("test operation should succeed");
7571        let branch = store
7572            .create_branch(
7573                "feature",
7574                oid,
7575                b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
7576                b"branch: Created from main".to_vec(),
7577            )
7578            .expect("test operation should succeed");
7579        assert_eq!(branch.name, "refs/heads/feature");
7580        assert_eq!(
7581            store
7582                .read_ref("refs/heads/feature")
7583                .expect("test operation should succeed"),
7584            Some(RefTarget::Direct(oid))
7585        );
7586        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7587    }
7588
7589    #[test]
7590    fn file_ref_store_deletes_loose_branch() {
7591        let git_dir = temp_git_dir();
7592        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7593        let oid = ObjectId::from_hex(
7594            ObjectFormat::Sha1,
7595            "ce013625030ba8dba906f756967f9e9ca394464a",
7596        )
7597        .expect("test operation should succeed");
7598        store
7599            .create_branch(
7600                "feature",
7601                oid,
7602                b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
7603                b"branch: Created from main".to_vec(),
7604            )
7605            .expect("test operation should succeed");
7606        let deleted = store
7607            .delete_branch("feature")
7608            .expect("test operation should succeed");
7609        assert_eq!(deleted.name, "refs/heads/feature");
7610        assert_eq!(deleted.oid, oid);
7611        assert_eq!(
7612            store
7613                .read_ref("refs/heads/feature")
7614                .expect("test operation should succeed"),
7615            None
7616        );
7617        assert!(!git_dir.join("refs").join("heads").join("feature").exists());
7618        assert!(
7619            !git_dir
7620                .join("logs")
7621                .join("refs")
7622                .join("heads")
7623                .join("feature")
7624                .exists()
7625        );
7626        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7627    }
7628
7629    #[test]
7630    fn file_ref_store_deletes_generic_loose_ref() {
7631        let git_dir = temp_git_dir();
7632        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7633        let oid = ObjectId::from_hex(
7634            ObjectFormat::Sha1,
7635            "ce013625030ba8dba906f756967f9e9ca394464a",
7636        )
7637        .expect("test operation should succeed");
7638        let mut tx = store.transaction();
7639        tx.update(RefUpdate {
7640            name: "refs/heads/topic".into(),
7641            expected: None,
7642            new: RefTarget::Direct(oid),
7643            reflog: Some(ReflogEntry {
7644                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
7645                new_oid: oid,
7646                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
7647                message: b"update by test".to_vec(),
7648            }),
7649        });
7650        tx.commit().expect("test operation should succeed");
7651        let deleted = store
7652            .delete_ref("refs/heads/topic")
7653            .expect("test operation should succeed");
7654        assert_eq!(deleted.name, "refs/heads/topic");
7655        assert_eq!(deleted.oid, oid);
7656        assert_eq!(
7657            store
7658                .read_ref("refs/heads/topic")
7659                .expect("test operation should succeed"),
7660            None
7661        );
7662        assert!(!git_dir.join("refs").join("heads").join("topic").exists());
7663        assert!(
7664            !git_dir
7665                .join("logs")
7666                .join("refs")
7667                .join("heads")
7668                .join("topic")
7669                .exists()
7670        );
7671        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7672    }
7673
7674    #[test]
7675    fn file_ref_store_delete_prunes_below_namespace_only() {
7676        let git_dir = temp_git_dir();
7677        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7678        let oid = ObjectId::from_hex(
7679            ObjectFormat::Sha1,
7680            "ce013625030ba8dba906f756967f9e9ca394464a",
7681        )
7682        .expect("test operation should succeed");
7683        for name in ["refs/heads/l/m", "refs/foo/bar"] {
7684            let mut tx = store.transaction();
7685            tx.update(RefUpdate {
7686                name: name.into(),
7687                expected: None,
7688                new: RefTarget::Direct(oid),
7689                reflog: None,
7690            });
7691            tx.commit().expect("test operation should succeed");
7692            store
7693                .delete_ref(name)
7694                .expect("test operation should succeed");
7695        }
7696        assert!(!git_dir.join("refs").join("heads").join("l").exists());
7697        assert!(git_dir.join("refs").join("heads").exists());
7698        assert!(!git_dir.join("refs").join("foo").join("bar").exists());
7699        assert!(git_dir.join("refs").join("foo").exists());
7700        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7701    }
7702
7703    #[test]
7704    fn file_ref_store_delete_ref_checked_removes_reflog() {
7705        let git_dir = temp_git_dir();
7706        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7707        let oid = ObjectId::from_hex(
7708            ObjectFormat::Sha1,
7709            "ce013625030ba8dba906f756967f9e9ca394464a",
7710        )
7711        .expect("test operation should succeed");
7712        // Create the ref *with* a reflog entry so logs/refs/heads/main exists on
7713        // disk; git unlinks that file on delete rather than appending a deletion
7714        // entry, so the checked delete must remove it (mirroring delete_ref).
7715        let mut tx = store.transaction();
7716        tx.update(RefUpdate {
7717            name: "refs/heads/main".into(),
7718            expected: None,
7719            new: RefTarget::Direct(oid),
7720            reflog: Some(ReflogEntry {
7721                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
7722                new_oid: oid,
7723                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
7724                message: b"create main".to_vec(),
7725            }),
7726        });
7727        tx.commit().expect("test operation should succeed");
7728        assert!(
7729            git_dir
7730                .join("logs")
7731                .join("refs")
7732                .join("heads")
7733                .join("main")
7734                .exists(),
7735            "reflog file should exist before the checked delete"
7736        );
7737
7738        let deleted = store
7739            .delete_ref_checked(DeleteRef {
7740                name: "refs/heads/main".into(),
7741                expected_old: Some(oid),
7742                reflog: Some(DeleteRefReflog {
7743                    committer: b"Git Rs <sley@example.invalid> 123 +0000".to_vec(),
7744                    message: b"delete main".to_vec(),
7745                }),
7746            })
7747            .expect("test operation should succeed");
7748
7749        assert_eq!(deleted.name, "refs/heads/main");
7750        assert_eq!(deleted.oid, oid);
7751        assert_eq!(
7752            store
7753                .read_ref("refs/heads/main")
7754                .expect("test operation should succeed"),
7755            None
7756        );
7757        // Git unlinks the reflog on delete: the file is gone and there is no
7758        // lingering deletion entry to read back.
7759        assert!(
7760            !git_dir
7761                .join("logs")
7762                .join("refs")
7763                .join("heads")
7764                .join("main")
7765                .exists(),
7766            "reflog file should be removed by the checked delete"
7767        );
7768        assert!(
7769            store
7770                .read_reflog("refs/heads/main")
7771                .expect("test operation should succeed")
7772                .is_empty()
7773        );
7774        assert!(
7775            !git_dir
7776                .join("refs")
7777                .join("heads")
7778                .join("main.lock")
7779                .exists()
7780        );
7781        assert!(!git_dir.join("packed-refs.lock").exists());
7782        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7783    }
7784
7785    #[test]
7786    fn file_ref_store_delete_ref_checked_stale_expected_leaves_ref_untouched() {
7787        let git_dir = temp_git_dir();
7788        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7789        let actual = ObjectId::from_hex(
7790            ObjectFormat::Sha1,
7791            "ce013625030ba8dba906f756967f9e9ca394464a",
7792        )
7793        .expect("test operation should succeed");
7794        let expected = ObjectId::from_hex(
7795            ObjectFormat::Sha1,
7796            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7797        )
7798        .expect("test operation should succeed");
7799        let mut tx = store.transaction();
7800        tx.update(RefUpdate {
7801            name: "refs/heads/main".into(),
7802            expected: None,
7803            new: RefTarget::Direct(actual),
7804            reflog: None,
7805        });
7806        tx.commit().expect("test operation should succeed");
7807
7808        let err = store
7809            .delete_ref_checked(DeleteRef {
7810                name: "refs/heads/main".into(),
7811                expected_old: Some(expected),
7812                reflog: None,
7813            })
7814            .expect_err("stale expected must fail");
7815
7816        assert!(matches!(
7817            err,
7818            RefDeleteError::ExpectedMismatch {
7819                expected: Some(got_expected),
7820                actual: Some(got_actual),
7821            } if got_expected == expected && got_actual == actual
7822        ));
7823        assert_eq!(
7824            store
7825                .read_ref("refs/heads/main")
7826                .expect("test operation should succeed"),
7827            Some(RefTarget::Direct(actual))
7828        );
7829        assert!(
7830            !git_dir
7831                .join("refs")
7832                .join("heads")
7833                .join("main.lock")
7834                .exists()
7835        );
7836        assert!(!git_dir.join("packed-refs.lock").exists());
7837        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7838    }
7839
7840    #[test]
7841    fn file_ref_store_delete_ref_checked_missing_returns_not_found() {
7842        let git_dir = temp_git_dir();
7843        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7844
7845        let err = store
7846            .delete_ref_checked(DeleteRef {
7847                name: "refs/heads/missing".into(),
7848                expected_old: None,
7849                reflog: None,
7850            })
7851            .expect_err("missing ref must fail");
7852
7853        assert!(matches!(err, RefDeleteError::NotFound));
7854        assert!(
7855            !git_dir
7856                .join("refs")
7857                .join("heads")
7858                .join("missing.lock")
7859                .exists()
7860        );
7861        assert!(!git_dir.join("packed-refs.lock").exists());
7862        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7863    }
7864
7865    #[test]
7866    fn file_ref_store_delete_ref_checked_removes_packed_ref() {
7867        let git_dir = temp_git_dir();
7868        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7869        let oid = ObjectId::from_hex(
7870            ObjectFormat::Sha1,
7871            "ce013625030ba8dba906f756967f9e9ca394464a",
7872        )
7873        .expect("test operation should succeed");
7874        let other = ObjectId::from_hex(
7875            ObjectFormat::Sha1,
7876            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
7877        )
7878        .expect("test operation should succeed");
7879        store
7880            .write_packed_refs(&[
7881                PackedRef {
7882                    reference: Ref {
7883                        name: "refs/heads/main".into(),
7884                        target: RefTarget::Direct(oid),
7885                    },
7886                    peeled: None,
7887                },
7888                PackedRef {
7889                    reference: Ref {
7890                        name: "refs/heads/other".into(),
7891                        target: RefTarget::Direct(other),
7892                    },
7893                    peeled: None,
7894                },
7895            ])
7896            .expect("test operation should succeed");
7897
7898        store
7899            .delete_ref_checked(DeleteRef {
7900                name: "refs/heads/main".into(),
7901                expected_old: Some(oid),
7902                reflog: None,
7903            })
7904            .expect("test operation should succeed");
7905
7906        assert_eq!(
7907            store
7908                .read_ref("refs/heads/main")
7909                .expect("test operation should succeed"),
7910            None
7911        );
7912        assert_eq!(
7913            store
7914                .read_ref("refs/heads/other")
7915                .expect("test operation should succeed"),
7916            Some(RefTarget::Direct(other))
7917        );
7918        let packed =
7919            fs::read_to_string(git_dir.join("packed-refs")).expect("test operation should succeed");
7920        assert!(!packed.contains("refs/heads/main"));
7921        assert!(packed.contains("refs/heads/other"));
7922        assert!(
7923            !git_dir
7924                .join("refs")
7925                .join("heads")
7926                .join("main.lock")
7927                .exists()
7928        );
7929        assert!(!git_dir.join("packed-refs.lock").exists());
7930        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7931    }
7932
7933    #[test]
7934    fn file_ref_store_delete_ref_checked_lock_conflict_returns_locked() {
7935        let git_dir = temp_git_dir();
7936        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7937        let oid = ObjectId::from_hex(
7938            ObjectFormat::Sha1,
7939            "ce013625030ba8dba906f756967f9e9ca394464a",
7940        )
7941        .expect("test operation should succeed");
7942        let mut tx = store.transaction();
7943        tx.update(RefUpdate {
7944            name: "refs/heads/main".into(),
7945            expected: None,
7946            new: RefTarget::Direct(oid),
7947            reflog: None,
7948        });
7949        tx.commit().expect("test operation should succeed");
7950        fs::write(
7951            git_dir.join("refs").join("heads").join("main.lock"),
7952            b"held\n",
7953        )
7954        .expect("test operation should succeed");
7955
7956        let err = store
7957            .delete_ref_checked(DeleteRef {
7958                name: "refs/heads/main".into(),
7959                expected_old: Some(oid),
7960                reflog: None,
7961            })
7962            .expect_err("held lock must fail");
7963
7964        assert!(matches!(err, RefDeleteError::Locked));
7965        assert_eq!(
7966            store
7967                .read_ref("refs/heads/main")
7968                .expect("test operation should succeed"),
7969            Some(RefTarget::Direct(oid))
7970        );
7971        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7972    }
7973
7974    #[test]
7975    fn file_ref_store_reports_current_branch() {
7976        let git_dir = temp_git_dir();
7977        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
7978            .expect("test operation should succeed");
7979        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
7980        assert_eq!(
7981            store
7982                .current_branch_ref()
7983                .expect("test operation should succeed"),
7984            Some("refs/heads/main".into())
7985        );
7986        assert_eq!(
7987            store
7988                .current_branch()
7989                .expect("test operation should succeed"),
7990            Some("main".into())
7991        );
7992        fs::remove_dir_all(git_dir).expect("test operation should succeed");
7993    }
7994
7995    #[test]
7996    fn file_ref_store_resolves_linked_worktree_head_through_common_refs() {
7997        let common = temp_git_dir();
7998        let admin = common.join("worktrees").join("linked");
7999        fs::create_dir_all(&admin).expect("test operation should succeed");
8000        fs::write(admin.join("commondir"), "../..\n").expect("test operation should succeed");
8001        fs::write(admin.join("HEAD"), b"ref: refs/heads/topic\n")
8002            .expect("test operation should succeed");
8003        let oid = ObjectId::from_hex(
8004            ObjectFormat::Sha256,
8005            "08ffba112b648c22b5425f01bec2c37ffc524c4d48ef04337779df3973733050",
8006        )
8007        .expect("test operation should succeed");
8008        fs::create_dir_all(common.join("refs").join("heads"))
8009            .expect("test operation should succeed");
8010        fs::write(
8011            common.join("refs").join("heads").join("topic"),
8012            format!("{oid}\n"),
8013        )
8014        .expect("test operation should succeed");
8015
8016        let store = FileRefStore::new(&admin, ObjectFormat::Sha256);
8017        assert_eq!(
8018            store
8019                .read_ref("HEAD")
8020                .expect("test operation should succeed"),
8021            Some(RefTarget::Symbolic("refs/heads/topic".into()))
8022        );
8023        assert_eq!(
8024            store
8025                .read_ref("refs/heads/topic")
8026                .expect("test operation should succeed"),
8027            Some(RefTarget::Direct(oid))
8028        );
8029
8030        fs::remove_dir_all(common).expect("test operation should succeed");
8031    }
8032
8033    #[test]
8034    fn file_ref_store_creates_tag() {
8035        let git_dir = temp_git_dir();
8036        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8037        let oid = ObjectId::from_hex(
8038            ObjectFormat::Sha1,
8039            "ce013625030ba8dba906f756967f9e9ca394464a",
8040        )
8041        .expect("test operation should succeed");
8042        let tag = store
8043            .create_tag("v1.0", oid)
8044            .expect("test operation should succeed");
8045        assert_eq!(tag.name, "refs/tags/v1.0");
8046        assert_eq!(
8047            store
8048                .read_ref("refs/tags/v1.0")
8049                .expect("test operation should succeed"),
8050            Some(RefTarget::Direct(oid))
8051        );
8052        assert!(
8053            store
8054                .read_reflog("refs/tags/v1.0")
8055                .expect("test operation should succeed")
8056                .is_empty()
8057        );
8058        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8059    }
8060
8061    #[test]
8062    fn file_ref_store_deletes_loose_tag() {
8063        let git_dir = temp_git_dir();
8064        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8065        let oid = ObjectId::from_hex(
8066            ObjectFormat::Sha1,
8067            "ce013625030ba8dba906f756967f9e9ca394464a",
8068        )
8069        .expect("test operation should succeed");
8070        store
8071            .create_tag("v1.0", oid)
8072            .expect("test operation should succeed");
8073        let deleted = store
8074            .delete_tag("v1.0")
8075            .expect("test operation should succeed");
8076        assert_eq!(deleted.name, "refs/tags/v1.0");
8077        assert_eq!(deleted.oid, oid);
8078        assert_eq!(
8079            store
8080                .read_ref("refs/tags/v1.0")
8081                .expect("test operation should succeed"),
8082            None
8083        );
8084        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
8085        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8086    }
8087
8088    #[test]
8089    fn file_ref_store_reads_packed_ref() {
8090        let git_dir = temp_git_dir();
8091        fs::write(
8092            git_dir.join("packed-refs"),
8093            b"ce013625030ba8dba906f756967f9e9ca394464a refs/heads/main\n",
8094        )
8095        .expect("test operation should succeed");
8096        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8097        assert!(matches!(
8098            store
8099                .read_ref("refs/heads/main")
8100                .expect("test operation should succeed"),
8101            Some(RefTarget::Direct(_))
8102        ));
8103        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8104    }
8105
8106    #[test]
8107    fn file_ref_store_lists_loose_refs_over_packed_refs() {
8108        let git_dir = temp_git_dir();
8109        fs::write(
8110            git_dir.join("packed-refs"),
8111            b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 refs/heads/main\n",
8112        )
8113        .expect("test operation should succeed");
8114        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8115        let oid = ObjectId::from_hex(
8116            ObjectFormat::Sha1,
8117            "ce013625030ba8dba906f756967f9e9ca394464a",
8118        )
8119        .expect("test operation should succeed");
8120        let mut tx = store.transaction();
8121        tx.update(RefUpdate {
8122            name: "refs/heads/main".into(),
8123            expected: None,
8124            new: RefTarget::Direct(oid),
8125            reflog: None,
8126        });
8127        tx.commit().expect("test operation should succeed");
8128        let refs = store.list_refs().expect("test operation should succeed");
8129        assert_eq!(refs.len(), 1);
8130        assert_eq!(refs[0].target, RefTarget::Direct(oid));
8131        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8132    }
8133
8134    #[test]
8135    fn file_ref_store_lists_refs_with_prefix_and_preserves_loose_shadowing() {
8136        let git_dir = temp_git_dir();
8137        fs::write(
8138            git_dir.join("packed-refs"),
8139            b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 refs/heads/main\n\
8140              18f002b4484b838b205a48b1e9e6763ba5e3a607 refs/heads/topic\n\
8141              ce013625030ba8dba906f756967f9e9ca394464a refs/tags/v1.0\n",
8142        )
8143        .expect("test operation should succeed");
8144        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8145        let loose_main = ObjectId::from_hex(
8146            ObjectFormat::Sha1,
8147            "ce013625030ba8dba906f756967f9e9ca394464a",
8148        )
8149        .expect("test operation should succeed");
8150        let packed_topic = ObjectId::from_hex(
8151            ObjectFormat::Sha1,
8152            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
8153        )
8154        .expect("test operation should succeed");
8155        let mut tx = store.transaction();
8156        tx.update(RefUpdate {
8157            name: "refs/heads/main".into(),
8158            expected: None,
8159            new: RefTarget::Direct(loose_main),
8160            reflog: None,
8161        });
8162        tx.commit().expect("test operation should succeed");
8163
8164        assert_eq!(
8165            store
8166                .list_refs_with_prefix("refs/heads/")
8167                .expect("test operation should succeed"),
8168            vec![
8169                Ref {
8170                    name: "refs/heads/main".into(),
8171                    target: RefTarget::Direct(loose_main),
8172                },
8173                Ref {
8174                    name: "refs/heads/topic".into(),
8175                    target: RefTarget::Direct(packed_topic),
8176                },
8177            ]
8178        );
8179        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8180    }
8181
8182    #[test]
8183    fn file_ref_store_writes_packed_refs() {
8184        let git_dir = temp_git_dir();
8185        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8186        let oid = ObjectId::from_hex(
8187            ObjectFormat::Sha1,
8188            "ce013625030ba8dba906f756967f9e9ca394464a",
8189        )
8190        .expect("test operation should succeed");
8191        store
8192            .write_packed_refs(&[PackedRef {
8193                reference: Ref {
8194                    name: "refs/heads/main".into(),
8195                    target: RefTarget::Direct(oid),
8196                },
8197                peeled: None,
8198            }])
8199            .expect("test operation should succeed");
8200        assert_eq!(
8201            store
8202                .read_ref("refs/heads/main")
8203                .expect("test operation should succeed"),
8204            Some(RefTarget::Direct(oid))
8205        );
8206        let refs = store.list_refs().expect("test operation should succeed");
8207        assert_eq!(refs.len(), 1);
8208        assert_eq!(refs[0].target, RefTarget::Direct(oid));
8209        assert!(git_dir.join("packed-refs").exists());
8210        assert!(!git_dir.join("packed-refs.lock").exists());
8211        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8212    }
8213
8214    #[test]
8215    fn file_ref_store_checks_ref_prefix_in_packed_refs() {
8216        let git_dir = temp_git_dir();
8217        fs::write(
8218            git_dir.join("packed-refs"),
8219            b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 refs/heads/main\n\
8220              ce013625030ba8dba906f756967f9e9ca394464a refs/replace/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n",
8221        )
8222        .expect("test operation should succeed");
8223        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8224        assert!(
8225            store
8226                .has_refs_with_prefix("refs/replace/")
8227                .expect("test operation should succeed")
8228        );
8229        assert!(
8230            !store
8231                .has_refs_with_prefix("refs/notes/")
8232                .expect("test operation should succeed")
8233        );
8234        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8235    }
8236
8237    #[test]
8238    fn file_ref_store_checks_ref_prefix_in_loose_refs() {
8239        let git_dir = temp_git_dir();
8240        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8241        let oid = ObjectId::from_hex(
8242            ObjectFormat::Sha1,
8243            "ce013625030ba8dba906f756967f9e9ca394464a",
8244        )
8245        .expect("test operation should succeed");
8246        let mut tx = store.transaction();
8247        tx.update(RefUpdate {
8248            name: "refs/replace/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391".into(),
8249            expected: None,
8250            new: RefTarget::Direct(oid),
8251            reflog: None,
8252        });
8253        tx.commit().expect("test operation should succeed");
8254        assert!(
8255            store
8256                .has_refs_with_prefix("refs/replace/")
8257                .expect("test operation should succeed")
8258        );
8259        assert!(
8260            !store
8261                .has_refs_with_prefix("refs/notes/")
8262                .expect("test operation should succeed")
8263        );
8264        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8265    }
8266
8267    #[test]
8268    fn file_ref_store_reads_reftable_stack_and_ignores_dummy_head() {
8269        let git_dir = temp_git_dir();
8270        write_reftable_config(&git_dir);
8271        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/.invalid\n")
8272            .expect("test operation should succeed");
8273        let head_oid = ObjectId::from_hex(
8274            ObjectFormat::Sha1,
8275            "ce013625030ba8dba906f756967f9e9ca394464a",
8276        )
8277        .expect("test operation should succeed");
8278        let tag_oid = ObjectId::from_hex(
8279            ObjectFormat::Sha1,
8280            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
8281        )
8282        .expect("test operation should succeed");
8283        let peeled_oid = ObjectId::from_hex(
8284            ObjectFormat::Sha1,
8285            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
8286        )
8287        .expect("test operation should succeed");
8288        write_reftable_stack(
8289            &git_dir,
8290            &[(
8291                "0x000000000001-0x000000000001-00000000.ref",
8292                vec![
8293                    sley_formats::ReftableRefRecord {
8294                        name: "HEAD".into(),
8295                        update_index: 1,
8296                        value: ReftableRefValue::Symbolic("refs/heads/main".into()),
8297                    },
8298                    sley_formats::ReftableRefRecord {
8299                        name: "refs/heads/main".into(),
8300                        update_index: 1,
8301                        value: ReftableRefValue::Direct(head_oid),
8302                    },
8303                    sley_formats::ReftableRefRecord {
8304                        name: "refs/tags/v1.0".into(),
8305                        update_index: 1,
8306                        value: ReftableRefValue::Peeled {
8307                            target: tag_oid,
8308                            peeled: peeled_oid,
8309                        },
8310                    },
8311                ],
8312            )],
8313        );
8314
8315        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8316        assert_eq!(
8317            store
8318                .read_ref("HEAD")
8319                .expect("test operation should succeed"),
8320            Some(RefTarget::Symbolic("refs/heads/main".into()))
8321        );
8322        assert_eq!(
8323            store
8324                .read_ref("refs/heads/main")
8325                .expect("test operation should succeed"),
8326            Some(RefTarget::Direct(head_oid))
8327        );
8328        assert_eq!(
8329            store
8330                .read_ref("refs/tags/v1.0")
8331                .expect("test operation should succeed"),
8332            Some(RefTarget::Direct(tag_oid))
8333        );
8334        let refs = store.list_refs().expect("test operation should succeed");
8335        assert_eq!(
8336            refs,
8337            vec![
8338                Ref {
8339                    name: "refs/heads/main".into(),
8340                    target: RefTarget::Direct(head_oid),
8341                },
8342                Ref {
8343                    name: "refs/tags/v1.0".into(),
8344                    target: RefTarget::Direct(tag_oid),
8345                },
8346            ]
8347        );
8348        assert_eq!(
8349            store
8350                .list_refs_with_prefix("refs/tags/")
8351                .expect("test operation should succeed"),
8352            vec![Ref {
8353                name: "refs/tags/v1.0".into(),
8354                target: RefTarget::Direct(tag_oid),
8355            }]
8356        );
8357
8358        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8359    }
8360
8361    #[test]
8362    fn root_ref_listing_is_backend_independent() {
8363        let oid = ObjectId::from_hex(
8364            ObjectFormat::Sha1,
8365            "ce013625030ba8dba906f756967f9e9ca394464a",
8366        )
8367        .expect("test operation should succeed");
8368        let expected = vec![
8369            Ref {
8370                name: "AUTO_MERGE".into(),
8371                target: RefTarget::Direct(oid),
8372            },
8373            Ref {
8374                name: "FOO_HEAD".into(),
8375                target: RefTarget::Direct(oid),
8376            },
8377            Ref {
8378                name: "HEAD".into(),
8379                target: RefTarget::Symbolic("refs/heads/main".into()),
8380            },
8381        ];
8382
8383        let files_dir = temp_git_dir();
8384        fs::write(files_dir.join("AUTO_MERGE"), format!("{oid}\n")).expect("write files root ref");
8385        fs::write(files_dir.join("FOO_HEAD"), format!("{oid}\n")).expect("write files root ref");
8386        fs::write(files_dir.join("HEAD"), b"ref: refs/heads/main\n").expect("write files root ref");
8387        fs::write(files_dir.join("FETCH_HEAD"), format!("{oid}\n"))
8388            .expect("write working-state file");
8389        fs::write(files_dir.join("MERGE_HEAD"), format!("{oid}\n"))
8390            .expect("write working-state file");
8391        let files = FileRefStore::new(&files_dir, ObjectFormat::Sha1)
8392            .list_root_refs()
8393            .expect("list files root refs");
8394
8395        let reftable_dir = temp_git_dir();
8396        write_reftable_config(&reftable_dir);
8397        write_reftable_stack(
8398            &reftable_dir,
8399            &[(
8400                "0x000000000001-0x000000000001-00000000.ref",
8401                vec![
8402                    ReftableRefRecord {
8403                        name: "AUTO_MERGE".into(),
8404                        update_index: 1,
8405                        value: ReftableRefValue::Direct(oid),
8406                    },
8407                    ReftableRefRecord {
8408                        name: "FETCH_HEAD".into(),
8409                        update_index: 1,
8410                        value: ReftableRefValue::Direct(oid),
8411                    },
8412                    ReftableRefRecord {
8413                        name: "FOO_HEAD".into(),
8414                        update_index: 1,
8415                        value: ReftableRefValue::Direct(oid),
8416                    },
8417                    ReftableRefRecord {
8418                        name: "HEAD".into(),
8419                        update_index: 1,
8420                        value: ReftableRefValue::Symbolic("refs/heads/main".into()),
8421                    },
8422                    ReftableRefRecord {
8423                        name: "MERGE_HEAD".into(),
8424                        update_index: 1,
8425                        value: ReftableRefValue::Direct(oid),
8426                    },
8427                    ReftableRefRecord {
8428                        name: "refs/heads/main".into(),
8429                        update_index: 1,
8430                        value: ReftableRefValue::Direct(oid),
8431                    },
8432                ],
8433            )],
8434        );
8435        let reftable = FileRefStore::new(&reftable_dir, ObjectFormat::Sha1)
8436            .list_root_refs()
8437            .expect("list reftable root refs");
8438
8439        assert_eq!(files, expected);
8440        assert_eq!(reftable, expected);
8441        fs::remove_dir_all(files_dir).expect("remove test files repo");
8442        fs::remove_dir_all(reftable_dir).expect("remove test reftable repo");
8443    }
8444
8445    #[test]
8446    fn file_ref_store_reads_loose_fetch_head_in_reftable_repo() {
8447        let git_dir = temp_git_dir();
8448        write_reftable_config(&git_dir);
8449        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/.invalid\n")
8450            .expect("test operation should succeed");
8451        fs::create_dir_all(git_dir.join("reftable")).expect("test operation should succeed");
8452        fs::write(git_dir.join("reftable").join("tables.list"), b"")
8453            .expect("test operation should succeed");
8454        let oid = ObjectId::from_hex(
8455            ObjectFormat::Sha1,
8456            "ce013625030ba8dba906f756967f9e9ca394464a",
8457        )
8458        .expect("test operation should succeed");
8459        fs::write(
8460            git_dir.join("FETCH_HEAD"),
8461            b"ce013625030ba8dba906f756967f9e9ca394464a\t\tbranch 'main' of ../sub\n",
8462        )
8463        .expect("test operation should succeed");
8464
8465        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8466        assert_eq!(
8467            store
8468                .read_ref("FETCH_HEAD")
8469                .expect("test operation should succeed"),
8470            Some(RefTarget::Direct(oid))
8471        );
8472        assert!(
8473            store
8474                .raw_ref_exists("FETCH_HEAD")
8475                .expect("test operation should succeed")
8476        );
8477
8478        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8479    }
8480
8481    #[test]
8482    fn file_ref_store_empty_reftable_reflog_rewrite_keeps_marker() {
8483        let git_dir = temp_git_dir();
8484        write_reftable_config(&git_dir);
8485        fs::create_dir_all(git_dir.join("reftable")).expect("test operation should succeed");
8486        fs::write(git_dir.join("reftable").join("tables.list"), b"")
8487            .expect("test operation should succeed");
8488        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8489        let null = ObjectId::null(ObjectFormat::Sha1);
8490        let oid = ObjectId::from_hex(
8491            ObjectFormat::Sha1,
8492            "ce013625030ba8dba906f756967f9e9ca394464a",
8493        )
8494        .expect("test operation should succeed");
8495
8496        store
8497            .write_reflog(
8498                "refs/heads/main",
8499                &[ReflogEntry {
8500                    old_oid: null,
8501                    new_oid: oid,
8502                    committer: b"A U Thor <author@example.com> 1 +0000".to_vec(),
8503                    message: b"commit: initial".to_vec(),
8504                }],
8505            )
8506            .expect("test operation should succeed");
8507
8508        store
8509            .write_reflog("refs/heads/main", &[])
8510            .expect("test operation should succeed");
8511
8512        assert!(
8513            store
8514                .read_reflog("refs/heads/main")
8515                .expect("test operation should succeed")
8516                .is_empty()
8517        );
8518        assert!(
8519            store
8520                .reflog_exists("refs/heads/main")
8521                .expect("test operation should succeed")
8522        );
8523        let tables = store.reftables().expect("test operation should succeed");
8524        assert!(
8525            tables
8526                .windows(2)
8527                .all(|pair| pair[0].header.max_update_index < pair[1].header.min_update_index)
8528        );
8529        let marker = tables
8530            .iter()
8531            .flat_map(|table| table.logs.iter())
8532            .find(|record| {
8533                record.refname == "refs/heads/main"
8534                    && matches!(
8535                        &record.value,
8536                        ReftableLogValue::Update(update)
8537                            if update.old_oid == null && update.new_oid == null
8538                    )
8539            })
8540            .expect("empty reflog marker should exist");
8541        let ReftableLogValue::Update(update) = &marker.value else {
8542            panic!("empty reflog marker should be an update");
8543        };
8544        assert_eq!(update.old_oid, null);
8545        assert_eq!(update.new_oid, null);
8546
8547        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8548    }
8549
8550    #[test]
8551    fn file_ref_store_limits_reftable_reflog_messages_to_half_a_block() {
8552        let git_dir = temp_git_dir();
8553        write_reftable_config(&git_dir);
8554        fs::create_dir_all(git_dir.join("reftable")).expect("test operation should succeed");
8555        fs::write(git_dir.join("reftable").join("tables.list"), b"")
8556            .expect("test operation should succeed");
8557        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1).with_reftable_write_options(
8558            ReftableWriteOptions {
8559                block_size: 0,
8560                ..ReftableWriteOptions::default()
8561            },
8562        );
8563        let oid = ObjectId::from_hex(
8564            ObjectFormat::Sha1,
8565            "ce013625030ba8dba906f756967f9e9ca394464a",
8566        )
8567        .expect("test operation should succeed");
8568        let mut tx = store.transaction();
8569        tx.update(RefUpdate {
8570            name: "refs/heads/main".into(),
8571            expected: None,
8572            new: RefTarget::Direct(oid),
8573            reflog: Some(ReflogEntry {
8574                old_oid: ObjectId::null(ObjectFormat::Sha1),
8575                new_oid: oid,
8576                committer: b"A U Thor <author@example.com> 1 +0000".to_vec(),
8577                message: vec![b'x'; 50_000],
8578            }),
8579        });
8580        tx.commit()
8581            .expect("long reflog message should be truncated");
8582
8583        let message = store
8584            .reftables()
8585            .expect("test operation should succeed")
8586            .into_iter()
8587            .flat_map(|table| table.logs)
8588            .find_map(|record| match record.value {
8589                ReftableLogValue::Update(update) => Some(update.message),
8590                ReftableLogValue::Deletion => None,
8591            })
8592            .expect("reflog update should exist");
8593        assert_eq!(message.len(), 2048);
8594
8595        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8596    }
8597
8598    #[test]
8599    fn file_ref_store_lists_only_visible_reftable_reflog_names() {
8600        let git_dir = temp_git_dir();
8601        write_reftable_config(&git_dir);
8602        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8603        let oid = ObjectId::from_hex(
8604            ObjectFormat::Sha1,
8605            "ce013625030ba8dba906f756967f9e9ca394464a",
8606        )
8607        .expect("test operation should succeed");
8608
8609        store
8610            .write_reflog("refs/heads/empty", &[])
8611            .expect("test operation should succeed");
8612        store
8613            .append_reflog("refs/heads/main", &reflog_entry(&oid, 1, "create main"))
8614            .expect("test operation should succeed");
8615
8616        assert_eq!(
8617            store
8618                .list_reflog_names()
8619                .expect("test operation should succeed"),
8620            vec!["refs/heads/main".to_string()]
8621        );
8622
8623        store
8624            .write_reflog("refs/heads/main", &[])
8625            .expect("test operation should succeed");
8626        assert!(
8627            store
8628                .list_reflog_names()
8629                .expect("test operation should succeed")
8630                .is_empty()
8631        );
8632
8633        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8634    }
8635
8636    #[test]
8637    fn file_ref_store_applies_reftable_stack_overrides_and_deletions() {
8638        let git_dir = temp_git_dir();
8639        write_reftable_config(&git_dir);
8640        let first = ObjectId::from_hex(
8641            ObjectFormat::Sha1,
8642            "ce013625030ba8dba906f756967f9e9ca394464a",
8643        )
8644        .expect("test operation should succeed");
8645        let second = ObjectId::from_hex(
8646            ObjectFormat::Sha1,
8647            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
8648        )
8649        .expect("test operation should succeed");
8650        write_reftable_stack(
8651            &git_dir,
8652            &[
8653                (
8654                    "0x000000000001-0x000000000001-00000000.ref",
8655                    vec![
8656                        sley_formats::ReftableRefRecord {
8657                            name: "refs/heads/main".into(),
8658                            update_index: 1,
8659                            value: ReftableRefValue::Direct(first),
8660                        },
8661                        sley_formats::ReftableRefRecord {
8662                            name: "refs/heads/topic".into(),
8663                            update_index: 1,
8664                            value: ReftableRefValue::Direct(second.clone()),
8665                        },
8666                    ],
8667                ),
8668                (
8669                    "000000000002-000000000002-tip.ref",
8670                    vec![
8671                        sley_formats::ReftableRefRecord {
8672                            name: "refs/heads/main".into(),
8673                            update_index: 2,
8674                            value: ReftableRefValue::Direct(second.clone()),
8675                        },
8676                        sley_formats::ReftableRefRecord {
8677                            name: "refs/heads/topic".into(),
8678                            update_index: 2,
8679                            value: ReftableRefValue::Deletion,
8680                        },
8681                    ],
8682                ),
8683            ],
8684        );
8685
8686        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8687        assert_eq!(
8688            store
8689                .read_ref("refs/heads/main")
8690                .expect("test operation should succeed"),
8691            Some(RefTarget::Direct(second.clone()))
8692        );
8693        assert_eq!(
8694            store
8695                .read_ref("refs/heads/topic")
8696                .expect("test operation should succeed"),
8697            None
8698        );
8699        assert_eq!(
8700            store.list_refs().expect("test operation should succeed"),
8701            vec![Ref {
8702                name: "refs/heads/main".into(),
8703                target: RefTarget::Direct(second),
8704            }]
8705        );
8706
8707        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8708    }
8709
8710    #[test]
8711    fn file_ref_store_writes_reftable_transaction_table() {
8712        let git_dir = temp_git_dir();
8713        write_reftable_config(&git_dir);
8714        let first = ObjectId::from_hex(
8715            ObjectFormat::Sha1,
8716            "ce013625030ba8dba906f756967f9e9ca394464a",
8717        )
8718        .expect("test operation should succeed");
8719        let second = ObjectId::from_hex(
8720            ObjectFormat::Sha1,
8721            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
8722        )
8723        .expect("test operation should succeed");
8724        write_reftable_stack(
8725            &git_dir,
8726            &[(
8727                "0x000000000001-0x000000000001-00000000.ref",
8728                vec![sley_formats::ReftableRefRecord {
8729                    name: "refs/heads/main".into(),
8730                    update_index: 1,
8731                    value: ReftableRefValue::Direct(first),
8732                }],
8733            )],
8734        );
8735
8736        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8737        let mut tx = store.transaction();
8738        tx.update(RefUpdate {
8739            name: "HEAD".into(),
8740            expected: None,
8741            new: RefTarget::Symbolic("refs/heads/main".into()),
8742            reflog: None,
8743        });
8744        tx.update(RefUpdate {
8745            name: "refs/heads/main".into(),
8746            expected: None,
8747            new: RefTarget::Direct(second.clone()),
8748            reflog: None,
8749        });
8750        tx.commit().expect("test operation should succeed");
8751
8752        assert_eq!(
8753            store
8754                .read_ref("HEAD")
8755                .expect("test operation should succeed"),
8756            Some(RefTarget::Symbolic("refs/heads/main".into()))
8757        );
8758        assert_eq!(
8759            store
8760                .read_ref("refs/heads/main")
8761                .expect("test operation should succeed"),
8762            Some(RefTarget::Direct(second.clone()))
8763        );
8764        assert_eq!(
8765            store
8766                .list_refs()
8767                .expect("test operation should succeed")
8768                .len(),
8769            1
8770        );
8771        assert!(!git_dir.join("HEAD").exists());
8772        let tables = fs::read_to_string(git_dir.join("reftable").join("tables.list"))
8773            .expect("test operation should succeed");
8774        assert_eq!(tables.lines().count(), 2);
8775        let last = tables
8776            .lines()
8777            .last()
8778            .expect("test operation should succeed");
8779        // The rust-written table name follows git's `0x%012x-0x%012x-%08x.ref`
8780        // shape (reftable/stack.c::format_name) so `git fsck` accepts it; the
8781        // earlier `-sley-<nanos>` token tripped `badReftableTableName`.
8782        assert!(
8783            last.starts_with("0x") && last.ends_with(".ref"),
8784            "expected git-format reftable name in tables.list, got {tables}"
8785        );
8786        assert!(
8787            reftable_table_name_is_valid(last),
8788            "rust-written reftable name must parse as git's hex format, got {last}"
8789        );
8790
8791        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8792    }
8793
8794    #[test]
8795    fn reftable_auto_compaction_uses_git_geometric_segments() {
8796        assert_eq!(
8797            reftable_compaction_segment(&[512, 64, 17, 16, 9, 9, 9, 16, 2, 16], 2),
8798            Some((1, 10))
8799        );
8800        assert_eq!(reftable_compaction_segment(&[64, 32, 16, 8, 4, 2], 2), None);
8801    }
8802
8803    #[test]
8804    fn file_ref_store_geometric_compaction_keeps_a_stack() {
8805        let git_dir = temp_git_dir();
8806        write_reftable_config(&git_dir);
8807        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8808        let oid = ObjectId::from_hex(
8809            ObjectFormat::Sha1,
8810            "ce013625030ba8dba906f756967f9e9ca394464a",
8811        )
8812        .expect("test operation should succeed");
8813
8814        for index in 1..=20 {
8815            let mut tx = store.transaction();
8816            tx.update(RefUpdate {
8817                name: format!("refs/heads/branch-{index}"),
8818                expected: None,
8819                new: RefTarget::Direct(oid),
8820                reflog: None,
8821            });
8822            tx.commit().expect("test operation should succeed");
8823        }
8824
8825        let table_count = store
8826            .reftable_table_count()
8827            .expect("test operation should succeed");
8828        assert!(table_count > 1, "geometric stack collapsed to one table");
8829        assert!(table_count < 20, "geometric stack was never compacted");
8830
8831        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8832    }
8833
8834    #[test]
8835    fn combined_reftable_transactions_auto_compact_geometrically() {
8836        let git_dir = temp_git_dir();
8837        write_reftable_config(&git_dir);
8838        let store =
8839            FileRefStore::new(&git_dir, ObjectFormat::Sha1).with_reftable_combined_logs(true);
8840        let oid = ObjectId::from_hex(
8841            ObjectFormat::Sha1,
8842            "ce013625030ba8dba906f756967f9e9ca394464a",
8843        )
8844        .expect("test operation should succeed");
8845
8846        for index in 1..=20 {
8847            let mut tx = store.transaction();
8848            tx.update(RefUpdate {
8849                name: format!("refs/heads/branch-{index}"),
8850                expected: None,
8851                new: RefTarget::Direct(oid),
8852                reflog: Some(ReflogEntry {
8853                    old_oid: ObjectId::null(ObjectFormat::Sha1),
8854                    new_oid: oid,
8855                    committer: format!("A <a@example.invalid> {index} +0000").into_bytes(),
8856                    message: Vec::new(),
8857                }),
8858            });
8859            tx.commit().expect("test operation should succeed");
8860        }
8861
8862        let table_count = store
8863            .reftable_table_count()
8864            .expect("test operation should succeed");
8865        assert!(table_count > 0, "combined transaction stack disappeared");
8866        assert!(table_count < 20, "combined transactions never compacted");
8867
8868        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8869    }
8870
8871    #[test]
8872    fn file_ref_store_deletes_reftable_refs_with_tombstones() {
8873        let git_dir = temp_git_dir();
8874        write_reftable_config(&git_dir);
8875        let oid = ObjectId::from_hex(
8876            ObjectFormat::Sha1,
8877            "ce013625030ba8dba906f756967f9e9ca394464a",
8878        )
8879        .expect("test operation should succeed");
8880        write_reftable_stack(
8881            &git_dir,
8882            &[(
8883                "0x000000000001-0x000000000001-00000000.ref",
8884                vec![
8885                    sley_formats::ReftableRefRecord {
8886                        name: "refs/heads/main".into(),
8887                        update_index: 1,
8888                        value: ReftableRefValue::Direct(oid),
8889                    },
8890                    sley_formats::ReftableRefRecord {
8891                        name: "refs/alias/main".into(),
8892                        update_index: 1,
8893                        value: ReftableRefValue::Symbolic("refs/heads/main".into()),
8894                    },
8895                ],
8896            )],
8897        );
8898
8899        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8900        assert!(
8901            store
8902                .delete_symbolic_ref("refs/alias/main")
8903                .expect("test operation should succeed")
8904        );
8905        assert_eq!(
8906            store
8907                .read_ref("refs/alias/main")
8908                .expect("test operation should succeed"),
8909            None
8910        );
8911        let deleted = store
8912            .delete_ref("refs/heads/main")
8913            .expect("test operation should succeed");
8914        assert_eq!(deleted.oid, oid);
8915        assert_eq!(
8916            store
8917                .read_ref("refs/heads/main")
8918                .expect("test operation should succeed"),
8919            None
8920        );
8921        assert!(
8922            store
8923                .list_refs()
8924                .expect("test operation should succeed")
8925                .is_empty()
8926        );
8927        let tables = fs::read_to_string(git_dir.join("reftable").join("tables.list"))
8928            .expect("test operation should succeed");
8929        assert_eq!(tables.lines().count(), 3);
8930
8931        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8932    }
8933
8934    #[test]
8935    fn file_ref_store_deletes_packed_branch() {
8936        let git_dir = temp_git_dir();
8937        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8938        let branch_oid = ObjectId::from_hex(
8939            ObjectFormat::Sha1,
8940            "ce013625030ba8dba906f756967f9e9ca394464a",
8941        )
8942        .expect("test operation should succeed");
8943        let tag_oid = ObjectId::from_hex(
8944            ObjectFormat::Sha1,
8945            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
8946        )
8947        .expect("test operation should succeed");
8948        store
8949            .write_packed_refs(&[
8950                PackedRef {
8951                    reference: Ref {
8952                        name: "refs/heads/feature".into(),
8953                        target: RefTarget::Direct(branch_oid),
8954                    },
8955                    peeled: None,
8956                },
8957                PackedRef {
8958                    reference: Ref {
8959                        name: "refs/tags/v1.0".into(),
8960                        target: RefTarget::Direct(tag_oid),
8961                    },
8962                    peeled: None,
8963                },
8964            ])
8965            .expect("test operation should succeed");
8966        let deleted = store
8967            .delete_branch("feature")
8968            .expect("test operation should succeed");
8969        assert_eq!(deleted.name, "refs/heads/feature");
8970        assert_eq!(deleted.oid, branch_oid);
8971        assert_eq!(
8972            store
8973                .read_ref("refs/heads/feature")
8974                .expect("test operation should succeed"),
8975            None
8976        );
8977        assert_eq!(
8978            store
8979                .read_ref("refs/tags/v1.0")
8980                .expect("test operation should succeed"),
8981            Some(RefTarget::Direct(tag_oid))
8982        );
8983        assert!(!git_dir.join("packed-refs.lock").exists());
8984        fs::remove_dir_all(git_dir).expect("test operation should succeed");
8985    }
8986
8987    #[test]
8988    fn file_ref_store_deletes_packed_tag() {
8989        let git_dir = temp_git_dir();
8990        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
8991        let oid = ObjectId::from_hex(
8992            ObjectFormat::Sha1,
8993            "ce013625030ba8dba906f756967f9e9ca394464a",
8994        )
8995        .expect("test operation should succeed");
8996        store
8997            .write_packed_refs(&[PackedRef {
8998                reference: Ref {
8999                    name: "refs/tags/v1.0".into(),
9000                    target: RefTarget::Direct(oid),
9001                },
9002                peeled: None,
9003            }])
9004            .expect("test operation should succeed");
9005        let deleted = store
9006            .delete_tag("v1.0")
9007            .expect("test operation should succeed");
9008        assert_eq!(deleted.name, "refs/tags/v1.0");
9009        assert_eq!(deleted.oid, oid);
9010        assert_eq!(
9011            store
9012                .read_ref("refs/tags/v1.0")
9013                .expect("test operation should succeed"),
9014            None
9015        );
9016        assert!(!git_dir.join("packed-refs.lock").exists());
9017        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9018    }
9019
9020    #[test]
9021    fn file_ref_store_packs_loose_refs_and_prunes() {
9022        let git_dir = temp_git_dir();
9023        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9024        let main_oid = ObjectId::from_hex(
9025            ObjectFormat::Sha1,
9026            "ce013625030ba8dba906f756967f9e9ca394464a",
9027        )
9028        .expect("test operation should succeed");
9029        let tag_oid = ObjectId::from_hex(
9030            ObjectFormat::Sha1,
9031            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
9032        )
9033        .expect("test operation should succeed");
9034        let mut tx = store.transaction();
9035        tx.update(RefUpdate {
9036            name: "refs/heads/main".into(),
9037            expected: None,
9038            new: RefTarget::Direct(main_oid),
9039            reflog: None,
9040        });
9041        tx.update(RefUpdate {
9042            name: "refs/tags/v1.0".into(),
9043            expected: None,
9044            new: RefTarget::Direct(tag_oid),
9045            reflog: None,
9046        });
9047        tx.commit().expect("test operation should succeed");
9048
9049        let packed = store
9050            .pack_refs(true)
9051            .expect("test operation should succeed");
9052        assert_eq!(packed.len(), 2);
9053        assert_eq!(
9054            store
9055                .read_ref("refs/heads/main")
9056                .expect("test operation should succeed"),
9057            Some(RefTarget::Direct(main_oid))
9058        );
9059        assert_eq!(
9060            store
9061                .read_ref("refs/tags/v1.0")
9062                .expect("test operation should succeed"),
9063            Some(RefTarget::Direct(tag_oid))
9064        );
9065        assert!(!git_dir.join("refs").join("heads").join("main").exists());
9066        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
9067        assert!(git_dir.join("packed-refs").exists());
9068        assert!(!git_dir.join("packed-refs.lock").exists());
9069        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9070    }
9071
9072    #[test]
9073    fn post_pack_prune_preserves_a_racing_loose_update() {
9074        let git_dir = temp_git_dir();
9075        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9076        let packed_oid = ObjectId::from_hex(
9077            ObjectFormat::Sha1,
9078            "ce013625030ba8dba906f756967f9e9ca394464a",
9079        )
9080        .expect("test operation should succeed");
9081        let newer_oid = ObjectId::from_hex(
9082            ObjectFormat::Sha1,
9083            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
9084        )
9085        .expect("test operation should succeed");
9086        let mut tx = store.transaction();
9087        tx.update(RefUpdate {
9088            name: "refs/heads/main".into(),
9089            expected: None,
9090            new: RefTarget::Direct(newer_oid),
9091            reflog: None,
9092        });
9093        tx.commit().expect("seed racing loose update");
9094
9095        assert!(
9096            !store
9097                .prune_loose_ref_after_pack("refs/heads/main", &packed_oid)
9098                .expect("mismatched loose value is preserved")
9099        );
9100        assert_eq!(
9101            store
9102                .read_ref("refs/heads/main")
9103                .expect("read preserved loose ref"),
9104            Some(RefTarget::Direct(newer_oid))
9105        );
9106        assert!(!git_dir.join("refs/heads/main.lock").exists());
9107
9108        assert!(
9109            store
9110                .prune_loose_ref_after_pack("refs/heads/main", &newer_oid)
9111                .expect("matching loose value is pruned")
9112        );
9113        assert!(!git_dir.join("refs/heads/main").exists());
9114        assert!(!git_dir.join("refs/heads/main.lock").exists());
9115        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9116    }
9117
9118    #[test]
9119    fn file_ref_store_imports_files_snapshot_in_final_packed_form() {
9120        let git_dir = temp_git_dir();
9121        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9122        let oid = ObjectId::from_hex(
9123            ObjectFormat::Sha1,
9124            "ce013625030ba8dba906f756967f9e9ca394464a",
9125        )
9126        .expect("test operation should succeed");
9127        let refs = vec![
9128            Ref {
9129                name: "HEAD".into(),
9130                target: RefTarget::Symbolic("refs/heads/main".into()),
9131            },
9132            Ref {
9133                name: "ORIG_HEAD".into(),
9134                target: RefTarget::Direct(oid),
9135            },
9136            Ref {
9137                name: "refs/heads/main".into(),
9138                target: RefTarget::Direct(oid),
9139            },
9140        ];
9141        let reflogs = vec![(
9142            "refs/heads/main".into(),
9143            vec![reflog_entry(&oid, 1, "import")],
9144        )];
9145
9146        store
9147            .import_snapshot(&refs, &reflogs, true)
9148            .expect("import files snapshot");
9149
9150        assert!(git_dir.join("packed-refs").exists());
9151        assert!(!git_dir.join("refs/heads/main").exists());
9152        assert!(git_dir.join("HEAD").exists());
9153        assert!(git_dir.join("ORIG_HEAD").exists());
9154        assert_eq!(
9155            store.read_ref("refs/heads/main").expect("read packed ref"),
9156            Some(RefTarget::Direct(oid))
9157        );
9158        assert_eq!(
9159            store
9160                .read_reflog("refs/heads/main")
9161                .expect("read reflog")
9162                .len(),
9163            1
9164        );
9165        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9166    }
9167
9168    #[test]
9169    fn files_snapshot_exports_materialized_logs_without_probing_every_ref() {
9170        let git_dir = temp_git_dir();
9171        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9172        let oid = ObjectId::from_hex(
9173            ObjectFormat::Sha1,
9174            "ce013625030ba8dba906f756967f9e9ca394464a",
9175        )
9176        .expect("valid oid");
9177        let refs = vec![Ref {
9178            name: "refs/heads/no-log".into(),
9179            target: RefTarget::Direct(oid),
9180        }];
9181        store
9182            .import_snapshot(&refs, &[], false)
9183            .expect("import ref without reflog");
9184        let deleted_log = vec![reflog_entry(&oid, 1, "deleted ref")];
9185        store
9186            .write_reflog("refs/heads/deleted", &deleted_log)
9187            .expect("write materialized reflog without live ref");
9188
9189        assert_eq!(
9190            store.export_snapshot(true).expect("export files snapshot"),
9191            RefSnapshot {
9192                refs,
9193                reflogs: vec![("refs/heads/deleted".into(), deleted_log)],
9194            }
9195        );
9196        fs::remove_dir_all(git_dir).expect("remove test repository");
9197    }
9198
9199    #[test]
9200    fn reftable_snapshot_export_batches_refs_and_reflogs() {
9201        let git_dir = temp_git_dir();
9202        write_reftable_config(&git_dir);
9203        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9204        let oid = ObjectId::from_hex(
9205            ObjectFormat::Sha1,
9206            "ce013625030ba8dba906f756967f9e9ca394464a",
9207        )
9208        .expect("test operation should succeed");
9209        let refs = vec![
9210            Ref {
9211                name: "HEAD".into(),
9212                target: RefTarget::Symbolic("refs/heads/main".into()),
9213            },
9214            Ref {
9215                name: "ORIG_HEAD".into(),
9216                target: RefTarget::Direct(oid),
9217            },
9218            Ref {
9219                name: "refs/heads/main".into(),
9220                target: RefTarget::Direct(oid),
9221            },
9222        ];
9223        let reflogs = vec![
9224            ("HEAD".into(), vec![reflog_entry(&oid, 1, "head")]),
9225            (
9226                "refs/heads/main".into(),
9227                vec![
9228                    reflog_entry(&oid, 2, "first"),
9229                    reflog_entry(&oid, 3, "second"),
9230                ],
9231            ),
9232        ];
9233        store
9234            .import_snapshot(&refs, &reflogs, false)
9235            .expect("import reftable snapshot");
9236
9237        assert_eq!(
9238            store.export_snapshot(true).expect("export with reflogs"),
9239            RefSnapshot {
9240                refs: refs.clone(),
9241                reflogs,
9242            }
9243        );
9244        assert_eq!(
9245            store
9246                .export_snapshot(false)
9247                .expect("export without reflogs"),
9248            RefSnapshot {
9249                refs,
9250                reflogs: Vec::new(),
9251            }
9252        );
9253        fs::remove_dir_all(git_dir).expect("remove test reftable repo");
9254    }
9255
9256    #[test]
9257    fn file_ref_store_packs_loose_refs_without_pruning() {
9258        let git_dir = temp_git_dir();
9259        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9260        let oid = ObjectId::from_hex(
9261            ObjectFormat::Sha1,
9262            "ce013625030ba8dba906f756967f9e9ca394464a",
9263        )
9264        .expect("test operation should succeed");
9265        let mut tx = store.transaction();
9266        tx.update(RefUpdate {
9267            name: "refs/heads/main".into(),
9268            expected: None,
9269            new: RefTarget::Direct(oid),
9270            reflog: None,
9271        });
9272        tx.commit().expect("test operation should succeed");
9273
9274        let packed = store
9275            .pack_refs(false)
9276            .expect("test operation should succeed");
9277        assert_eq!(packed.len(), 1);
9278        assert!(git_dir.join("refs").join("heads").join("main").exists());
9279        assert_eq!(
9280            store
9281                .read_ref("refs/heads/main")
9282                .expect("test operation should succeed"),
9283            Some(RefTarget::Direct(oid))
9284        );
9285        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9286    }
9287
9288    #[test]
9289    fn file_ref_store_packs_loose_refs_with_peeled_ids() {
9290        let git_dir = temp_git_dir();
9291        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9292        let tag_oid = ObjectId::from_hex(
9293            ObjectFormat::Sha1,
9294            "ce013625030ba8dba906f756967f9e9ca394464a",
9295        )
9296        .expect("test operation should succeed");
9297        let peeled_oid = ObjectId::from_hex(
9298            ObjectFormat::Sha1,
9299            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
9300        )
9301        .expect("test operation should succeed");
9302        let mut tx = store.transaction();
9303        tx.update(RefUpdate {
9304            name: "refs/tags/v1.0".into(),
9305            expected: None,
9306            new: RefTarget::Direct(tag_oid),
9307            reflog: None,
9308        });
9309        tx.commit().expect("test operation should succeed");
9310
9311        let packed = store
9312            .pack_refs_with_peeler(true, |name, oid| {
9313                if name == "refs/tags/v1.0" && oid == &tag_oid {
9314                    Ok(Some(peeled_oid))
9315                } else {
9316                    Ok(None)
9317                }
9318            })
9319            .expect("test operation should succeed");
9320        assert_eq!(packed.len(), 1);
9321        assert_eq!(packed[0].peeled, Some(peeled_oid));
9322        let bytes =
9323            fs::read_to_string(git_dir.join("packed-refs")).expect("test operation should succeed");
9324        assert!(bytes.contains(&format!("^{peeled_oid}\n")));
9325        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
9326        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9327    }
9328
9329    fn reflog_entry(new_oid: &ObjectId, timestamp: i64, message: &str) -> ReflogEntry {
9330        ReflogEntry {
9331            old_oid: zero_oid(new_oid.format()).expect("test operation should succeed"),
9332            new_oid: *new_oid,
9333            committer: format!("Git Rs <sley@example.invalid> {timestamp} +0000").into_bytes(),
9334            message: message.as_bytes().to_vec(),
9335        }
9336    }
9337
9338    #[test]
9339    fn expire_reflog_drops_old_entries_and_keeps_latest() {
9340        let oid_a = ObjectId::from_hex(
9341            ObjectFormat::Sha1,
9342            "ce013625030ba8dba906f756967f9e9ca394464a",
9343        )
9344        .expect("test operation should succeed");
9345        let oid_b = ObjectId::from_hex(
9346            ObjectFormat::Sha1,
9347            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
9348        )
9349        .expect("test operation should succeed");
9350        let oid_c = ObjectId::from_hex(
9351            ObjectFormat::Sha1,
9352            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
9353        )
9354        .expect("test operation should succeed");
9355        let entries = vec![
9356            reflog_entry(&oid_a, 10, "oldest"),
9357            reflog_entry(&oid_b, 100, "middle"),
9358            reflog_entry(&oid_c, 20, "latest"),
9359        ];
9360
9361        // Cutoff drops the oldest entry; the most recent entry survives even
9362        // though its timestamp (20) is below the cutoff (50).
9363        let retained =
9364            expire_reflog(&entries, 50, None, |_| true).expect("test operation should succeed");
9365        assert_eq!(retained.len(), 2);
9366        assert_eq!(retained[0].message, b"middle");
9367        assert_eq!(retained[1].message, b"latest");
9368    }
9369
9370    #[test]
9371    fn expire_reflog_applies_stricter_unreachable_cutoff() {
9372        let reachable = ObjectId::from_hex(
9373            ObjectFormat::Sha1,
9374            "ce013625030ba8dba906f756967f9e9ca394464a",
9375        )
9376        .expect("test operation should succeed");
9377        let unreachable = ObjectId::from_hex(
9378            ObjectFormat::Sha1,
9379            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
9380        )
9381        .expect("test operation should succeed");
9382        let tip = ObjectId::from_hex(
9383            ObjectFormat::Sha1,
9384            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
9385        )
9386        .expect("test operation should succeed");
9387        // Both candidate entries sit above the lenient cutoff (50) but below the
9388        // stricter unreachable cutoff (150). Only the unreachable one is dropped.
9389        let entries = vec![
9390            reflog_entry(&reachable, 100, "reachable"),
9391            reflog_entry(&unreachable, 100, "unreachable"),
9392            reflog_entry(&tip, 200, "tip"),
9393        ];
9394        let retained = expire_reflog(&entries, 50, Some(150), |oid| {
9395            oid == &reachable || oid == &tip
9396        })
9397        .expect("test operation should succeed");
9398        assert_eq!(retained.len(), 2);
9399        assert_eq!(retained[0].message, b"reachable");
9400        assert_eq!(retained[1].message, b"tip");
9401    }
9402
9403    #[test]
9404    fn expire_reflog_keeps_single_entry_below_cutoff() {
9405        let oid = ObjectId::from_hex(
9406            ObjectFormat::Sha1,
9407            "ce013625030ba8dba906f756967f9e9ca394464a",
9408        )
9409        .expect("test operation should succeed");
9410        let entries = vec![reflog_entry(&oid, 1, "only")];
9411        let retained = expire_reflog(&entries, i64::MAX, Some(i64::MAX), |_| false)
9412            .expect("test operation should succeed");
9413        assert_eq!(retained.len(), 1);
9414        assert_eq!(retained[0].message, b"only");
9415    }
9416
9417    #[test]
9418    fn file_ref_store_expire_reflog_file_rewrites_and_dry_runs() {
9419        let git_dir = temp_git_dir();
9420        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9421        let first = ObjectId::from_hex(
9422            ObjectFormat::Sha1,
9423            "ce013625030ba8dba906f756967f9e9ca394464a",
9424        )
9425        .expect("test operation should succeed");
9426        let second = ObjectId::from_hex(
9427            ObjectFormat::Sha1,
9428            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
9429        )
9430        .expect("test operation should succeed");
9431        store
9432            .write_reflog(
9433                "refs/heads/main",
9434                &[
9435                    reflog_entry(&first, 10, "old"),
9436                    reflog_entry(&second, 100, "new"),
9437                ],
9438            )
9439            .expect("test operation should succeed");
9440
9441        // Dry run reports the removal count without touching the file.
9442        let would_remove = store
9443            .expire_reflog_file("refs/heads/main", 50, None, false, |_| true)
9444            .expect("test operation should succeed");
9445        assert_eq!(would_remove, 1);
9446        assert_eq!(
9447            store
9448                .read_reflog("refs/heads/main")
9449                .expect("test operation should succeed")
9450                .len(),
9451            2
9452        );
9453
9454        // Opt-in rewrite drops the stale entry and leaves the latest.
9455        let removed = store
9456            .expire_reflog_file("refs/heads/main", 50, None, true, |_| true)
9457            .expect("test operation should succeed");
9458        assert_eq!(removed, 1);
9459        let log = store
9460            .read_reflog("refs/heads/main")
9461            .expect("test operation should succeed");
9462        assert_eq!(log.len(), 1);
9463        assert_eq!(log[0].new_oid, second);
9464        assert!(
9465            !git_dir
9466                .join("logs")
9467                .join("refs")
9468                .join("heads")
9469                .join("main.lock")
9470                .exists()
9471        );
9472        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9473    }
9474
9475    #[test]
9476    fn file_ref_transaction_commits_all_refs_atomically() {
9477        let git_dir = temp_git_dir();
9478        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9479        let main_oid = ObjectId::from_hex(
9480            ObjectFormat::Sha1,
9481            "ce013625030ba8dba906f756967f9e9ca394464a",
9482        )
9483        .expect("test operation should succeed");
9484        let topic_oid = ObjectId::from_hex(
9485            ObjectFormat::Sha1,
9486            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
9487        )
9488        .expect("test operation should succeed");
9489        let tag_oid = ObjectId::from_hex(
9490            ObjectFormat::Sha1,
9491            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
9492        )
9493        .expect("test operation should succeed");
9494        let mut tx = store.transaction();
9495        tx.update(RefUpdate {
9496            name: "refs/heads/main".into(),
9497            expected: None,
9498            new: RefTarget::Direct(main_oid),
9499            reflog: Some(reflog_entry(&main_oid, 0, "create main")),
9500        });
9501        tx.update(RefUpdate {
9502            name: "refs/heads/topic".into(),
9503            expected: None,
9504            new: RefTarget::Direct(topic_oid),
9505            reflog: None,
9506        });
9507        tx.update(RefUpdate {
9508            name: "refs/tags/v1.0".into(),
9509            expected: None,
9510            new: RefTarget::Direct(tag_oid),
9511            reflog: None,
9512        });
9513        tx.commit().expect("test operation should succeed");
9514
9515        assert_eq!(
9516            store
9517                .read_ref("refs/heads/main")
9518                .expect("test operation should succeed"),
9519            Some(RefTarget::Direct(main_oid))
9520        );
9521        assert_eq!(
9522            store
9523                .read_ref("refs/heads/topic")
9524                .expect("test operation should succeed"),
9525            Some(RefTarget::Direct(topic_oid))
9526        );
9527        assert_eq!(
9528            store
9529                .read_ref("refs/tags/v1.0")
9530                .expect("test operation should succeed"),
9531            Some(RefTarget::Direct(tag_oid))
9532        );
9533        let main_log = store
9534            .read_reflog("refs/heads/main")
9535            .expect("test operation should succeed");
9536        assert_eq!(main_log.len(), 1);
9537        assert_eq!(main_log[0].new_oid, main_oid);
9538        // No lock files survive a successful commit.
9539        assert!(
9540            !git_dir
9541                .join("refs")
9542                .join("heads")
9543                .join("main.lock")
9544                .exists()
9545        );
9546        assert!(
9547            !git_dir
9548                .join("refs")
9549                .join("heads")
9550                .join("topic.lock")
9551                .exists()
9552        );
9553        assert!(!git_dir.join("refs").join("tags").join("v1.0.lock").exists());
9554        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9555    }
9556
9557    #[test]
9558    fn file_ref_transaction_preserves_batched_reflog_order() {
9559        let git_dir = temp_git_dir();
9560        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9561        let first = ObjectId::from_hex(
9562            ObjectFormat::Sha1,
9563            "ce013625030ba8dba906f756967f9e9ca394464a",
9564        )
9565        .expect("test operation should succeed");
9566        let second = ObjectId::from_hex(
9567            ObjectFormat::Sha1,
9568            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
9569        )
9570        .expect("test operation should succeed");
9571        let mut tx = store.transaction();
9572        tx.update(RefUpdate {
9573            name: "refs/heads/main".into(),
9574            expected: None,
9575            new: RefTarget::Direct(first),
9576            reflog: Some(reflog_entry(&first, 1, "first")),
9577        });
9578        tx.update(RefUpdate {
9579            name: "refs/heads/topic".into(),
9580            expected: None,
9581            new: RefTarget::Direct(first),
9582            reflog: Some(reflog_entry(&first, 2, "topic")),
9583        });
9584        tx.update(RefUpdate {
9585            name: "refs/heads/main".into(),
9586            expected: None,
9587            new: RefTarget::Direct(second),
9588            reflog: Some(reflog_entry(&second, 3, "second")),
9589        });
9590        tx.commit().expect("test operation should succeed");
9591
9592        let main_log = store
9593            .read_reflog("refs/heads/main")
9594            .expect("test operation should succeed");
9595        assert_eq!(
9596            main_log
9597                .iter()
9598                .map(|entry| entry.message.as_slice())
9599                .collect::<Vec<_>>(),
9600            vec![b"first".as_slice(), b"second".as_slice()]
9601        );
9602        assert_eq!(
9603            store
9604                .read_reflog("refs/heads/topic")
9605                .expect("test operation should succeed")[0]
9606                .message,
9607            b"topic"
9608        );
9609        assert_eq!(
9610            store
9611                .read_ref("refs/heads/main")
9612                .expect("test operation should succeed"),
9613            Some(RefTarget::Direct(second))
9614        );
9615        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9616    }
9617
9618    #[test]
9619    fn file_ref_transaction_replaces_empty_directory_at_ref_path() {
9620        let git_dir = temp_git_dir();
9621        let blocked_ref = git_dir.join("refs").join("heads").join("topic");
9622        fs::create_dir_all(blocked_ref.join("empty").join("nested"))
9623            .expect("test operation should succeed");
9624        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9625        let oid = ObjectId::from_hex(
9626            ObjectFormat::Sha1,
9627            "ce013625030ba8dba906f756967f9e9ca394464a",
9628        )
9629        .expect("test operation should succeed");
9630        let mut tx = store.transaction();
9631        tx.update(RefUpdate {
9632            name: "refs/heads/topic".into(),
9633            expected: None,
9634            new: RefTarget::Direct(oid),
9635            reflog: None,
9636        });
9637        tx.commit().expect("test operation should succeed");
9638
9639        assert!(blocked_ref.is_file());
9640        assert_eq!(
9641            store
9642                .read_ref("refs/heads/topic")
9643                .expect("test operation should succeed"),
9644            Some(RefTarget::Direct(oid))
9645        );
9646        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9647    }
9648
9649    #[test]
9650    fn file_ref_transaction_reports_non_empty_directory_without_mutation() {
9651        let git_dir = temp_git_dir();
9652        let blocked_ref = git_dir.join("refs/heads/topic");
9653        fs::create_dir_all(blocked_ref.join("nested")).expect("test operation should succeed");
9654        fs::write(blocked_ref.join("nested/blocker.lock"), b"")
9655            .expect("test operation should succeed");
9656        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9657        let oid = ObjectId::from_hex(
9658            ObjectFormat::Sha1,
9659            "ce013625030ba8dba906f756967f9e9ca394464a",
9660        )
9661        .expect("test operation should succeed");
9662        let mut tx = store.transaction();
9663        tx.update(RefUpdate {
9664            name: "refs/heads/topic".into(),
9665            expected: None,
9666            new: RefTarget::Direct(oid),
9667            reflog: None,
9668        });
9669
9670        let error = tx.commit().expect_err("non-empty directory must block ref");
9671        assert!(error.to_string().contains("there is a non-empty directory"));
9672        assert!(blocked_ref.join("nested/blocker.lock").is_file());
9673        assert!(!git_dir.join("refs/heads/topic.lock").exists());
9674        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9675    }
9676
9677    #[cfg(unix)]
9678    #[test]
9679    fn file_ref_store_ignores_broken_filesystem_symlink_during_iteration() {
9680        let git_dir = temp_git_dir();
9681        let heads = git_dir.join("refs/heads");
9682        fs::create_dir_all(&heads).expect("test operation should succeed");
9683        std::os::unix::fs::symlink("does-not-exist", heads.join("broken"))
9684            .expect("test operation should succeed");
9685        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9686
9687        assert!(
9688            store
9689                .list_refs()
9690                .expect("test operation should succeed")
9691                .is_empty()
9692        );
9693        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9694    }
9695
9696    #[test]
9697    fn update_snapshot_keeps_broken_loose_ref_over_packed_value() {
9698        let git_dir = temp_git_dir();
9699        fs::create_dir_all(git_dir.join("refs/heads")).expect("test operation should succeed");
9700        let oid = ObjectId::from_hex(
9701            ObjectFormat::Sha1,
9702            "ce013625030ba8dba906f756967f9e9ca394464a",
9703        )
9704        .expect("test operation should succeed");
9705        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9706        store
9707            .write_packed_refs(&[PackedRef {
9708                reference: Ref {
9709                    name: "refs/heads/topic".into(),
9710                    target: RefTarget::Direct(oid),
9711                },
9712                peeled: None,
9713            }])
9714            .expect("test operation should succeed");
9715        fs::write(git_dir.join("refs/heads/topic"), b"gobbledigook\n")
9716            .expect("test operation should succeed");
9717
9718        let snapshot = store
9719            .read_snapshot_for_update()
9720            .expect("test operation should succeed");
9721        assert_eq!(
9722            snapshot.get("refs/heads/topic"),
9723            Some(&RefReadSnapshotValue::Broken)
9724        );
9725        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9726    }
9727
9728    #[cfg(unix)]
9729    #[test]
9730    fn file_ref_transaction_writes_symbolic_ref_as_symlink_when_preferred() {
9731        let git_dir = temp_git_dir();
9732        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1).with_prefer_symlink_refs(true);
9733        let mut tx = store.transaction();
9734        tx.update(RefUpdate {
9735            name: "TEST_SYMREF_HEAD".into(),
9736            expected: None,
9737            new: RefTarget::Symbolic("refs/heads/main".into()),
9738            reflog: None,
9739        });
9740        tx.commit().expect("test operation should succeed");
9741
9742        let path = git_dir.join("TEST_SYMREF_HEAD");
9743        assert!(
9744            fs::symlink_metadata(&path)
9745                .expect("test operation should succeed")
9746                .file_type()
9747                .is_symlink()
9748        );
9749        assert_eq!(
9750            fs::read_link(path).expect("test operation should succeed"),
9751            PathBuf::from("refs/heads/main")
9752        );
9753        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9754    }
9755
9756    #[cfg(unix)]
9757    #[test]
9758    fn file_ref_transaction_rollback_restores_original_symbolic_ref_symlink() {
9759        let git_dir = temp_git_dir();
9760        let heads = git_dir.join("refs/heads");
9761        fs::create_dir_all(&heads).expect("test operation should succeed");
9762        let oid = ObjectId::from_hex(
9763            ObjectFormat::Sha1,
9764            "ce013625030ba8dba906f756967f9e9ca394464a",
9765        )
9766        .expect("test operation should succeed");
9767        fs::write(heads.join("main"), format!("{oid}\n")).expect("test operation should succeed");
9768        let symref_path = git_dir.join("TEST_SYMREF_HEAD");
9769        std::os::unix::fs::symlink("refs/heads/main", &symref_path)
9770            .expect("test operation should succeed");
9771        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1).with_prefer_symlink_refs(true);
9772        let mut tx = store.transaction();
9773        tx.update(RefUpdate {
9774            name: "TEST_SYMREF_HEAD".into(),
9775            expected: None,
9776            new: RefTarget::Symbolic("refs/heads/topic".into()),
9777            reflog: None,
9778        });
9779        tx.update(RefUpdate {
9780            name: "refs/heads/other".into(),
9781            expected: None,
9782            new: RefTarget::Direct(oid),
9783            reflog: None,
9784        });
9785
9786        set_fail_loose_commit_action_for_test(Some(1));
9787        tx.commit()
9788            .expect_err("injected second apply failure must roll back the symref");
9789
9790        assert!(
9791            fs::symlink_metadata(&symref_path)
9792                .expect("test operation should succeed")
9793                .file_type()
9794                .is_symlink()
9795        );
9796        assert_eq!(
9797            fs::read_link(&symref_path).expect("test operation should succeed"),
9798            PathBuf::from("refs/heads/main")
9799        );
9800        assert!(!heads.join("other").exists());
9801        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9802    }
9803
9804    #[test]
9805    fn file_ref_transaction_rolls_back_all_refs_on_expected_mismatch() {
9806        let git_dir = temp_git_dir();
9807        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9808        let old_topic = ObjectId::from_hex(
9809            ObjectFormat::Sha1,
9810            "ce013625030ba8dba906f756967f9e9ca394464a",
9811        )
9812        .expect("test operation should succeed");
9813        let new_main = ObjectId::from_hex(
9814            ObjectFormat::Sha1,
9815            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
9816        )
9817        .expect("test operation should succeed");
9818        let new_tag = ObjectId::from_hex(
9819            ObjectFormat::Sha1,
9820            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
9821        )
9822        .expect("test operation should succeed");
9823        let wrong_expected = ObjectId::from_hex(
9824            ObjectFormat::Sha1,
9825            "0000000000000000000000000000000000000001",
9826        )
9827        .expect("test operation should succeed");
9828
9829        // Seed an existing topic ref so the failing update has a real prior value
9830        // to be compared against (and left untouched).
9831        let mut seed = store.transaction();
9832        seed.update(RefUpdate {
9833            name: "refs/heads/topic".into(),
9834            expected: None,
9835            new: RefTarget::Direct(old_topic.clone()),
9836            reflog: None,
9837        });
9838        seed.commit().expect("test operation should succeed");
9839
9840        let mut tx = store.transaction();
9841        // 1st ref: brand new, would succeed in isolation.
9842        tx.update(RefUpdate {
9843            name: "refs/heads/main".into(),
9844            expected: None,
9845            new: RefTarget::Direct(new_main.clone()),
9846            reflog: Some(reflog_entry(&new_main, 0, "create main")),
9847        });
9848        // 2nd ref: expected value does not match on disk -> whole tx must abort.
9849        tx.update(RefUpdate {
9850            name: "refs/heads/topic".into(),
9851            expected: Some(RefTarget::Direct(wrong_expected)),
9852            new: RefTarget::Direct(new_main.clone()),
9853            reflog: None,
9854        });
9855        // 3rd ref: brand new, must not be written because the tx aborts.
9856        tx.update(RefUpdate {
9857            name: "refs/tags/v1.0".into(),
9858            expected: None,
9859            new: RefTarget::Direct(new_tag),
9860            reflog: None,
9861        });
9862        let result = tx.commit();
9863        assert!(result.is_err());
9864
9865        // Nothing changed: the new refs were never created and the existing one
9866        // keeps its original value.
9867        assert_eq!(
9868            store
9869                .read_ref("refs/heads/main")
9870                .expect("test operation should succeed"),
9871            None
9872        );
9873        assert_eq!(
9874            store
9875                .read_ref("refs/heads/topic")
9876                .expect("test operation should succeed"),
9877            Some(RefTarget::Direct(old_topic))
9878        );
9879        assert_eq!(
9880            store
9881                .read_ref("refs/tags/v1.0")
9882                .expect("test operation should succeed"),
9883            None
9884        );
9885        assert!(
9886            store
9887                .read_reflog("refs/heads/main")
9888                .expect("test operation should succeed")
9889                .is_empty()
9890        );
9891
9892        // All lock files were released.
9893        assert!(
9894            !git_dir
9895                .join("refs")
9896                .join("heads")
9897                .join("main.lock")
9898                .exists()
9899        );
9900        assert!(
9901            !git_dir
9902                .join("refs")
9903                .join("heads")
9904                .join("topic.lock")
9905                .exists()
9906        );
9907        assert!(!git_dir.join("refs").join("tags").join("v1.0.lock").exists());
9908        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9909    }
9910
9911    #[test]
9912    fn file_ref_transaction_mixes_update_and_delete() {
9913        let git_dir = temp_git_dir();
9914        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9915        let old_main = ObjectId::from_hex(
9916            ObjectFormat::Sha1,
9917            "ce013625030ba8dba906f756967f9e9ca394464a",
9918        )
9919        .expect("test operation should succeed");
9920        let new_topic = ObjectId::from_hex(
9921            ObjectFormat::Sha1,
9922            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
9923        )
9924        .expect("test operation should succeed");
9925        let mut seed = store.transaction();
9926        seed.update(RefUpdate {
9927            name: "refs/heads/main".into(),
9928            expected: None,
9929            new: RefTarget::Direct(old_main),
9930            reflog: None,
9931        });
9932        seed.commit().expect("test operation should succeed");
9933
9934        let mut tx = store.transaction();
9935        tx.update(RefUpdate {
9936            name: "refs/heads/topic".into(),
9937            expected: None,
9938            new: RefTarget::Direct(new_topic),
9939            reflog: None,
9940        });
9941        tx.delete_with_precondition(
9942            "refs/heads/main",
9943            RefDeletePrecondition::Direct(Some(old_main)),
9944            None,
9945        );
9946        tx.commit().expect("test operation should succeed");
9947
9948        assert_eq!(
9949            store
9950                .read_ref("refs/heads/main")
9951                .expect("test operation should succeed"),
9952            None
9953        );
9954        assert_eq!(
9955            store
9956                .read_ref("refs/heads/topic")
9957                .expect("test operation should succeed"),
9958            Some(RefTarget::Direct(new_topic))
9959        );
9960        fs::remove_dir_all(git_dir).expect("test operation should succeed");
9961    }
9962
9963    #[test]
9964    fn file_ref_transaction_rejects_deleted_descendant_parent_create() {
9965        let git_dir = temp_git_dir();
9966        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
9967        let old_conflict = ObjectId::from_hex(
9968            ObjectFormat::Sha1,
9969            "ce013625030ba8dba906f756967f9e9ca394464a",
9970        )
9971        .expect("test operation should succeed");
9972        let new_parent = ObjectId::from_hex(
9973            ObjectFormat::Sha1,
9974            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
9975        )
9976        .expect("test operation should succeed");
9977        let mut seed = store.transaction();
9978        seed.update(RefUpdate {
9979            name: "refs/heads/branch/conflict".into(),
9980            expected: None,
9981            new: RefTarget::Direct(old_conflict),
9982            reflog: None,
9983        });
9984        seed.commit().expect("test operation should succeed");
9985
9986        let mut tx = store.transaction();
9987        tx.delete_with_precondition(
9988            "refs/heads/branch/conflict",
9989            RefDeletePrecondition::Direct(Some(old_conflict)),
9990            None,
9991        );
9992        tx.update(RefUpdate {
9993            name: "refs/heads/branch".into(),
9994            expected: None,
9995            new: RefTarget::Direct(new_parent),
9996            reflog: None,
9997        });
9998        let err = tx
9999            .commit()
10000            .expect_err("D/F-conflicting delete plus create must fail");
10001        assert_eq!(
10002            err.to_string(),
10003            "transaction failed: cannot lock ref 'refs/heads/branch': 'refs/heads/branch/conflict' exists; cannot create 'refs/heads/branch'"
10004        );
10005
10006        assert_eq!(
10007            store
10008                .read_ref("refs/heads/branch/conflict")
10009                .expect("test operation should succeed"),
10010            Some(RefTarget::Direct(old_conflict))
10011        );
10012        assert_eq!(
10013            store
10014                .read_ref("refs/heads/branch")
10015                .expect("test operation should succeed"),
10016            None
10017        );
10018        fs::remove_dir_all(git_dir).expect("test operation should succeed");
10019    }
10020
10021    #[test]
10022    fn file_ref_transaction_stale_delete_rolls_back_update() {
10023        let git_dir = temp_git_dir();
10024        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
10025        let old_oid = ObjectId::from_hex(
10026            ObjectFormat::Sha1,
10027            "ce013625030ba8dba906f756967f9e9ca394464a",
10028        )
10029        .expect("test operation should succeed");
10030        let new_oid = ObjectId::from_hex(
10031            ObjectFormat::Sha1,
10032            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
10033        )
10034        .expect("test operation should succeed");
10035        let mut seed = store.transaction();
10036        for name in ["refs/heads/main", "refs/heads/topic"] {
10037            seed.update(RefUpdate {
10038                name: name.into(),
10039                expected: None,
10040                new: RefTarget::Direct(old_oid),
10041                reflog: None,
10042            });
10043        }
10044        seed.commit().expect("test operation should succeed");
10045
10046        let mut tx = store.transaction();
10047        tx.update(RefUpdate {
10048            name: "refs/heads/topic".into(),
10049            expected: None,
10050            new: RefTarget::Direct(new_oid),
10051            reflog: None,
10052        });
10053        tx.delete_with_precondition(
10054            "refs/heads/main",
10055            RefDeletePrecondition::Direct(Some(new_oid)),
10056            None,
10057        );
10058        let err = tx.commit().expect_err("stale delete must abort");
10059        assert!(err.to_string().contains("expected ref refs/heads/main"));
10060
10061        assert_eq!(
10062            store
10063                .read_ref("refs/heads/main")
10064                .expect("test operation should succeed"),
10065            Some(RefTarget::Direct(old_oid))
10066        );
10067        assert_eq!(
10068            store
10069                .read_ref("refs/heads/topic")
10070                .expect("test operation should succeed"),
10071            Some(RefTarget::Direct(old_oid))
10072        );
10073        fs::remove_dir_all(git_dir).expect("test operation should succeed");
10074    }
10075
10076    #[test]
10077    fn file_ref_transaction_rejects_duplicate_mixed_ref() {
10078        let git_dir = temp_git_dir();
10079        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
10080        let oid = ObjectId::from_hex(
10081            ObjectFormat::Sha1,
10082            "ce013625030ba8dba906f756967f9e9ca394464a",
10083        )
10084        .expect("test operation should succeed");
10085        let mut tx = store.transaction();
10086        tx.update(RefUpdate {
10087            name: "refs/heads/main".into(),
10088            expected: None,
10089            new: RefTarget::Direct(oid),
10090            reflog: None,
10091        });
10092        tx.delete_with_precondition("refs/heads/main", RefDeletePrecondition::Any, None);
10093
10094        let err = tx.commit().expect_err("duplicate ref must fail");
10095        assert!(err.to_string().contains("refs/heads/main"));
10096        assert_eq!(
10097            store
10098                .read_ref("refs/heads/main")
10099                .expect("test operation should succeed"),
10100            None
10101        );
10102        fs::remove_dir_all(git_dir).expect("test operation should succeed");
10103    }
10104
10105    #[test]
10106    fn file_ref_transaction_deletes_symbolic_ref_with_immediate_expectation() {
10107        let git_dir = temp_git_dir();
10108        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
10109        let oid = ObjectId::from_hex(
10110            ObjectFormat::Sha1,
10111            "ce013625030ba8dba906f756967f9e9ca394464a",
10112        )
10113        .expect("test operation should succeed");
10114        let mut seed = store.transaction();
10115        seed.update(RefUpdate {
10116            name: "refs/heads/main".into(),
10117            expected: None,
10118            new: RefTarget::Direct(oid),
10119            reflog: None,
10120        });
10121        seed.update(RefUpdate {
10122            name: "refs/aliases/main".into(),
10123            expected: None,
10124            new: RefTarget::Symbolic("refs/heads/main".into()),
10125            reflog: None,
10126        });
10127        seed.commit().expect("test operation should succeed");
10128
10129        let mut tx = store.transaction();
10130        tx.delete_with_precondition(
10131            "refs/aliases/main",
10132            RefDeletePrecondition::Immediate(RefTarget::Symbolic("refs/heads/main".into())),
10133            None,
10134        );
10135        tx.commit().expect("test operation should succeed");
10136
10137        assert_eq!(
10138            store
10139                .read_ref("refs/aliases/main")
10140                .expect("test operation should succeed"),
10141            None
10142        );
10143        assert_eq!(
10144            store
10145                .read_ref("refs/heads/main")
10146                .expect("test operation should succeed"),
10147            Some(RefTarget::Direct(oid))
10148        );
10149        fs::remove_dir_all(git_dir).expect("test operation should succeed");
10150    }
10151
10152    #[test]
10153    fn file_ref_transaction_rolls_back_delete_after_late_write_failure() {
10154        let git_dir = temp_git_dir();
10155        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
10156        let old_oid = ObjectId::from_hex(
10157            ObjectFormat::Sha1,
10158            "ce013625030ba8dba906f756967f9e9ca394464a",
10159        )
10160        .expect("test operation should succeed");
10161        let new_oid = ObjectId::from_hex(
10162            ObjectFormat::Sha1,
10163            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
10164        )
10165        .expect("test operation should succeed");
10166        let mut seed = store.transaction();
10167        for name in ["refs/heads/main", "refs/heads/topic"] {
10168            seed.update(RefUpdate {
10169                name: name.into(),
10170                expected: None,
10171                new: RefTarget::Direct(old_oid),
10172                reflog: None,
10173            });
10174        }
10175        seed.commit().expect("test operation should succeed");
10176
10177        set_fail_loose_commit_action_for_test(Some(1));
10178        let mut tx = store.transaction();
10179        tx.delete_with_precondition(
10180            "refs/heads/main",
10181            RefDeletePrecondition::Direct(Some(old_oid)),
10182            None,
10183        );
10184        tx.update(RefUpdate {
10185            name: "refs/heads/topic".into(),
10186            expected: None,
10187            new: RefTarget::Direct(new_oid),
10188            reflog: None,
10189        });
10190        let err = tx.commit().expect_err("injected failure must abort");
10191        assert!(
10192            err.to_string()
10193                .contains("injected loose ref transaction failure")
10194        );
10195
10196        assert_eq!(
10197            store
10198                .read_ref("refs/heads/main")
10199                .expect("test operation should succeed"),
10200            Some(RefTarget::Direct(old_oid))
10201        );
10202        assert_eq!(
10203            store
10204                .read_ref("refs/heads/topic")
10205                .expect("test operation should succeed"),
10206            Some(RefTarget::Direct(old_oid))
10207        );
10208        assert!(
10209            !git_dir
10210                .join("refs")
10211                .join("heads")
10212                .join("main.lock")
10213                .exists()
10214        );
10215        assert!(
10216            !git_dir
10217                .join("refs")
10218                .join("heads")
10219                .join("topic.lock")
10220                .exists()
10221        );
10222        fs::remove_dir_all(git_dir).expect("test operation should succeed");
10223    }
10224
10225    fn temp_git_dir() -> PathBuf {
10226        let path = std::env::temp_dir().join(format!(
10227            "sley-refs-{}-{}",
10228            std::process::id(),
10229            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
10230        ));
10231        fs::create_dir_all(&path).expect("test operation should succeed");
10232        path
10233    }
10234
10235    fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
10236        Ok(ObjectId::null(format))
10237    }
10238
10239    fn write_reftable_config(git_dir: &Path) {
10240        fs::write(
10241            git_dir.join("config"),
10242            b"[core]\n\trepositoryformatversion = 1\n[extensions]\n\trefStorage = reftable\n",
10243        )
10244        .expect("test operation should succeed");
10245    }
10246
10247    fn write_reftable_stack(
10248        git_dir: &Path,
10249        tables: &[(&str, Vec<sley_formats::ReftableRefRecord>)],
10250    ) {
10251        let reftable_dir = git_dir.join("reftable");
10252        fs::create_dir_all(&reftable_dir).expect("test operation should succeed");
10253        let mut list = String::new();
10254        for (idx, (name, refs)) in tables.iter().enumerate() {
10255            let update_index = (idx + 1) as u64;
10256            let bytes = sley_formats::Reftable::write_ref_only(
10257                ObjectFormat::Sha1,
10258                update_index,
10259                update_index,
10260                refs,
10261            )
10262            .expect("test operation should succeed");
10263            fs::write(reftable_dir.join(name), bytes).expect("test operation should succeed");
10264            list.push_str(name);
10265            list.push('\n');
10266        }
10267        fs::write(reftable_dir.join("tables.list"), list).expect("test operation should succeed");
10268    }
10269}