Skip to main content

sley_refs/
lib.rs

1// sley#7: untrusted-input parsing crate — fallible ops propagate errors;
2// the only retained `expect`s would be documented compile-time invariants.
3#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
4
5use sley_config::GitConfig;
6use sley_core::{GitError, ObjectFormat, ObjectId, Result};
7use sley_formats::{
8    Reftable, ReftableLogRecord, ReftableLogUpdate, ReftableLogValue, ReftableRefRecord,
9    ReftableRefValue,
10};
11use std::borrow::Borrow;
12use std::collections::{BTreeMap, BTreeSet, HashMap};
13use std::fmt;
14use std::fs;
15use std::io::Write;
16use std::ops::Deref;
17use std::path::{Path, PathBuf};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum RefTarget {
22    Direct(ObjectId),
23    Symbolic(String),
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Ref {
28    pub name: String,
29    pub target: RefTarget,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct RefDelete {
34    pub name: String,
35    pub oid: ObjectId,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct DeleteRef {
40    pub name: String,
41    pub expected_old: Option<ObjectId>,
42    pub reflog: Option<DeleteRefReflog>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct DeleteRefReflog {
47    pub committer: Vec<u8>,
48    pub message: Vec<u8>,
49}
50
51#[derive(Debug)]
52pub enum RefDeleteError {
53    NotFound,
54    ExpectedMismatch {
55        expected: Option<ObjectId>,
56        actual: Option<ObjectId>,
57    },
58    Locked,
59    InvalidName,
60    Io(std::io::Error),
61}
62
63impl fmt::Display for RefDeleteError {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::NotFound => f.write_str("ref not found"),
67            Self::ExpectedMismatch { expected, actual } => {
68                write!(
69                    f,
70                    "ref expected old oid mismatch: expected {:?}, actual {:?}",
71                    expected, actual
72                )
73            }
74            Self::Locked => f.write_str("ref is locked"),
75            Self::InvalidName => f.write_str("invalid ref name"),
76            Self::Io(err) => write!(f, "io error: {err}"),
77        }
78    }
79}
80
81impl std::error::Error for RefDeleteError {}
82
83impl From<std::io::Error> for RefDeleteError {
84    fn from(value: std::io::Error) -> Self {
85        Self::Io(value)
86    }
87}
88
89pub fn parse_loose_ref(format: ObjectFormat, name: impl Into<String>, bytes: &[u8]) -> Result<Ref> {
90    let name = name.into();
91    let value = std::str::from_utf8(bytes)
92        .map_err(|err| GitError::InvalidFormat(err.to_string()))?
93        .trim_end_matches('\n');
94    let target = if let Some(symbolic) = value.strip_prefix("ref: ") {
95        RefTarget::Symbolic(symbolic.to_string())
96    } else {
97        RefTarget::Direct(ObjectId::from_hex(format, value)?)
98    };
99    Ok(Ref { name, target })
100}
101
102pub fn write_loose_ref(reference: &Ref) -> Vec<u8> {
103    match &reference.target {
104        RefTarget::Direct(oid) => format!("{oid}\n").into_bytes(),
105        RefTarget::Symbolic(target) => format!("ref: {target}\n").into_bytes(),
106    }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct PackedRef {
111    pub reference: Ref,
112    pub peeled: Option<ObjectId>,
113}
114
115pub fn parse_packed_refs(format: ObjectFormat, bytes: &[u8]) -> Result<Vec<PackedRef>> {
116    let text =
117        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
118    let mut refs: Vec<PackedRef> = Vec::new();
119    for raw_line in text.lines() {
120        let line = raw_line.trim_end();
121        if line.is_empty() || line.starts_with('#') {
122            continue;
123        }
124        if let Some(peeled) = line.strip_prefix('^') {
125            let oid = ObjectId::from_hex(format, peeled)?;
126            let Some(last) = refs.last_mut() else {
127                return Err(GitError::InvalidFormat(
128                    "peeled packed ref without preceding ref".into(),
129                ));
130            };
131            last.peeled = Some(oid);
132            continue;
133        }
134        let (oid, name) = line
135            .split_once(' ')
136            .ok_or_else(|| GitError::InvalidFormat("invalid packed ref line".into()))?;
137        validate_ref_name(name)?;
138        refs.push(PackedRef {
139            reference: Ref {
140                name: name.into(),
141                target: RefTarget::Direct(ObjectId::from_hex(format, oid)?),
142            },
143            peeled: None,
144        });
145    }
146    Ok(refs)
147}
148
149fn packed_refs_have_prefix(format: ObjectFormat, bytes: &[u8], prefix: &str) -> Result<bool> {
150    let text =
151        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
152    let mut found = false;
153    let mut saw_ref = false;
154    for raw_line in text.lines() {
155        let line = raw_line.trim_end();
156        if line.is_empty() || line.starts_with('#') {
157            continue;
158        }
159        if let Some(peeled) = line.strip_prefix('^') {
160            ObjectId::from_hex(format, peeled)?;
161            if !saw_ref {
162                return Err(GitError::InvalidFormat(
163                    "peeled packed ref without preceding ref".into(),
164                ));
165            }
166            continue;
167        }
168        let (oid, name) = line
169            .split_once(' ')
170            .ok_or_else(|| GitError::InvalidFormat("invalid packed ref line".into()))?;
171        validate_ref_name(name)?;
172        ObjectId::from_hex(format, oid)?;
173        saw_ref = true;
174        found |= name.starts_with(prefix);
175    }
176    Ok(found)
177}
178
179pub fn write_packed_refs(refs: &[PackedRef]) -> Result<Vec<u8>> {
180    let mut refs = refs.to_vec();
181    refs.sort_by(|left, right| left.reference.name.cmp(&right.reference.name));
182    let mut out = b"# pack-refs with: peeled fully-peeled sorted \n".to_vec();
183    for packed in refs {
184        validate_ref_name(&packed.reference.name)?;
185        let RefTarget::Direct(oid) = &packed.reference.target else {
186            return Err(GitError::InvalidFormat(format!(
187                "packed ref {} is symbolic",
188                packed.reference.name
189            )));
190        };
191        out.extend_from_slice(oid.to_hex().as_bytes());
192        out.push(b' ');
193        out.extend_from_slice(packed.reference.name.as_bytes());
194        out.push(b'\n');
195        if let Some(peeled) = packed.peeled {
196            out.push(b'^');
197            out.extend_from_slice(peeled.to_hex().as_bytes());
198            out.push(b'\n');
199        }
200    }
201    Ok(out)
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct ReflogEntry {
206    pub old_oid: ObjectId,
207    pub new_oid: ObjectId,
208    pub committer: Vec<u8>,
209    pub message: Vec<u8>,
210}
211
212impl ReflogEntry {
213    pub fn to_line(&self) -> Vec<u8> {
214        let mut out = Vec::new();
215        out.extend_from_slice(self.old_oid.to_hex().as_bytes());
216        out.push(b' ');
217        out.extend_from_slice(self.new_oid.to_hex().as_bytes());
218        out.push(b' ');
219        out.extend_from_slice(&self.committer);
220        if !self.message.is_empty() {
221            out.push(b'\t');
222            out.extend_from_slice(&self.message);
223        }
224        out.push(b'\n');
225        out
226    }
227
228    pub fn timestamp_seconds(&self) -> Result<i64> {
229        let committer = std::str::from_utf8(&self.committer)
230            .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
231        let Some((before_tz, _tz)) = committer.rsplit_once(' ') else {
232            return Err(GitError::InvalidFormat(
233                "reflog committer is missing timezone".into(),
234            ));
235        };
236        let Some((_identity, timestamp)) = before_tz.rsplit_once(' ') else {
237            return Err(GitError::InvalidFormat(
238                "reflog committer is missing timestamp".into(),
239            ));
240        };
241        timestamp
242            .parse::<i64>()
243            .map_err(|err| GitError::InvalidFormat(err.to_string()))
244    }
245}
246
247pub fn parse_reflog(format: ObjectFormat, bytes: &[u8]) -> Result<Vec<ReflogEntry>> {
248    let text =
249        std::str::from_utf8(bytes).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
250    let mut entries = Vec::new();
251    for line in text.lines() {
252        let mut parts = line.splitn(3, ' ');
253        let old = parts
254            .next()
255            .ok_or_else(|| GitError::InvalidFormat("missing reflog old oid".into()))?;
256        let new = parts
257            .next()
258            .ok_or_else(|| GitError::InvalidFormat("missing reflog new oid".into()))?;
259        let rest = parts
260            .next()
261            .ok_or_else(|| GitError::InvalidFormat("missing reflog committer".into()))?;
262        let (committer, message) = rest.split_once('\t').unwrap_or((rest, ""));
263        entries.push(ReflogEntry {
264            old_oid: ObjectId::from_hex(format, old)?,
265            new_oid: ObjectId::from_hex(format, new)?,
266            committer: committer.as_bytes().to_vec(),
267            message: message.as_bytes().to_vec(),
268        });
269    }
270    Ok(entries)
271}
272
273/// Expire reflog entries, mirroring `git reflog expire` semantics.
274///
275/// Entries are kept when their committer timestamp is at or after `cutoff_unix`.
276/// Entries whose `new_oid` is unreachable (per `is_reachable`) are held to the
277/// stricter `expire_unreachable_cutoff` when one is supplied: such an entry is
278/// dropped when its timestamp falls below either cutoff. When
279/// `expire_unreachable_cutoff` is `None`, reachability does not relax the single
280/// `cutoff_unix` bound.
281///
282/// The most recent entry (the one describing the ref's current value) is always
283/// preserved, exactly as git refuses to expire the tip of a reflog, even when it
284/// is older than the cutoff. Relative order of the surviving entries is kept.
285///
286/// This is a pure function over already-parsed entries so callers can read,
287/// filter, and rewrite reflogs however they like; see
288/// [`FileRefStore::expire_reflog_file`] for a filesystem convenience built on top
289/// of it.
290pub fn expire_reflog(
291    entries: &[ReflogEntry],
292    cutoff_unix: i64,
293    expire_unreachable_cutoff: Option<i64>,
294    is_reachable: impl Fn(&ObjectId) -> bool,
295) -> Result<Vec<ReflogEntry>> {
296    let last_index = entries.len().checked_sub(1);
297    let mut retained = Vec::with_capacity(entries.len());
298    for (index, entry) in entries.iter().enumerate() {
299        // Always keep the most recent entry: it records the current ref value
300        // and git never expires it.
301        if Some(index) == last_index {
302            retained.push(entry.clone());
303            continue;
304        }
305        let timestamp = entry.timestamp_seconds()?;
306        let mut expired = timestamp < cutoff_unix;
307        if let Some(unreachable_cutoff) = expire_unreachable_cutoff
308            && !is_reachable(&entry.new_oid)
309        {
310            expired = expired || timestamp < unreachable_cutoff;
311        }
312        if !expired {
313            retained.push(entry.clone());
314        }
315    }
316    Ok(retained)
317}
318
319#[derive(Debug, Default, Clone)]
320pub struct RefStore {
321    refs: HashMap<String, RefTarget>,
322    reflogs: BTreeMap<String, Vec<ReflogEntry>>,
323}
324
325impl RefStore {
326    pub fn new() -> Self {
327        Self::default()
328    }
329
330    pub fn get(&self, name: &str) -> Option<&RefTarget> {
331        self.refs.get(name)
332    }
333
334    pub fn transaction(&mut self) -> RefTransaction<'_> {
335        RefTransaction {
336            store: self,
337            updates: Vec::new(),
338        }
339    }
340
341    pub fn reflog(&self, name: &str) -> &[ReflogEntry] {
342        self.reflogs
343            .get(name)
344            .map(Vec::as_slice)
345            .unwrap_or_default()
346    }
347}
348
349#[derive(Debug)]
350pub struct RefUpdate {
351    pub name: String,
352    pub expected: Option<RefTarget>,
353    pub new: RefTarget,
354    pub reflog: Option<ReflogEntry>,
355}
356
357/// The compare-and-swap precondition a ref update is checked against (re-verified
358/// while the ref is locked, so it is a true CAS, not a check-then-write).
359///
360/// [`RefUpdate::expected`] can express [`Any`](RefPrecondition::Any) (`None`) and
361/// [`MustExistAndMatch`](RefPrecondition::MustExistAndMatch) (`Some`); the
362/// create-only and match-or-create modes are reachable via
363/// [`FileRefTransaction::update_to`].
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub enum RefPrecondition {
366    /// No precondition: create or overwrite unconditionally.
367    Any,
368    /// The ref must currently exist (with any value).
369    MustExist,
370    /// The ref must currently not exist (create-only).
371    MustNotExist,
372    /// The ref must currently exist and point exactly at this target.
373    MustExistAndMatch(RefTarget),
374    /// If the ref exists it must point exactly at this target; if it is absent,
375    /// the update is still allowed (match-or-create).
376    ExistingMustMatch(RefTarget),
377}
378
379impl RefPrecondition {
380    /// The precondition implied by a [`RefUpdate::expected`] value.
381    fn from_expected(expected: Option<RefTarget>) -> Self {
382        match expected {
383            None => Self::Any,
384            Some(target) => Self::MustExistAndMatch(target),
385        }
386    }
387
388    /// Whether `current` — the ref's value right now, or `None` if absent —
389    /// satisfies this precondition.
390    fn is_satisfied_by(&self, current: Option<&RefTarget>) -> bool {
391        match self {
392            Self::Any => true,
393            Self::MustExist => current.is_some(),
394            Self::MustNotExist => current.is_none(),
395            Self::MustExistAndMatch(target) => current == Some(target),
396            Self::ExistingMustMatch(target) => match current {
397                None => true,
398                Some(current) => current == target,
399            },
400        }
401    }
402
403    /// A human-readable description of an unmet precondition, for errors.
404    fn describe(&self, name: &str) -> String {
405        match self {
406            Self::Any => format!("ref {name} precondition not met"),
407            Self::MustExist => format!("expected ref {name} to exist"),
408            Self::MustNotExist => format!("expected ref {name} to not already exist"),
409            Self::MustExistAndMatch(_) => format!("expected ref {name} to match"),
410            Self::ExistingMustMatch(_) => {
411                format!("expected ref {name} to match its current value")
412            }
413        }
414    }
415}
416
417pub struct RefTransaction<'a> {
418    store: &'a mut RefStore,
419    updates: Vec<RefUpdate>,
420}
421
422impl<'a> RefTransaction<'a> {
423    pub fn update(&mut self, update: RefUpdate) {
424        self.updates.push(update);
425    }
426
427    pub fn commit(self) -> Result<()> {
428        for update in &self.updates {
429            if let Some(expected) = &update.expected
430                && self.store.refs.get(&update.name) != Some(expected)
431            {
432                return Err(GitError::Transaction(format!(
433                    "expected ref {} to match",
434                    update.name
435                )));
436            }
437        }
438        for update in self.updates {
439            self.store.refs.insert(update.name.clone(), update.new);
440            if let Some(entry) = update.reflog {
441                self.store
442                    .reflogs
443                    .entry(update.name)
444                    .or_default()
445                    .push(entry);
446            }
447        }
448        Ok(())
449    }
450}
451
452#[derive(Debug, Clone)]
453pub struct FileRefStore {
454    git_dir: PathBuf,
455    common_dir: PathBuf,
456    format: ObjectFormat,
457}
458
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct BranchCreate {
461    pub name: String,
462    pub oid: ObjectId,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct BranchDelete {
467    pub name: String,
468    pub oid: ObjectId,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct TagCreate {
473    pub name: String,
474    pub oid: ObjectId,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct TagDelete {
479    pub name: String,
480    pub oid: ObjectId,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq)]
484pub struct BundleRefUpdate {
485    pub name: String,
486    pub oid: ObjectId,
487}
488
489#[derive(Debug, Clone, PartialEq, Eq)]
490pub struct BundleRefUpdateReflog {
491    pub committer: Vec<u8>,
492    pub message: Vec<u8>,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub struct AppliedBundleRefUpdate {
497    pub name: String,
498    pub old_oid: Option<ObjectId>,
499    pub new_oid: ObjectId,
500}
501
502impl FileRefStore {
503    pub fn new(git_dir: impl Into<PathBuf>, format: ObjectFormat) -> Self {
504        let git_dir = git_dir.into();
505        let common_dir = repository_common_dir(&git_dir);
506        Self {
507            git_dir,
508            common_dir,
509            format,
510        }
511    }
512
513    pub fn read_ref(&self, name: &str) -> Result<Option<RefTarget>> {
514        validate_ref_name_for_read(name)?;
515        self.read_ref_unchecked(name)
516    }
517
518    fn read_ref_unchecked(&self, name: &str) -> Result<Option<RefTarget>> {
519        if self.uses_reftable()? {
520            return self.read_reftable_ref(name);
521        }
522        if let Some(reference) = self.read_loose_ref(name)? {
523            return Ok(Some(reference.target));
524        }
525        if let Some(reference) = self.read_packed_ref(name)? {
526            return Ok(Some(reference.reference.target));
527        }
528        Ok(None)
529    }
530
531    /// Raw existence check matching git's `refs_read_raw_ref` (builtin/refs.c
532    /// cmd_refs_exists). A ref "exists" if its loose file is present (regardless
533    /// of contents — dangling symrefs, bad object ids, and refs written with a
534    /// bad name all count) or if it is recorded in packed-refs / the reftable.
535    /// Unlike [`read_ref`], no name validation is performed and the object the
536    /// ref points at is never read. Returns:
537    ///   * `Ok(true)`  — the raw ref exists.
538    ///   * `Ok(false)` — ENOENT or EISDIR (a bare directory where the ref would
539    ///     live and no packed entry); git maps both to exit code 2.
540    pub fn raw_ref_exists(&self, name: &str) -> Result<bool> {
541        if self.uses_reftable()? {
542            return Ok(self.read_reftable_ref(name)?.is_some());
543        }
544        // git routes root-ref-syntax names (HEAD, FETCH_HEAD, MERGE_HEAD, …) to
545        // the per-worktree gitdir and everything else to the common dir; mirror
546        // files_ref_path's REF_WORKTREE_CURRENT vs REF_WORKTREE_SHARED split.
547        let base = if is_root_ref_syntax(name) {
548            &self.git_dir
549        } else {
550            &self.common_dir
551        };
552        let path = base.join(name);
553        match fs::symlink_metadata(&path) {
554            Ok(meta) if meta.is_dir() => {
555                // A directory at the loose path is EISDIR unless packed-refs
556                // still carries the name.
557                Ok(self.read_packed_ref(name)?.is_some())
558            }
559            Ok(_) => Ok(true),
560            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
561                Ok(self.read_packed_ref(name)?.is_some())
562            }
563            Err(err) => Err(err.into()),
564        }
565    }
566
567    pub fn read_reflog(&self, name: &str) -> Result<Vec<ReflogEntry>> {
568        validate_ref_name_for_read(name)?;
569        if self.uses_reftable()? {
570            return self.read_reftable_logs(name);
571        }
572        let path = self.reflog_path(name);
573        if !path.exists() {
574            return Ok(Vec::new());
575        }
576        parse_reflog(self.format, &fs::read(path)?)
577    }
578
579    pub fn write_reflog(&self, name: &str, entries: &[ReflogEntry]) -> Result<()> {
580        validate_ref_name_for_read(name)?;
581        if self.uses_reftable()? {
582            return self.rewrite_reftable_logs(name, entries);
583        }
584        let path = self.reflog_path(name);
585        let parent = path
586            .parent()
587            .ok_or_else(|| GitError::InvalidPath("reflog path has no parent".into()))?;
588        fs::create_dir_all(parent)?;
589        let mut bytes = Vec::new();
590        for entry in entries {
591            bytes.extend_from_slice(&entry.to_line());
592        }
593        write_locked(&path, &bytes)
594    }
595
596    pub fn expire_reflog_older_than(&self, name: &str, cutoff_seconds: i64) -> Result<usize> {
597        validate_ref_name_for_read(name)?;
598        if self.uses_reftable()? {
599            let entries = self.read_reftable_logs(name)?;
600            let original_len = entries.len();
601            let mut retained = Vec::new();
602            for entry in entries {
603                if entry.timestamp_seconds()? >= cutoff_seconds {
604                    retained.push(entry);
605                }
606            }
607            let removed = original_len - retained.len();
608            if removed > 0 {
609                self.rewrite_reftable_logs(name, &retained)?;
610            }
611            return Ok(removed);
612        }
613        let path = self.reflog_path(name);
614        if !path.exists() {
615            return Ok(0);
616        }
617        let entries = parse_reflog(self.format, &fs::read(&path)?)?;
618        let original_len = entries.len();
619        let mut retained = Vec::new();
620        for entry in entries {
621            if entry.timestamp_seconds()? >= cutoff_seconds {
622                retained.push(entry);
623            }
624        }
625        let mut bytes = Vec::new();
626        for entry in &retained {
627            bytes.extend_from_slice(&entry.to_line());
628        }
629        write_locked(&path, &bytes)?;
630        Ok(original_len - retained.len())
631    }
632
633    /// Read a ref's reflog, expire entries with [`expire_reflog`], and rewrite
634    /// the file with the survivors.
635    ///
636    /// Reachability of each entry's `new_oid` is delegated to `is_reachable` so
637    /// the caller can supply whatever object-graph knowledge it has. Rewriting is
638    /// opt-in via `write`: when `false` nothing is written and the function only
639    /// reports how many entries would be removed (a dry run). When `true` the
640    /// reflog is rewritten atomically (lock file + rename) only if at least one
641    /// entry was removed; an unchanged reflog is left untouched. Returns the
642    /// number of entries removed.
643    pub fn expire_reflog_file(
644        &self,
645        name: &str,
646        cutoff_unix: i64,
647        expire_unreachable_cutoff: Option<i64>,
648        write: bool,
649        is_reachable: impl Fn(&ObjectId) -> bool,
650    ) -> Result<usize> {
651        validate_ref_name(name)?;
652        if self.uses_reftable()? {
653            let entries = self.read_reftable_logs(name)?;
654            let original_len = entries.len();
655            let retained = expire_reflog(
656                &entries,
657                cutoff_unix,
658                expire_unreachable_cutoff,
659                is_reachable,
660            )?;
661            let removed = original_len - retained.len();
662            if write && removed > 0 {
663                self.rewrite_reftable_logs(name, &retained)?;
664            }
665            return Ok(removed);
666        }
667        let path = self.reflog_path(name);
668        if !path.exists() {
669            return Ok(0);
670        }
671        let entries = parse_reflog(self.format, &fs::read(&path)?)?;
672        let original_len = entries.len();
673        let retained = expire_reflog(
674            &entries,
675            cutoff_unix,
676            expire_unreachable_cutoff,
677            is_reachable,
678        )?;
679        let removed = original_len - retained.len();
680        if write && removed > 0 {
681            let mut bytes = Vec::new();
682            for entry in &retained {
683                bytes.extend_from_slice(&entry.to_line());
684            }
685            write_locked(&path, &bytes)?;
686        }
687        Ok(removed)
688    }
689
690    pub fn list_refs(&self) -> Result<Vec<Ref>> {
691        if self.uses_reftable()? {
692            return self.list_reftable_refs();
693        }
694        let mut refs = Vec::new();
695        let packed_path = self.common_dir.join("packed-refs");
696        if packed_path.exists() {
697            for packed in parse_packed_refs(self.format, &fs::read(packed_path)?)? {
698                refs.push(packed.reference);
699            }
700        }
701        let refs_dir = self.common_dir.join("refs");
702        let mut loose_refs = BTreeMap::new();
703        if refs_dir.exists() {
704            self.collect_loose_refs(&refs_dir, "refs", &mut loose_refs)?;
705        }
706        if !loose_refs.is_empty() {
707            refs.retain(|reference| !loose_refs.contains_key(&reference.name));
708            refs.extend(loose_refs.into_values());
709        }
710        refs.sort_by(|left, right| left.name.cmp(&right.name));
711        Ok(refs)
712    }
713
714    pub fn has_refs_with_prefix(&self, prefix: &str) -> Result<bool> {
715        if self.uses_reftable()? {
716            return Ok(self
717                .list_reftable_refs()?
718                .iter()
719                .any(|reference| reference.name.starts_with(prefix)));
720        }
721        let packed_path = self.common_dir.join("packed-refs");
722        if packed_path.exists()
723            && packed_refs_have_prefix(self.format, &fs::read(&packed_path)?, prefix)?
724        {
725            return Ok(true);
726        }
727        self.loose_refs_have_prefix(prefix)
728    }
729
730    pub fn write_packed_refs(&self, refs: &[PackedRef]) -> Result<()> {
731        write_locked(
732            &self.common_dir.join("packed-refs"),
733            &write_packed_refs(refs)?,
734        )
735    }
736
737    pub fn pack_refs(&self, prune_loose: bool) -> Result<Vec<PackedRef>> {
738        self.pack_refs_with_peeler(prune_loose, |_, _| Ok(None))
739    }
740
741    pub fn pack_refs_with_peeler<F>(&self, prune_loose: bool, mut peel: F) -> Result<Vec<PackedRef>>
742    where
743        F: FnMut(&str, &ObjectId) -> Result<Option<ObjectId>>,
744    {
745        let mut packed_refs = BTreeMap::new();
746        let packed_path = self.common_dir.join("packed-refs");
747        if packed_path.exists() {
748            for packed in parse_packed_refs(self.format, &fs::read(&packed_path)?)? {
749                packed_refs.insert(packed.reference.name.clone(), packed);
750            }
751        }
752
753        let mut loose_refs = BTreeMap::new();
754        let refs_dir = self.common_dir.join("refs");
755        if refs_dir.exists() {
756            self.collect_loose_refs(&refs_dir, "refs", &mut loose_refs)?;
757        }
758        let mut packed_loose_names = Vec::new();
759        for reference in loose_refs.into_values() {
760            let RefTarget::Direct(oid) = reference.target else {
761                continue;
762            };
763            let peeled = peel(&reference.name, &oid)?;
764            packed_loose_names.push(reference.name.clone());
765            packed_refs.insert(
766                reference.name.clone(),
767                PackedRef {
768                    reference: Ref {
769                        name: reference.name,
770                        target: RefTarget::Direct(oid),
771                    },
772                    peeled,
773                },
774            );
775        }
776
777        let refs = packed_refs.into_values().collect::<Vec<_>>();
778        self.write_packed_refs(&refs)?;
779        if prune_loose {
780            for name in packed_loose_names {
781                self.delete_loose_ref(&name)?;
782            }
783        }
784        Ok(refs)
785    }
786
787    pub fn current_branch_ref(&self) -> Result<Option<String>> {
788        match self.read_ref("HEAD")? {
789            Some(RefTarget::Symbolic(name)) if name.starts_with("refs/heads/") => Ok(Some(name)),
790            _ => Ok(None),
791        }
792    }
793
794    pub fn current_branch(&self) -> Result<Option<String>> {
795        Ok(self
796            .current_branch_ref()?
797            .and_then(|name| name.strip_prefix("refs/heads/").map(str::to_string)))
798    }
799
800    pub fn transaction(&self) -> FileRefTransaction<'_> {
801        FileRefTransaction {
802            store: self,
803            changes: Vec::new(),
804            hook: None,
805        }
806    }
807
808    pub fn create_branch(
809        &self,
810        branch: &str,
811        start: ObjectId,
812        committer: Vec<u8>,
813        message: Vec<u8>,
814    ) -> Result<BranchCreate> {
815        let name = branch_ref_name(branch)?;
816        if self.read_ref(&name)?.is_some() {
817            return Err(GitError::Transaction(format!(
818                "branch {branch} already exists"
819            )));
820        }
821        let zero = ObjectId::null(self.format);
822        let mut tx = self.transaction();
823        tx.update(RefUpdate {
824            name: name.clone(),
825            expected: None,
826            new: RefTarget::Direct(start),
827            reflog: Some(ReflogEntry {
828                old_oid: zero,
829                new_oid: start,
830                committer,
831                message,
832            }),
833        });
834        tx.commit()?;
835        Ok(BranchCreate { name, oid: start })
836    }
837
838    pub fn delete_branch(&self, branch: &str) -> Result<BranchDelete> {
839        let name = branch_ref_name(branch)?;
840        if matches!(self.read_ref("HEAD")?, Some(RefTarget::Symbolic(head)) if head == name) {
841            return Err(GitError::Transaction(format!(
842                "cannot delete branch {branch} checked out at HEAD"
843            )));
844        }
845        let oid = self.delete_direct_ref(&name, "branch", branch)?;
846        self.remove_reflog_file(&name);
847        Ok(BranchDelete { name, oid })
848    }
849
850    pub fn move_branch(
851        &self,
852        old_branch: &str,
853        new_branch: &str,
854        force: bool,
855        committer: Vec<u8>,
856    ) -> Result<()> {
857        self.copy_or_move_branch(old_branch, new_branch, force, false, committer)
858    }
859
860    pub fn copy_branch(
861        &self,
862        old_branch: &str,
863        new_branch: &str,
864        force: bool,
865        committer: Vec<u8>,
866    ) -> Result<()> {
867        self.copy_or_move_branch(old_branch, new_branch, force, true, committer)
868    }
869
870    /// Find an existing ref (other than `exclude`) that would have a
871    /// directory/file conflict with creating `new_name`: either an existing ref
872    /// is a path-prefix of `new_name` (it occupies a directory component
873    /// `new_name` needs), or `new_name` is a path-prefix of an existing ref
874    /// (`new_name` would occupy a directory another ref needs). Returns the
875    /// conflicting ref name.
876    fn conflicting_ref_for_path(&self, new_name: &str, exclude: &str) -> Result<Option<String>> {
877        for reference in self.list_refs()? {
878            let name = &reference.name;
879            if name == new_name || name == exclude {
880                continue;
881            }
882            // `name` sits above `new_name`: name = refs/heads/r, new = refs/heads/r/q
883            if new_name.starts_with(&format!("{name}/")) {
884                return Ok(Some(name.clone()));
885            }
886            // `name` sits below `new_name`: new = refs/heads/r, name = refs/heads/r/q
887            if name.starts_with(&format!("{new_name}/")) {
888                return Ok(Some(name.clone()));
889            }
890        }
891        Ok(None)
892    }
893
894    fn copy_or_move_branch(
895        &self,
896        old_branch: &str,
897        new_branch: &str,
898        force: bool,
899        copy: bool,
900        committer: Vec<u8>,
901    ) -> Result<()> {
902        let old_name = branch_ref_name(old_branch)?;
903        let new_name = branch_ref_name(new_branch)?;
904        if old_name == new_name {
905            return Ok(());
906        }
907        let Some(target) = self.read_ref(&old_name)? else {
908            return Err(GitError::reference_not_found(format!(
909                "branch {old_branch}"
910            )));
911        };
912        let RefTarget::Direct(oid) = target else {
913            return Err(GitError::InvalidFormat(format!(
914                "branch {old_branch} is symbolic"
915            )));
916        };
917        // Detect a directory/file conflict against some *other* ref before
918        // mutating anything (git's rename_ref fails up front, leaving the old
919        // branch intact): e.g. renaming `q` -> `r/q` while `r` exists, or `q` ->
920        // `r` while `r/x` exists. The old ref itself is excluded because a
921        // self-nesting rename (`m` -> `m/m`) is handled by removing it first.
922        if let Some(conflict) = self.conflicting_ref_for_path(&new_name, &old_name)? {
923            return Err(GitError::Transaction(format!(
924                "'{conflict}' exists; cannot create '{new_name}'"
925            )));
926        }
927        // git's validate_branchname uses refs_ref_exists (RESOLVE_REF_READING):
928        // a *dangling* symref destination does not "exist", so a rename onto it
929        // proceeds without --force and overwrites the symref file (t3200 #16).
930        let dest_entry = self.read_ref(&new_name)?;
931        let dest_resolves = resolve_ref_peeled(self, &new_name)?.is_some();
932        if dest_resolves && !force {
933            return Err(GitError::Transaction(format!(
934                "branch {new_branch} already exists"
935            )));
936        }
937        // Remove any existing destination ref (direct or symbolic) before
938        // writing. A dangling symref must be removed as a symref; a real branch
939        // as a direct ref.
940        match dest_entry {
941            Some(RefTarget::Symbolic(_)) => {
942                self.delete_symbolic_ref(&new_name)?;
943                self.remove_reflog_file(&new_name);
944            }
945            Some(RefTarget::Direct(_)) => {
946                let _ = self.delete_direct_ref(&new_name, "branch", new_branch)?;
947                self.remove_reflog_file(&new_name);
948            }
949            None => {}
950        }
951
952        // Capture the old reflog before removing anything; it is carried over
953        // to the new ref.
954        let mut reflog = self.read_reflog(&old_name)?;
955        reflog.push(ReflogEntry {
956            old_oid: oid,
957            new_oid: oid,
958            committer,
959            message: if copy {
960                format!("Branch: copied {old_name} to {new_name}").into_bytes()
961            } else {
962                format!("Branch: renamed {old_name} to {new_name}").into_bytes()
963            },
964        });
965
966        // A directory/file conflict can occur when the new ref's path nests
967        // under the old ref (`m` -> `m/m`) or vice-versa; remove the old loose
968        // ref AND its reflog first so neither file blocks creating the new
969        // directory under refs/ or logs/refs/ (t3200 #17, #18).
970        if !copy {
971            let _ = self.delete_direct_ref(&old_name, "branch", old_branch)?;
972            self.remove_reflog_file(&old_name);
973        }
974
975        self.write_loose_ref(&Ref {
976            name: new_name.clone(),
977            target: RefTarget::Direct(oid),
978        })?;
979        self.write_reflog(&new_name, &reflog)?;
980
981        if !copy
982            && matches!(self.read_ref("HEAD")?, Some(RefTarget::Symbolic(head)) if head == old_name)
983        {
984            self.write_loose_ref(&Ref {
985                name: "HEAD".into(),
986                target: RefTarget::Symbolic(new_name),
987            })?;
988        }
989        Ok(())
990    }
991
992    pub fn create_tag(&self, tag: &str, target: ObjectId) -> Result<TagCreate> {
993        let name = tag_ref_name(tag)?;
994        if self.read_ref(&name)?.is_some() {
995            return Err(GitError::Transaction(format!("tag {tag} already exists")));
996        }
997        let mut tx = self.transaction();
998        tx.update(RefUpdate {
999            name: name.clone(),
1000            expected: None,
1001            new: RefTarget::Direct(target),
1002            reflog: None,
1003        });
1004        tx.commit()?;
1005        Ok(TagCreate { name, oid: target })
1006    }
1007
1008    pub fn apply_bundle_ref_updates(
1009        &self,
1010        refs: &[BundleRefUpdate],
1011        reflog: Option<BundleRefUpdateReflog>,
1012    ) -> Result<Vec<AppliedBundleRefUpdate>> {
1013        let (updates, applied) = prepare_bundle_ref_updates(refs, reflog.as_ref(), |name, oid| {
1014            if oid.format() != self.format {
1015                return Err(GitError::InvalidObjectId(format!(
1016                    "bundle ref {name} has {} object id for {} repository",
1017                    oid.format().name(),
1018                    self.format.name()
1019                )));
1020            }
1021            self.read_ref(name)
1022        })?;
1023        let mut tx = self.transaction();
1024        for update in updates {
1025            tx.update(update);
1026        }
1027        tx.commit()?;
1028        Ok(applied)
1029    }
1030
1031    pub fn delete_tag(&self, tag: &str) -> Result<TagDelete> {
1032        let name = TagRefNameBuf::from_tag_name_unrestricted(tag)?.into_string();
1033        let oid = self.delete_direct_ref(&name, "tag", tag)?;
1034        Ok(TagDelete { name, oid })
1035    }
1036
1037    pub fn delete_ref(&self, name: &str) -> Result<RefDelete> {
1038        validate_ref_name(name)?;
1039        let oid = self.delete_direct_ref(name, "ref", name)?;
1040        self.remove_reflog_file(name);
1041        Ok(RefDelete {
1042            name: name.into(),
1043            oid,
1044        })
1045    }
1046
1047    pub fn delete_ref_checked(
1048        &self,
1049        delete: DeleteRef,
1050    ) -> std::result::Result<RefDelete, RefDeleteError> {
1051        validate_ref_name(&delete.name).map_err(|_| RefDeleteError::InvalidName)?;
1052        if self.uses_reftable().map_err(ref_delete_error_from_git)? {
1053            return self.delete_reftable_ref_checked(delete);
1054        }
1055        self.delete_files_ref_checked(delete)
1056    }
1057
1058    pub fn delete_symbolic_ref(&self, name: &str) -> Result<bool> {
1059        validate_ref_name_for_read(name)?;
1060        if self.uses_reftable()? {
1061            let Some(target) = self.read_ref(name)? else {
1062                return Ok(false);
1063            };
1064            if !matches!(target, RefTarget::Symbolic(_)) {
1065                return Ok(false);
1066            }
1067            self.append_reftable_records(vec![ReftableRefRecord {
1068                name: name.to_string(),
1069                update_index: 0,
1070                value: ReftableRefValue::Deletion,
1071            }])?;
1072            self.remove_reflog_file(name);
1073            return Ok(true);
1074        }
1075        let Some(reference) = self.read_loose_ref(name)? else {
1076            return Ok(false);
1077        };
1078        if !matches!(reference.target, RefTarget::Symbolic(_)) {
1079            return Ok(false);
1080        }
1081        self.delete_loose_ref(name)?;
1082        self.remove_reflog_file(name);
1083        Ok(true)
1084    }
1085
1086    fn delete_direct_ref(&self, name: &str, kind: &str, short_name: &str) -> Result<ObjectId> {
1087        if self.uses_reftable()? {
1088            let Some(target) = self.read_ref(name)? else {
1089                return Err(GitError::reference_not_found(format!(
1090                    "{kind} {short_name}"
1091                )));
1092            };
1093            let RefTarget::Direct(oid) = target else {
1094                return Err(GitError::InvalidFormat(format!(
1095                    "{kind} {short_name} is symbolic"
1096                )));
1097            };
1098            self.append_reftable_records(vec![ReftableRefRecord {
1099                name: name.to_string(),
1100                update_index: 0,
1101                value: ReftableRefValue::Deletion,
1102            }])?;
1103            // git drops the reflog when the ref goes away; tombstone the log
1104            // records so `git reflog` / `git stash list` stop seeing them.
1105            self.remove_reflog_file(name);
1106            return Ok(oid);
1107        }
1108        let Some(reference) = self.read_loose_ref(name)? else {
1109            return self.delete_packed_ref(name, kind, short_name);
1110        };
1111        let oid = match reference.target {
1112            RefTarget::Direct(oid) => oid,
1113            RefTarget::Symbolic(target) => {
1114                return Err(GitError::InvalidFormat(format!(
1115                    "{kind} {short_name} is symbolic to {target}"
1116                )));
1117            }
1118        };
1119        self.delete_loose_ref(name)?;
1120        Ok(oid)
1121    }
1122
1123    fn delete_packed_ref(&self, name: &str, kind: &str, short_name: &str) -> Result<ObjectId> {
1124        let path = self.common_dir.join("packed-refs");
1125        if !path.exists() {
1126            return Err(GitError::reference_not_found(format!(
1127                "{kind} {short_name}"
1128            )));
1129        }
1130        let mut refs = parse_packed_refs(self.format, &fs::read(&path)?)?;
1131        let Some(index) = refs
1132            .iter()
1133            .position(|reference| reference.reference.name == name)
1134        else {
1135            return Err(GitError::reference_not_found(format!(
1136                "{kind} {short_name}"
1137            )));
1138        };
1139        let removed = refs.remove(index);
1140        let RefTarget::Direct(oid) = removed.reference.target else {
1141            return Err(GitError::InvalidFormat(format!(
1142                "{kind} {short_name} is symbolic"
1143            )));
1144        };
1145        self.write_packed_refs(&refs)?;
1146        Ok(oid)
1147    }
1148
1149    fn delete_reftable_ref_checked(
1150        &self,
1151        delete: DeleteRef,
1152    ) -> std::result::Result<RefDelete, RefDeleteError> {
1153        let target = self
1154            .read_ref(&delete.name)
1155            .map_err(ref_delete_error_from_git)?;
1156        let oid = checked_delete_oid(delete.expected_old, target)?;
1157        self.append_reftable_records(vec![ReftableRefRecord {
1158            name: delete.name.clone(),
1159            update_index: 0,
1160            value: ReftableRefValue::Deletion,
1161        }])
1162        .map_err(ref_delete_error_from_git)?;
1163        // Git unlinks logs/refs/<name> on delete (pruning now-empty dirs); it
1164        // does not keep a deletion entry. Mirror delete_ref / delete_branch.
1165        self.remove_reflog_file(&delete.name);
1166        Ok(RefDelete {
1167            name: delete.name,
1168            oid,
1169        })
1170    }
1171
1172    fn delete_files_ref_checked(
1173        &self,
1174        delete: DeleteRef,
1175    ) -> std::result::Result<RefDelete, RefDeleteError> {
1176        let name = delete.name;
1177        let path = self.ref_path(&name);
1178        let parent = path.parent().ok_or(RefDeleteError::InvalidName)?;
1179        fs::create_dir_all(parent).map_err(RefDeleteError::from)?;
1180
1181        let loose_lock_path = lock_path_for(&path).map_err(|_| RefDeleteError::InvalidName)?;
1182        let _prune_guard = RefDirPruneGuard {
1183            store: self,
1184            name: name.clone(),
1185        };
1186        let loose_lock = DeleteLock::acquire(loose_lock_path)?;
1187
1188        let packed_path = self.common_dir.join("packed-refs");
1189        let packed_lock_path =
1190            lock_path_for(&packed_path).map_err(|_| RefDeleteError::InvalidName)?;
1191        let mut packed_lock = DeleteLock::acquire(packed_lock_path)?;
1192
1193        let loose_ref = self
1194            .read_loose_ref(&name)
1195            .map_err(ref_delete_error_from_git)?;
1196        let packed_original = match fs::read(&packed_path) {
1197            Ok(bytes) => Some(bytes),
1198            Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
1199            Err(err) => return Err(RefDeleteError::Io(err)),
1200        };
1201        let mut packed_refs = match &packed_original {
1202            Some(bytes) => {
1203                parse_packed_refs(self.format, bytes).map_err(ref_delete_error_from_git)?
1204            }
1205            None => Vec::new(),
1206        };
1207        let packed_index = packed_refs
1208            .iter()
1209            .position(|reference| reference.reference.name == name);
1210
1211        let current = if let Some(reference) = loose_ref.as_ref() {
1212            Some(reference.target.clone())
1213        } else {
1214            packed_index.map(|index| packed_refs[index].reference.target.clone())
1215        };
1216        let oid = checked_delete_oid(delete.expected_old, current)?;
1217
1218        let packed_changed = if let Some(index) = packed_index {
1219            packed_refs.remove(index);
1220            true
1221        } else {
1222            false
1223        };
1224
1225        if packed_changed {
1226            let packed_bytes =
1227                write_packed_refs(&packed_refs).map_err(ref_delete_error_from_git)?;
1228            packed_lock.write_all(&packed_bytes)?;
1229            let lock_path = packed_lock.close();
1230            if let Err(err) = fs::rename(&lock_path, &packed_path) {
1231                let _ = fs::remove_file(&lock_path);
1232                return Err(RefDeleteError::Io(err));
1233            }
1234        } else {
1235            packed_lock.remove();
1236        }
1237
1238        if loose_ref.is_some()
1239            && let Err(err) = fs::remove_file(&path)
1240        {
1241            if packed_changed && let Some(bytes) = packed_original.as_ref() {
1242                let _ = restore_file_atomically(&packed_path, bytes);
1243            }
1244            return Err(RefDeleteError::Io(err));
1245        }
1246        loose_lock.remove();
1247
1248        // Git unlinks logs/refs/<name> on delete (pruning now-empty dirs); it
1249        // does not keep a deletion entry. Mirror delete_ref / delete_branch.
1250        self.remove_reflog_file(&name);
1251        Ok(RefDelete { name, oid })
1252    }
1253
1254    fn read_loose_ref(&self, name: &str) -> Result<Option<Ref>> {
1255        let path = self.ref_path(name);
1256        if !path.exists() {
1257            return Ok(None);
1258        }
1259        if path.is_dir() {
1260            return Ok(None);
1261        }
1262        Ok(Some(parse_loose_ref(self.format, name, &fs::read(path)?)?))
1263    }
1264
1265    fn read_packed_ref(&self, name: &str) -> Result<Option<PackedRef>> {
1266        let path = self.common_dir.join("packed-refs");
1267        if !path.exists() {
1268            return Ok(None);
1269        }
1270        Ok(parse_packed_refs(self.format, &fs::read(path)?)?
1271            .into_iter()
1272            .find(|reference| reference.reference.name == name))
1273    }
1274
1275    fn read_reftable_ref(&self, name: &str) -> Result<Option<RefTarget>> {
1276        for table in self.reftables()?.into_iter().rev() {
1277            if let Some(record) = table.refs.into_iter().find(|record| record.name == name) {
1278                return reftable_ref_target(record.value);
1279            }
1280        }
1281        Ok(None)
1282    }
1283
1284    fn list_reftable_refs(&self) -> Result<Vec<Ref>> {
1285        let mut refs = BTreeMap::<String, Ref>::new();
1286        for table in self.reftables()? {
1287            for record in table.refs {
1288                if !record.name.starts_with("refs/") {
1289                    continue;
1290                }
1291                match reftable_ref_target(record.value)? {
1292                    Some(target) => {
1293                        refs.insert(
1294                            record.name.clone(),
1295                            Ref {
1296                                name: record.name,
1297                                target,
1298                            },
1299                        );
1300                    }
1301                    None => {
1302                        refs.remove(&record.name);
1303                    }
1304                }
1305            }
1306        }
1307        Ok(refs.into_values().collect())
1308    }
1309
1310    fn reftables(&self) -> Result<Vec<Reftable>> {
1311        let reftable_dir = self.common_dir.join("reftable");
1312        let tables_list = reftable_dir.join("tables.list");
1313        if !tables_list.exists() {
1314            return Ok(Vec::new());
1315        }
1316        let text = fs::read_to_string(&tables_list)?;
1317        let mut tables = Vec::new();
1318        for raw_line in text.lines() {
1319            let line = raw_line.trim();
1320            if line.is_empty() {
1321                continue;
1322            }
1323            if line.contains('/')
1324                || line.contains('\\')
1325                || Path::new(line).components().count() != 1
1326            {
1327                return Err(GitError::InvalidPath(format!(
1328                    "invalid reftable table name {line}"
1329                )));
1330            }
1331            let table = Reftable::parse(&fs::read(reftable_dir.join(line))?)?;
1332            if table.header.object_format != self.format {
1333                return Err(GitError::InvalidFormat(format!(
1334                    "reftable {line} has {} object ids in {} repository",
1335                    table.header.object_format.name(),
1336                    self.format.name()
1337                )));
1338            }
1339            tables.push(table);
1340        }
1341        Ok(tables)
1342    }
1343
1344    fn uses_reftable(&self) -> Result<bool> {
1345        let config_path = self.common_dir.join("config");
1346        if !config_path.exists() {
1347            return Ok(false);
1348        }
1349        let config = GitConfig::parse(&fs::read(config_path)?)?;
1350        Ok(matches!(
1351            config.get("extensions", None, "refStorage"),
1352            Some(value) if value.eq_ignore_ascii_case("reftable")
1353        ))
1354    }
1355
1356    fn append_reftable_records(&self, records: Vec<ReftableRefRecord>) -> Result<()> {
1357        if records.is_empty() {
1358            return Ok(());
1359        }
1360        self.append_reftable_table(records, Vec::new())?;
1361        Ok(())
1362    }
1363
1364    fn next_reftable_update_index(&self, table_names: &[String]) -> Result<u64> {
1365        let reftable_dir = self.common_dir.join("reftable");
1366        let mut max_update_index = 0;
1367        for name in table_names {
1368            let table = Reftable::parse(&fs::read(reftable_dir.join(name))?)?;
1369            max_update_index = max_update_index.max(table.header.max_update_index);
1370        }
1371        max_update_index
1372            .checked_add(1)
1373            .ok_or_else(|| GitError::InvalidFormat("reftable update index overflow".into()))
1374    }
1375
1376    /// Read the table list (file names) backing the reftable stack, oldest first.
1377    fn reftable_table_names(&self) -> Result<Vec<String>> {
1378        let tables_list = self.common_dir.join("reftable").join("tables.list");
1379        if !tables_list.exists() {
1380            return Ok(Vec::new());
1381        }
1382        Ok(fs::read_to_string(&tables_list)?
1383            .lines()
1384            .map(str::trim)
1385            .filter(|line| !line.is_empty())
1386            .map(str::to_string)
1387            .collect())
1388    }
1389
1390    /// Append a single combined ref+log table to the stack, allocating the next
1391    /// update index. Either slice may be empty (a log-only table for an
1392    /// `append_reflog` / `delete-reflog`, or a ref-only table for a plain ref
1393    /// write). Returns the allocated update index.
1394    fn append_reftable_table(
1395        &self,
1396        mut refs: Vec<ReftableRefRecord>,
1397        mut logs: Vec<ReftableLogRecord>,
1398    ) -> Result<u64> {
1399        let reftable_dir = self.common_dir.join("reftable");
1400        fs::create_dir_all(&reftable_dir)?;
1401        let mut table_names = self.reftable_table_names()?;
1402        let update_index = self.next_reftable_update_index(&table_names)?;
1403        for record in &mut refs {
1404            record.update_index = update_index;
1405        }
1406        for record in &mut logs {
1407            record.update_index = update_index;
1408        }
1409        let table_name = reftable_table_name(update_index, update_index);
1410        let bytes = Reftable::write(self.format, update_index, update_index, &refs, &logs)?;
1411        write_locked(&reftable_dir.join(&table_name), &bytes)?;
1412        table_names.push(table_name);
1413        let mut list = Vec::new();
1414        for name in &table_names {
1415            list.extend_from_slice(name.as_bytes());
1416            list.push(b'\n');
1417        }
1418        write_locked(&reftable_dir.join("tables.list"), &list)?;
1419        Ok(update_index)
1420    }
1421
1422    /// Merge the log records for `name` across the whole stack into the reflog
1423    /// entries `git reflog` expects, in *oldest-first* order (the loose-file
1424    /// order callers reverse for display). Later tables in the stack override
1425    /// earlier ones for the same `(refname, update_index)`, deletions mask
1426    /// entries, and the old==new==null existence marker is dropped (it records
1427    /// reflog existence, not a real entry).
1428    fn read_reftable_logs(&self, name: &str) -> Result<Vec<ReflogEntry>> {
1429        // Collect the newest value for each update_index, honoring deletions.
1430        // update_index ascending == chronological order.
1431        let mut by_index: BTreeMap<u64, Option<ReftableLogUpdate>> = BTreeMap::new();
1432        for table in self.reftables()? {
1433            for record in table.logs {
1434                if record.refname != name {
1435                    continue;
1436                }
1437                match record.value {
1438                    ReftableLogValue::Deletion => {
1439                        by_index.insert(record.update_index, None);
1440                    }
1441                    ReftableLogValue::Update(update) => {
1442                        by_index.insert(record.update_index, Some(update));
1443                    }
1444                }
1445            }
1446        }
1447        let null = ObjectId::null(self.format);
1448        let mut entries = Vec::new();
1449        for update in by_index.into_values().flatten() {
1450            // Drop the existence marker (old==new==null): it is not a real entry.
1451            if update.old_oid == null && update.new_oid == null {
1452                continue;
1453            }
1454            entries.push(reflog_entry_from_reftable(update));
1455        }
1456        Ok(entries)
1457    }
1458
1459
1460    fn collect_loose_refs(
1461        &self,
1462        dir: &Path,
1463        prefix: &str,
1464        refs: &mut BTreeMap<String, Ref>,
1465    ) -> Result<()> {
1466        for entry in fs::read_dir(dir)? {
1467            let entry = entry?;
1468            let path = entry.path();
1469            let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
1470            if path.is_dir() {
1471                self.collect_loose_refs(&path, &name, refs)?;
1472            } else if !name.ends_with(".lock") {
1473                let reference = parse_loose_ref(self.format, name.clone(), &fs::read(path)?)?;
1474                refs.insert(name, reference);
1475            }
1476        }
1477        Ok(())
1478    }
1479
1480    fn loose_refs_have_prefix(&self, prefix: &str) -> Result<bool> {
1481        if !prefix.starts_with("refs/") || !prefix.ends_with('/') {
1482            return Ok(self
1483                .list_refs()?
1484                .iter()
1485                .any(|reference| reference.name.starts_with(prefix)));
1486        }
1487        let loose_prefix = prefix.trim_end_matches('/');
1488        let dir = self.common_dir.join(loose_prefix);
1489        match fs::metadata(&dir) {
1490            Ok(meta) if meta.is_dir() => {
1491                let mut refs = BTreeMap::new();
1492                self.collect_loose_refs(&dir, loose_prefix, &mut refs)?;
1493                Ok(!refs.is_empty())
1494            }
1495            Ok(_) => Ok(false),
1496            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
1497            Err(err) => Err(err.into()),
1498        }
1499    }
1500
1501    fn write_loose_ref(&self, reference: &Ref) -> Result<()> {
1502        if self.uses_reftable()? {
1503            self.append_reftable_records(vec![ReftableRefRecord {
1504                name: reference.name.clone(),
1505                update_index: 0,
1506                value: reftable_value_from_ref_target(&reference.target),
1507            }])?;
1508            return Ok(());
1509        }
1510        let path = self.ref_path(&reference.name);
1511        let parent = path
1512            .parent()
1513            .ok_or_else(|| GitError::InvalidPath("ref path has no parent".into()))?;
1514        fs::create_dir_all(parent)?;
1515        write_locked(&path, &write_loose_ref(reference))
1516    }
1517
1518    fn delete_loose_ref(&self, name: &str) -> Result<()> {
1519        let path = self.ref_path(name);
1520        let lock_path = lock_path_for(&path)?;
1521        {
1522            let mut file = fs::OpenOptions::new()
1523                .write(true)
1524                .create_new(true)
1525                .open(&lock_path)?;
1526            file.write_all(b"delete\n")?;
1527            file.sync_all()?;
1528        }
1529        match fs::remove_file(&path) {
1530            Ok(()) => {
1531                fs::remove_file(lock_path)?;
1532                self.prune_empty_ref_dirs(name);
1533                Ok(())
1534            }
1535            Err(err) => {
1536                let _ = fs::remove_file(lock_path);
1537                Err(GitError::Io(err.to_string()))
1538            }
1539        }
1540    }
1541
1542    /// Remove now-empty parent directories left after deleting a loose ref,
1543    /// stopping at the `refs/` boundary. git does this so that, e.g., deleting
1544    /// `refs/heads/l/m` lets `refs/heads/l` be created as a file afterwards
1545    /// (t3200 #14). Pruning stops at the first non-empty directory and never
1546    /// removes the `refs` directory itself.
1547    fn prune_empty_ref_dirs(&self, name: &str) {
1548        let base = self.ref_base_dir(name).to_path_buf();
1549        let refs_root = base.join("refs");
1550        if let Some(parent) = self.ref_path(name).parent() {
1551            prune_empty_dirs_up_to(parent, &refs_root);
1552        }
1553    }
1554
1555    /// Remove a ref's reflog file and prune any empty parent directories it
1556    /// leaves behind under `logs/refs/`, stopping at the `logs/refs` boundary.
1557    /// Without this, deleting `refs/heads/l/m` leaves `logs/refs/heads/l/` and a
1558    /// later `refs/heads/l` cannot create its own `logs/refs/heads/l` reflog
1559    /// file (t3200 #14, #18).
1560    fn remove_reflog_file(&self, name: &str) {
1561        // Reftable repos keep the reflog inside the table stack, not as a loose
1562        // file: deleting a ref must tombstone its log records or `git stash
1563        // list` / `git reflog` keep surfacing them (t0610 'basic: stash').
1564        if matches!(self.uses_reftable(), Ok(true)) {
1565            let _ = self.tombstone_reftable_logs(name);
1566            return;
1567        }
1568        let path = self.reflog_path(name);
1569        let _ = fs::remove_file(&path);
1570        let base = self.ref_base_dir(name).to_path_buf();
1571        let logs_refs_root = base.join("logs").join("refs");
1572        if let Some(parent) = path.parent() {
1573            prune_empty_dirs_up_to(parent, &logs_refs_root);
1574        }
1575    }
1576
1577    /// Mask every live log record for `name` with deletion tombstones, so the
1578    /// reflog reads as absent. Mirrors git unlinking `logs/<name>` on the loose
1579    /// backend; the reftable analogue is an all-tombstone table.
1580    fn tombstone_reftable_logs(&self, name: &str) -> Result<()> {
1581        self.rewrite_reftable_logs(name, &[])
1582    }
1583
1584    /// Mirror git's `files_log_ref_write`: when a transaction updates a branch
1585    /// that `HEAD` symbolically points at, the same reflog entry is also written
1586    /// to `logs/HEAD`. Without this, `git reflog` (which reads the HEAD reflog)
1587    /// misses commits/merges/resets done on the checked-out branch.
1588    ///
1589    /// `head_branch` is HEAD's symref target captured **before** the transaction
1590    /// mutated any refs — using the post-apply value would mis-mirror a
1591    /// transaction that re-points HEAD onto the branch it just updated (e.g.
1592    /// rebase's finish step, which manages `logs/HEAD` itself). When the
1593    /// transaction explicitly updates `HEAD` it owns the HEAD reflog and nothing
1594    /// is mirrored.
1595    fn head_reflog_mirror(
1596        head_branch: Option<&str>,
1597        reflogs: &[(String, ReflogEntry)],
1598    ) -> Vec<(String, ReflogEntry)> {
1599        let Some(head_branch) = head_branch else {
1600            return Vec::new();
1601        };
1602        // A transaction that touches HEAD directly is managing the HEAD reflog
1603        // on its own terms (detach, rebase finish, checkout); don't double-write.
1604        if reflogs.iter().any(|(name, _)| name == "HEAD") {
1605            return Vec::new();
1606        }
1607        reflogs
1608            .iter()
1609            .filter(|(name, _)| name == head_branch)
1610            .map(|(_, entry)| ("HEAD".to_string(), entry.clone()))
1611            .collect()
1612    }
1613
1614    /// HEAD's symref target (`refs/heads/<branch>`) if HEAD is symbolic, else
1615    /// `None`. Read once at the start of a transaction commit so the HEAD-reflog
1616    /// mirror reflects the pre-transaction state.
1617    fn head_symref_target(&self) -> Option<String> {
1618        match self.read_ref("HEAD") {
1619            Ok(Some(RefTarget::Symbolic(branch))) => Some(branch),
1620            _ => None,
1621        }
1622    }
1623
1624    pub fn append_reflog(&self, name: &str, entry: &ReflogEntry) -> Result<()> {
1625        validate_ref_name_for_read(name)?;
1626        if self.uses_reftable()? {
1627            let update = reftable_update_from_reflog(entry)?;
1628            self.append_reftable_table(
1629                Vec::new(),
1630                vec![ReftableLogRecord {
1631                    refname: name.to_string(),
1632                    update_index: 0,
1633                    value: ReftableLogValue::Update(update),
1634                }],
1635            )?;
1636            return Ok(());
1637        }
1638        let path = self.reflog_path(name);
1639        let parent = path
1640            .parent()
1641            .ok_or_else(|| GitError::InvalidPath("reflog path has no parent".into()))?;
1642        fs::create_dir_all(parent)?;
1643        let mut file = fs::OpenOptions::new()
1644            .create(true)
1645            .append(true)
1646            .open(path)?;
1647        file.write_all(&entry.to_line())?;
1648        file.sync_all()?;
1649        Ok(())
1650    }
1651
1652    /// Replace the entire reflog for `name` in the reftable stack with `entries`
1653    /// (chronological, oldest first). Writes a single new table that tombstones
1654    /// every currently-live log update index for `name` and re-adds the desired
1655    /// entries at fresh update indexes that preserve their order. This is the
1656    /// stack-friendly analogue of rewriting a loose `logs/<name>` file — used by
1657    /// `write_reflog` / reflog expiry. An empty `entries` slice clears the
1658    /// reflog (an empty `git reflog`).
1659    fn rewrite_reftable_logs(&self, name: &str, entries: &[ReflogEntry]) -> Result<()> {
1660        // Gather every update index that currently carries a live log record for
1661        // `name`, so we can mask them with deletion tombstones.
1662        let mut live_indexes: BTreeSet<u64> = BTreeSet::new();
1663        let mut deleted_indexes: BTreeSet<u64> = BTreeSet::new();
1664        for table in self.reftables()? {
1665            for record in table.logs {
1666                if record.refname != name {
1667                    continue;
1668                }
1669                match record.value {
1670                    ReftableLogValue::Deletion => {
1671                        deleted_indexes.insert(record.update_index);
1672                        live_indexes.remove(&record.update_index);
1673                    }
1674                    ReftableLogValue::Update(_) => {
1675                        live_indexes.insert(record.update_index);
1676                        deleted_indexes.remove(&record.update_index);
1677                    }
1678                }
1679            }
1680        }
1681
1682        let table_names = self.reftable_table_names()?;
1683        let base = self.next_reftable_update_index(&table_names)?;
1684        let mut logs: Vec<ReftableLogRecord> = Vec::new();
1685        // Tombstone the old entries at their original update indexes.
1686        for index in &live_indexes {
1687            logs.push(ReftableLogRecord {
1688                refname: name.to_string(),
1689                update_index: *index,
1690                value: ReftableLogValue::Deletion,
1691            });
1692        }
1693        // Re-add the survivors at fresh, monotonically increasing indexes so
1694        // their chronological order is preserved on the next read.
1695        for (offset, entry) in entries.iter().enumerate() {
1696            let update_index = base
1697                .checked_add(offset as u64)
1698                .ok_or_else(|| GitError::InvalidFormat("reftable update index overflow".into()))?;
1699            logs.push(ReftableLogRecord {
1700                refname: name.to_string(),
1701                update_index,
1702                value: ReftableLogValue::Update(reftable_update_from_reflog(entry)?),
1703            });
1704        }
1705        if logs.is_empty() {
1706            return Ok(());
1707        }
1708        self.append_reftable_table_spanning(Vec::new(), logs)?;
1709        Ok(())
1710    }
1711
1712    /// Like [`Self::append_reftable_table`] but the caller has already assigned
1713    /// each record's `update_index`; the new table's header `[min, max]` is set
1714    /// to span them (plus the freshly allocated index for any ref records). Used
1715    /// by reflog rewrites that mix old-index tombstones with new-index entries.
1716    fn append_reftable_table_spanning(
1717        &self,
1718        mut refs: Vec<ReftableRefRecord>,
1719        logs: Vec<ReftableLogRecord>,
1720    ) -> Result<u64> {
1721        let reftable_dir = self.common_dir.join("reftable");
1722        fs::create_dir_all(&reftable_dir)?;
1723        let mut table_names = self.reftable_table_names()?;
1724        let alloc_index = self.next_reftable_update_index(&table_names)?;
1725        for record in &mut refs {
1726            record.update_index = alloc_index;
1727        }
1728        let mut min_index = alloc_index;
1729        let mut max_index = alloc_index;
1730        for record in &logs {
1731            min_index = min_index.min(record.update_index);
1732            max_index = max_index.max(record.update_index);
1733        }
1734        for record in &refs {
1735            min_index = min_index.min(record.update_index);
1736            max_index = max_index.max(record.update_index);
1737        }
1738        let table_name = reftable_table_name(min_index, max_index);
1739        let bytes = Reftable::write(self.format, min_index, max_index, &refs, &logs)?;
1740        write_locked(&reftable_dir.join(&table_name), &bytes)?;
1741        table_names.push(table_name);
1742        let mut list = Vec::new();
1743        for name in &table_names {
1744            list.extend_from_slice(name.as_bytes());
1745            list.push(b'\n');
1746        }
1747        write_locked(&reftable_dir.join("tables.list"), &list)?;
1748        Ok(max_index)
1749    }
1750
1751    fn ref_path(&self, name: &str) -> PathBuf {
1752        self.ref_base_dir(name).join(name)
1753    }
1754
1755    fn reflog_path(&self, name: &str) -> PathBuf {
1756        self.ref_base_dir(name).join("logs").join(name)
1757    }
1758
1759    fn ref_base_dir(&self, name: &str) -> &Path {
1760        if name == "HEAD" {
1761            &self.git_dir
1762        } else {
1763            &self.common_dir
1764        }
1765    }
1766
1767    fn check_ref_directory_conflict(&self, name: &str) -> Result<()> {
1768        let components = name.split('/').collect::<Vec<_>>();
1769        for index in 1..components.len() {
1770            let ancestor = components[..index].join("/");
1771            if self.read_ref_unchecked(&ancestor)?.is_some() {
1772                return Err(ref_directory_conflict_error(name, &ancestor));
1773            }
1774        }
1775        let child_prefix = format!("{name}/");
1776        for reference in self.list_refs()? {
1777            if reference.name.starts_with(&child_prefix) {
1778                return Err(ref_directory_conflict_error(name, &reference.name));
1779            }
1780        }
1781        Ok(())
1782    }
1783}
1784
1785fn reftable_ref_target(value: ReftableRefValue) -> Result<Option<RefTarget>> {
1786    match value {
1787        ReftableRefValue::Deletion => Ok(None),
1788        ReftableRefValue::Direct(oid) | ReftableRefValue::Peeled { target: oid, .. } => {
1789            Ok(Some(RefTarget::Direct(oid)))
1790        }
1791        ReftableRefValue::Symbolic(target) => Ok(Some(RefTarget::Symbolic(target))),
1792    }
1793}
1794
1795fn reftable_value_from_ref_target(target: &RefTarget) -> ReftableRefValue {
1796    match target {
1797        RefTarget::Direct(oid) => ReftableRefValue::Direct(*oid),
1798        RefTarget::Symbolic(target) => ReftableRefValue::Symbolic(target.clone()),
1799    }
1800}
1801
1802/// Reconstruct a `ReflogEntry`'s flat committer line from a reftable log update.
1803///
1804/// git's reftable backend stores the identity split into `name`/`email`/`time`/
1805/// `tz_offset` (refs/reftable-backend.c::fill_reftable_log_record) and rebuilds
1806/// `Name <email> time tz` on read. The loose reflog committer field is exactly
1807/// that string, so the entry round-trips byte-for-byte with the loose backend.
1808fn reflog_entry_from_reftable(update: ReftableLogUpdate) -> ReflogEntry {
1809    let committer = format!(
1810        "{} <{}> {} {}",
1811        update.name,
1812        update.email,
1813        update.time,
1814        format_reflog_tz(update.tz_offset),
1815    );
1816    // git stores reflog messages with a trailing newline in the reftable record
1817    // (refs/reftable-backend.c passes `u->msg`, which carries the `\n`). A loose
1818    // `ReflogEntry.message` is the newline-free form, so strip the single
1819    // trailing `\n` we add on write.
1820    let mut message = update.message.into_bytes();
1821    if message.last() == Some(&b'\n') {
1822        message.pop();
1823    }
1824    ReflogEntry {
1825        old_oid: update.old_oid,
1826        new_oid: update.new_oid,
1827        committer: committer.into_bytes(),
1828        message,
1829    }
1830}
1831
1832/// Split a flat reflog committer line (`Name <email> <seconds> <±HHMM>`) plus the
1833/// entry's oids/message into the reftable log update fields.
1834fn reftable_update_from_reflog(entry: &ReflogEntry) -> Result<ReftableLogUpdate> {
1835    let committer = std::str::from_utf8(&entry.committer)
1836        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
1837    let (name, email, time, tz_offset) = split_committer_ident(committer)?;
1838    let mut message = std::str::from_utf8(&entry.message)
1839        .map_err(|err| GitError::InvalidFormat(err.to_string()))?
1840        .to_string();
1841    // git stores reflog messages with a trailing newline in reftable records;
1842    // `%gs` and the loose backend strip it. Add it back so git renders the
1843    // message identically across backends.
1844    if !message.ends_with('\n') {
1845        message.push('\n');
1846    }
1847    Ok(ReftableLogUpdate {
1848        old_oid: entry.old_oid,
1849        new_oid: entry.new_oid,
1850        name,
1851        email,
1852        time,
1853        tz_offset,
1854        message,
1855    })
1856}
1857
1858/// Parse `Name <email> <seconds> <±HHMM>` into the reftable log fields. Mirrors
1859/// git's `split_ident_line` semantics for the committer line: the display name
1860/// is everything before ` <`, the email is between the angle brackets, then the
1861/// unix timestamp and the signed `HHMM` timezone follow.
1862fn split_committer_ident(committer: &str) -> Result<(String, String, u64, i16)> {
1863    let open = committer.find(" <").ok_or_else(|| {
1864        GitError::InvalidFormat("reflog committer is missing email opener".into())
1865    })?;
1866    let name = committer[..open].to_string();
1867    let after_open = open + 2;
1868    let close = committer[after_open..].find('>').ok_or_else(|| {
1869        GitError::InvalidFormat("reflog committer is missing email closer".into())
1870    })?;
1871    let email = committer[after_open..after_open + close].to_string();
1872    let rest = committer[after_open + close + 1..].trim();
1873    let (time_str, tz_str) = rest.split_once(' ').ok_or_else(|| {
1874        GitError::InvalidFormat("reflog committer is missing timestamp/timezone".into())
1875    })?;
1876    let time = time_str
1877        .trim()
1878        .parse::<u64>()
1879        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
1880    let tz_offset = parse_reflog_tz(tz_str.trim())?;
1881    Ok((name, email, time, tz_offset))
1882}
1883
1884/// Format a reftable `tz_offset` (a signed `HHMM` value) as git renders it in a
1885/// committer line, e.g. `120 -> "+0200"`, `-300 -> "-0500"`.
1886fn format_reflog_tz(tz_offset: i16) -> String {
1887    let sign = if tz_offset < 0 { '-' } else { '+' };
1888    let magnitude = tz_offset.unsigned_abs();
1889    format!("{sign}{magnitude:04}")
1890}
1891
1892/// Parse a `±HHMM` timezone token into the raw signed `HHMM` value git stores in
1893/// a reftable log record (refs/reftable-backend.c parses it the same way).
1894fn parse_reflog_tz(tz: &str) -> Result<i16> {
1895    let (sign, digits) = match tz.strip_prefix('-') {
1896        Some(rest) => (-1i16, rest),
1897        None => (1i16, tz.strip_prefix('+').unwrap_or(tz)),
1898    };
1899    let magnitude = digits
1900        .parse::<i16>()
1901        .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
1902    Ok(sign * magnitude)
1903}
1904
1905/// Build a reftable file name in git's exact `0x%012x-0x%012x-%08x.ref` shape
1906/// (reftable/stack.c::format_name). git's `table_has_valid_name` (reftable/fsck.c)
1907/// parses all three dash-separated components as hex, so the suffix MUST be a
1908/// pure 8-hex-digit token — a non-hex disambiguator like `-sley-<nanos>` makes
1909/// `git fsck` reject the table with `badReftableTableName`.
1910fn reftable_table_name(min_update_index: u64, max_update_index: u64) -> String {
1911    let nanos = SystemTime::now()
1912        .duration_since(UNIX_EPOCH)
1913        .map(|duration| duration.as_nanos())
1914        .unwrap_or(0);
1915    // Mix the process id in so concurrent writers in the same nanosecond still
1916    // pick distinct names; truncate to 32 bits to match git's `%08x`.
1917    let salt = (nanos as u64) ^ (u64::from(std::process::id()) << 16);
1918    format!("0x{min_update_index:012x}-0x{max_update_index:012x}-{:08x}.ref", salt as u32)
1919}
1920
1921/// Whether `name` parses as a valid reftable file name the way git's
1922/// `table_has_valid_name` (reftable/fsck.c) does: three hex tokens separated by
1923/// `-`, ending in `.ref` (or `.log`). Used to keep sley's generated names from
1924/// regressing into a shape `git fsck` would reject.
1925#[cfg(test)]
1926fn reftable_table_name_is_valid(name: &str) -> bool {
1927    fn hex_prefix(s: &str) -> Option<&str> {
1928        // strtoull(base 16) skips an optional leading 0x and consumes hex digits.
1929        let body = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
1930        let consumed = body.find(|c: char| !c.is_ascii_hexdigit()).unwrap_or(body.len());
1931        if consumed == 0 {
1932            return None;
1933        }
1934        Some(&body[consumed..])
1935    }
1936    let Some(rest) = hex_prefix(name) else {
1937        return false;
1938    };
1939    let Some(rest) = rest.strip_prefix('-') else {
1940        return false;
1941    };
1942    let Some(rest) = hex_prefix(rest) else {
1943        return false;
1944    };
1945    let Some(rest) = rest.strip_prefix('-') else {
1946        return false;
1947    };
1948    let Some(rest) = hex_prefix(rest) else {
1949        return false;
1950    };
1951    rest == ".ref" || rest == ".log"
1952}
1953
1954fn repository_common_dir(git_dir: &Path) -> PathBuf {
1955    if let Some(common_dir) = std::env::var_os("GIT_COMMON_DIR") {
1956        return PathBuf::from(common_dir);
1957    }
1958    let commondir = git_dir.join("commondir");
1959    if let Ok(value) = fs::read_to_string(&commondir) {
1960        let path = PathBuf::from(value.trim());
1961        let common = if path.is_absolute() {
1962            path
1963        } else {
1964            git_dir.join(path)
1965        };
1966        return fs::canonicalize(&common).unwrap_or(common);
1967    }
1968    git_dir.to_path_buf()
1969}
1970
1971/// The phase a [`ReferenceTransactionHook`] is invoked for, mirroring the
1972/// `state` argument git passes to the `reference-transaction` hook
1973/// (`refs.c:run_transaction_hook`).
1974#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1975pub enum RefTransactionPhase {
1976    /// Before references are locked. A nonzero hook exit aborts the
1977    /// transaction with `in 'preparing' phase, update aborted ...`.
1978    Preparing,
1979    /// After references are locked but before they are written. A nonzero
1980    /// hook exit aborts with `in 'prepared' phase, update aborted ...`.
1981    Prepared,
1982    /// After every ref change has landed. The hook's exit status is ignored.
1983    Committed,
1984    /// When a prepared transaction is rolled back. The exit status is ignored.
1985    Aborted,
1986}
1987
1988impl RefTransactionPhase {
1989    /// The literal `state` string git feeds as `argv[1]` to the hook.
1990    pub fn as_str(self) -> &'static str {
1991        match self {
1992            RefTransactionPhase::Preparing => "preparing",
1993            RefTransactionPhase::Prepared => "prepared",
1994            RefTransactionPhase::Committed => "committed",
1995            RefTransactionPhase::Aborted => "aborted",
1996        }
1997    }
1998}
1999
2000/// One queued ref change as the `reference-transaction` hook sees it: the
2001/// `<old-value> SP <new-value> SP <refname>` triple git writes to the hook's
2002/// stdin (`refs.c:transaction_hook_feed_stdin`). `old_value`/`new_value` are
2003/// already rendered the way git renders them — a 40/64-hex OID, the string
2004/// `ref:<target>` for a symref, or the all-zeros OID when the side is absent.
2005#[derive(Clone, Debug)]
2006pub struct RefTransactionHookUpdate {
2007    pub old_value: String,
2008    pub new_value: String,
2009    pub refname: String,
2010}
2011
2012/// A handler the file backend invokes at each phase of a ref transaction so the
2013/// CLI layer can run the project's `reference-transaction` hook. Implemented in
2014/// `sley-cli`; the backend stays oblivious to how (or whether) a hook script is
2015/// found and executed.
2016///
2017/// `run` returns `Ok(true)` to mean "the hook ran and requested an abort"
2018/// (nonzero exit in a `preparing`/`prepared` phase), `Ok(false)` to mean
2019/// "proceed" (hook absent, succeeded, or a non-abortable phase), and `Err` only
2020/// for an I/O failure spawning the hook. The backend turns an abort request in
2021/// the prepare phases into the git-shaped `in '<phase>' phase, update aborted by
2022/// the reference-transaction hook` failure.
2023pub trait ReferenceTransactionHook {
2024    fn run(
2025        &self,
2026        phase: RefTransactionPhase,
2027        updates: &[RefTransactionHookUpdate],
2028    ) -> Result<bool>;
2029}
2030
2031pub struct FileRefTransaction<'a> {
2032    store: &'a FileRefStore,
2033    changes: Vec<QueuedRefChange>,
2034    hook: Option<&'a dyn ReferenceTransactionHook>,
2035}
2036
2037/// One queued update inside a [`FileRefTransaction`], carrying the
2038/// compare-and-swap precondition to enforce under lock.
2039struct QueuedUpdate {
2040    name: String,
2041    precondition: RefPrecondition,
2042    new: RefTarget,
2043    reflog: Option<ReflogEntry>,
2044}
2045
2046struct QueuedDelete {
2047    name: String,
2048    precondition: RefDeletePrecondition,
2049}
2050
2051enum QueuedRefChange {
2052    Update(QueuedUpdate),
2053    Delete(QueuedDelete),
2054}
2055
2056/// The compare-and-delete precondition checked for a queued ref delete.
2057#[derive(Debug, Clone, PartialEq, Eq)]
2058pub enum RefDeletePrecondition {
2059    /// Any existing direct or symbolic ref may be deleted.
2060    Any,
2061    /// The ref's immediate target must match exactly.
2062    Immediate(RefTarget),
2063    /// The ref must be direct. When an object id is supplied, it must match.
2064    Direct(Option<ObjectId>),
2065    /// The ref may be symbolic, but its peeled direct target must match.
2066    Peeled(ObjectId),
2067}
2068
2069impl<'a> FileRefTransaction<'a> {
2070    /// Attach the `reference-transaction` hook handler this transaction fires at
2071    /// each phase. Without one the transaction behaves exactly as before (no
2072    /// hook is run). This is the single point through which every ref-write path
2073    /// — `update-ref`, `symbolic-ref`, `update-ref --stdin`, push — gets hook
2074    /// coverage, so a new write site cannot silently skip the hook.
2075    pub fn with_hook(mut self, hook: &'a dyn ReferenceTransactionHook) -> Self {
2076        self.hook = Some(hook);
2077        self
2078    }
2079
2080    /// Queue a ref update whose precondition comes from [`RefUpdate::expected`]
2081    /// (`None` = no check; `Some(target)` = the ref must currently match
2082    /// `target`). For create-only or match-or-create semantics use
2083    /// [`update_to`](FileRefTransaction::update_to).
2084    pub fn update(&mut self, update: RefUpdate) {
2085        self.changes.push(QueuedRefChange::Update(QueuedUpdate {
2086            name: update.name,
2087            precondition: RefPrecondition::from_expected(update.expected),
2088            new: update.new,
2089            reflog: update.reflog,
2090        }));
2091    }
2092
2093    /// Queue a ref update with an explicit compare-and-swap [`RefPrecondition`]
2094    /// (e.g. [`MustNotExist`](RefPrecondition::MustNotExist) for create-only, or
2095    /// [`ExistingMustMatch`](RefPrecondition::ExistingMustMatch) for
2096    /// match-or-create). The precondition is re-verified while the ref is
2097    /// locked.
2098    pub fn update_to(
2099        &mut self,
2100        name: impl Into<String>,
2101        new: RefTarget,
2102        precondition: RefPrecondition,
2103        reflog: Option<ReflogEntry>,
2104    ) {
2105        self.changes.push(QueuedRefChange::Update(QueuedUpdate {
2106            name: name.into(),
2107            precondition,
2108            new,
2109            reflog,
2110        }));
2111    }
2112
2113    /// Queue a direct ref delete using the historical checked-delete shape.
2114    ///
2115    /// `expected_old = None` means "delete any direct ref"; `Some(oid)` means
2116    /// the direct ref must currently point at that object id.
2117    pub fn delete(&mut self, delete: DeleteRef) {
2118        self.delete_with_precondition(
2119            delete.name,
2120            RefDeletePrecondition::Direct(delete.expected_old),
2121            delete.reflog,
2122        );
2123    }
2124
2125    /// Queue a ref delete with an explicit direct/symbolic precondition.
2126    ///
2127    /// `_reflog` is accepted for API compatibility but ignored: git unlinks the
2128    /// reflog on delete rather than writing a deletion entry, so a
2129    /// caller-supplied deletion message has no on-disk effect.
2130    pub fn delete_with_precondition(
2131        &mut self,
2132        name: impl Into<String>,
2133        precondition: RefDeletePrecondition,
2134        _reflog: Option<DeleteRefReflog>,
2135    ) {
2136        self.changes.push(QueuedRefChange::Delete(QueuedDelete {
2137            name: name.into(),
2138            precondition,
2139        }));
2140    }
2141
2142    /// Commit all queued updates and deletes atomically and durably.
2143    ///
2144    /// All ref changes succeed together or none take effect. For the loose-ref
2145    /// backend the sequence is:
2146    ///
2147    /// 1. Preserve the historical update-only coalescing behavior. Mixed
2148    ///    transactions reject duplicate ref names so a delete and write cannot
2149    ///    target the same ref ambiguously.
2150    /// 2. Take an exclusive `<ref>.lock` file for every ref up front, and lock
2151    ///    `packed-refs` before checked deletes can inspect or rewrite it.
2152    /// 3. Re-verify every precondition *while holding the locks*, closing
2153    ///    the check-then-write race that a pre-lock verification would leave open.
2154    /// 4. Stage every write, delete marker, and packed-refs rewrite.
2155    /// 5. Rename/remove staged paths, rolling back already-applied paths if a
2156    ///    later step fails.
2157    ///
2158    /// If any step fails, every path already changed in this commit is restored
2159    /// to the exact bytes it held beforehand (or removed if it did not exist),
2160    /// and all outstanding lock files are deleted. Reflog entries are appended
2161    /// only after every ref change has landed.
2162    pub fn commit(self) -> Result<()> {
2163        let FileRefTransaction {
2164            store,
2165            changes,
2166            hook,
2167        } = self;
2168        let changes = coalesce_ref_changes(changes)?;
2169        // Derive the `<old> <new> <refname>` lines the reference-transaction
2170        // hook sees, in the same coalesced order the writes apply. This is the
2171        // single place the hook is fed, so loose, packed, and symref updates all
2172        // flow through one firing path (git's run_transaction_hook).
2173        let hook_updates = hook.map(|_| hook_updates_for_changes(store.format, &changes));
2174        if let (Some(hook), Some(updates)) = (hook, hook_updates.as_ref())
2175            && hook.run(RefTransactionPhase::Preparing, updates)?
2176        {
2177            return Err(ref_transaction_hook_abort(RefTransactionPhase::Preparing));
2178        }
2179        let result = if store.uses_reftable()? {
2180            store.commit_reftable(changes)
2181        } else {
2182            store.commit_loose_hooked(changes, hook, hook_updates.as_deref())
2183        };
2184        result
2185    }
2186}
2187
2188/// The git-shaped fatal raised when the `reference-transaction` hook requests an
2189/// abort in the `preparing`/`prepared` phase (`refs.c:abort_by_ref_transaction_hook`).
2190fn ref_transaction_hook_abort(phase: RefTransactionPhase) -> GitError {
2191    GitError::Transaction(format!(
2192        "in '{}' phase, update aborted by the reference-transaction hook",
2193        phase.as_str()
2194    ))
2195}
2196
2197/// Render the per-update hook lines for a coalesced change set, matching git's
2198/// `transaction_hook_feed_stdin`: the old side is `null_oid` when no old value
2199/// was required, `ref:<target>` for a symref precondition, else the expected
2200/// OID; the new side is `null_oid` for a delete, `ref:<target>` for a new
2201/// symref, else the new OID.
2202fn hook_updates_for_changes(
2203    format: ObjectFormat,
2204    changes: &[CoalescedRefChange],
2205) -> Vec<RefTransactionHookUpdate> {
2206    let zero = ObjectId::null(format).to_string();
2207    changes
2208        .iter()
2209        .map(|change| match change {
2210            CoalescedRefChange::Update(update) => RefTransactionHookUpdate {
2211                old_value: hook_old_value(&zero, &update.precondition),
2212                new_value: hook_target_value(&zero, Some(&update.new)),
2213                refname: update.name.clone(),
2214            },
2215            CoalescedRefChange::Delete(delete) => RefTransactionHookUpdate {
2216                old_value: hook_delete_old_value(&zero, &delete.precondition),
2217                new_value: zero.clone(),
2218                refname: delete.name.clone(),
2219            },
2220        })
2221        .collect()
2222}
2223
2224/// The hook's `<old-value>` for an update: git prints `null_oid` unless the
2225/// caller supplied an old value (`REF_HAVE_OLD`), in which case it is the
2226/// expected target (a `ref:` for a symref expectation, else the OID).
2227fn hook_old_value(zero: &str, precondition: &RefPrecondition) -> String {
2228    match precondition {
2229        RefPrecondition::Any | RefPrecondition::MustExist => zero.to_string(),
2230        RefPrecondition::MustNotExist => zero.to_string(),
2231        RefPrecondition::MustExistAndMatch(target)
2232        | RefPrecondition::ExistingMustMatch(target) => hook_target_value(zero, Some(target)),
2233    }
2234}
2235
2236/// The hook's `<old-value>` for a delete: git renders the supplied old OID, or
2237/// `null_oid` when none was required.
2238fn hook_delete_old_value(zero: &str, precondition: &RefDeletePrecondition) -> String {
2239    match precondition {
2240        RefDeletePrecondition::Any => zero.to_string(),
2241        RefDeletePrecondition::Immediate(target) => hook_target_value(zero, Some(target)),
2242        RefDeletePrecondition::Direct(Some(oid)) | RefDeletePrecondition::Peeled(oid) => {
2243            oid.to_string()
2244        }
2245        RefDeletePrecondition::Direct(None) => zero.to_string(),
2246    }
2247}
2248
2249/// Render a [`RefTarget`] the way git renders a hook value: `ref:<target>` for a
2250/// symref, the bare OID for a direct ref, or `null_oid` when absent.
2251fn hook_target_value(zero: &str, target: Option<&RefTarget>) -> String {
2252    match target {
2253        None => zero.to_string(),
2254        Some(RefTarget::Direct(oid)) => oid.to_string(),
2255        Some(RefTarget::Symbolic(name)) => format!("ref:{name}"),
2256    }
2257}
2258
2259impl FileRefStore {
2260    fn commit_reftable(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
2261        // Capture HEAD's symref target before any ref is mutated so the
2262        // HEAD-reflog mirror reflects the pre-transaction checked-out branch.
2263        let head_branch = self.head_symref_target();
2264        let mut records = Vec::with_capacity(changes.len());
2265        let mut reflogs = Vec::new();
2266        let mut delete_names = Vec::new();
2267        for change in changes {
2268            match change {
2269                CoalescedRefChange::Update(update) => {
2270                    if !matches!(update.precondition, RefPrecondition::Any) {
2271                        let current = self.read_ref(&update.name)?;
2272                        if !update.precondition.is_satisfied_by(current.as_ref()) {
2273                            return Err(GitError::Transaction(
2274                                update.precondition.describe(&update.name),
2275                            ));
2276                        }
2277                    }
2278                    records.push(ReftableRefRecord {
2279                        name: update.name.clone(),
2280                        update_index: 0,
2281                        value: reftable_value_from_ref_target(&update.new),
2282                    });
2283                    for entry in update.reflog {
2284                        reflogs.push((update.name.clone(), entry));
2285                    }
2286                }
2287                CoalescedRefChange::Delete(delete) => {
2288                    let current = self.read_ref(&delete.name)?;
2289                    // Enforce the precondition; git unlinks logs/refs/<name> on
2290                    // delete rather than appending a deletion reflog entry, so the
2291                    // returned OID is unused.
2292                    verify_delete_precondition(
2293                        self,
2294                        &delete.name,
2295                        current.as_ref(),
2296                        &delete.precondition,
2297                    )?;
2298                    records.push(ReftableRefRecord {
2299                        name: delete.name.clone(),
2300                        update_index: 0,
2301                        value: ReftableRefValue::Deletion,
2302                    });
2303                    delete_names.push(delete.name.clone());
2304                }
2305            }
2306        }
2307        self.append_reftable_records(records)?;
2308        // Git unlinks logs/refs/<name> (pruning now-empty dirs) on delete; do
2309        // this before appending update reflogs so a delete+recreate does not race
2310        // the new ref's reflog file.
2311        for name in &delete_names {
2312            self.remove_reflog_file(name);
2313        }
2314        let head_mirror = Self::head_reflog_mirror(head_branch.as_deref(), &reflogs);
2315        reflogs.extend(head_mirror);
2316        for (name, entry) in reflogs {
2317            self.append_reflog(&name, &entry)?;
2318        }
2319        Ok(())
2320    }
2321
2322    /// Atomic, all-or-nothing commit for the loose-ref backend. See
2323    /// [`FileRefTransaction::commit`] for the full ordering and rollback rules.
2324    fn commit_loose(&self, changes: Vec<CoalescedRefChange>) -> Result<()> {
2325        self.commit_loose_hooked(changes, None, None)
2326    }
2327
2328    /// As [`commit_loose`](Self::commit_loose) but firing the
2329    /// `reference-transaction` hook at `prepared` (after every ref is locked and
2330    /// staged, before any rename) and `committed` (after every change lands).
2331    /// A nonzero hook exit in the `prepared` phase rolls the staged changes back
2332    /// and surfaces the git-shaped abort error.
2333    fn commit_loose_hooked(
2334        &self,
2335        changes: Vec<CoalescedRefChange>,
2336        hook: Option<&dyn ReferenceTransactionHook>,
2337        hook_updates: Option<&[RefTransactionHookUpdate]>,
2338    ) -> Result<()> {
2339        // Capture HEAD's symref target before any ref is mutated so the
2340        // HEAD-reflog mirror reflects the pre-transaction checked-out branch.
2341        let head_branch = self.head_symref_target();
2342        let has_delete = changes
2343            .iter()
2344            .any(|change| matches!(change, CoalescedRefChange::Delete(_)));
2345        let mut pending = Vec::with_capacity(changes.len() + usize::from(has_delete));
2346        // Acquire every lock first; bail (releasing what we hold) on any failure.
2347        for change in &changes {
2348            let name = change.name();
2349            if matches!(change, CoalescedRefChange::Update(_))
2350                && let Err(err) = self.check_ref_directory_conflict(name)
2351            {
2352                release_pending_locks(&pending);
2353                return Err(err);
2354            }
2355            let path = self.ref_path(name);
2356            let parent = path
2357                .parent()
2358                .ok_or_else(|| GitError::InvalidPath("ref path has no parent".into()))?;
2359            if let Err(err) = fs::create_dir_all(parent) {
2360                release_pending_locks(&pending);
2361                if err.kind() == std::io::ErrorKind::NotADirectory {
2362                    return Err(ref_directory_conflict_error(
2363                        name,
2364                        &parent_to_ref_name(self.ref_base_dir(name), parent),
2365                    ));
2366                }
2367                return Err(GitError::Io(err.to_string()));
2368            }
2369            let lock_path = match lock_path_for(&path) {
2370                Ok(lock_path) => lock_path,
2371                Err(err) => {
2372                    release_pending_locks(&pending);
2373                    return Err(err);
2374                }
2375            };
2376            if let Err(err) = fs::OpenOptions::new()
2377                .write(true)
2378                .create_new(true)
2379                .open(&lock_path)
2380            {
2381                release_pending_locks(&pending);
2382                return Err(GitError::Io(format!("could not lock ref {name}: {err}")));
2383            }
2384            let action = match change {
2385                CoalescedRefChange::Update(update) => PendingPathAction::Write {
2386                    contents: write_loose_ref(&Ref {
2387                        name: update.name.clone(),
2388                        target: update.new.clone(),
2389                    }),
2390                },
2391                CoalescedRefChange::Delete(_) => PendingPathAction::Delete,
2392            };
2393            pending.push(PendingPathChange {
2394                name: name.to_string(),
2395                path,
2396                lock_path,
2397                original: None,
2398                action,
2399            });
2400        }
2401
2402        let packed_path = self.common_dir.join("packed-refs");
2403        let mut packed_refs = Vec::new();
2404        if has_delete {
2405            let packed_lock_path = match lock_path_for(&packed_path) {
2406                Ok(lock_path) => lock_path,
2407                Err(err) => {
2408                    release_pending_locks(&pending);
2409                    return Err(err);
2410                }
2411            };
2412            if let Err(err) = fs::OpenOptions::new()
2413                .write(true)
2414                .create_new(true)
2415                .open(&packed_lock_path)
2416            {
2417                release_pending_locks(&pending);
2418                return Err(GitError::Io(format!("could not lock packed-refs: {err}")));
2419            }
2420            let packed_original = match fs::read(&packed_path) {
2421                Ok(bytes) => Some(bytes),
2422                Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
2423                Err(err) => {
2424                    release_pending_locks(&pending);
2425                    let _ = fs::remove_file(&packed_lock_path);
2426                    return Err(GitError::Io(err.to_string()));
2427                }
2428            };
2429            packed_refs = match &packed_original {
2430                Some(bytes) => match parse_packed_refs(self.format, bytes) {
2431                    Ok(refs) => refs,
2432                    Err(err) => {
2433                        release_pending_locks(&pending);
2434                        let _ = fs::remove_file(&packed_lock_path);
2435                        return Err(err);
2436                    }
2437                },
2438                None => Vec::new(),
2439            };
2440            pending.push(PendingPathChange {
2441                name: "packed-refs".into(),
2442                path: packed_path.clone(),
2443                lock_path: packed_lock_path,
2444                original: packed_original,
2445                action: PendingPathAction::ReleaseLock,
2446            });
2447        }
2448
2449        // Verify expectations under lock, then capture prior on-disk state for
2450        // rollback. Mixed transactions read packed refs from the snapshot held
2451        // behind packed-refs.lock so deletes cannot race a packed rewrite.
2452        let mut reflogs = Vec::new();
2453        let mut delete_names = BTreeSet::new();
2454        for index in 0..changes.len() {
2455            match &changes[index] {
2456                CoalescedRefChange::Update(update) => {
2457                    if !matches!(update.precondition, RefPrecondition::Any) {
2458                        let current = if has_delete {
2459                            match self.read_ref_from_locked_packed(&update.name, &packed_refs) {
2460                                Ok(current) => current,
2461                                Err(err) => {
2462                                    release_pending_locks(&pending);
2463                                    return Err(err);
2464                                }
2465                            }
2466                        } else {
2467                            match self.read_ref(&update.name) {
2468                                Ok(current) => current,
2469                                Err(err) => {
2470                                    release_pending_locks(&pending);
2471                                    return Err(err);
2472                                }
2473                            }
2474                        };
2475                        if !update.precondition.is_satisfied_by(current.as_ref()) {
2476                            release_pending_locks(&pending);
2477                            return Err(GitError::Transaction(
2478                                update.precondition.describe(&update.name),
2479                            ));
2480                        }
2481                    }
2482                    pending[index].original = match read_optional_file(&pending[index].path) {
2483                        Ok(original) => original,
2484                        Err(err) => {
2485                            release_pending_locks(&pending);
2486                            return Err(err);
2487                        }
2488                    };
2489                    for entry in &update.reflog {
2490                        reflogs.push((update.name.clone(), entry.clone()));
2491                    }
2492                }
2493                CoalescedRefChange::Delete(delete) => {
2494                    let state = match self.read_locked_ref_state(&delete.name, &packed_refs) {
2495                        Ok(state) => state,
2496                        Err(err) => {
2497                            release_pending_locks(&pending);
2498                            return Err(err);
2499                        }
2500                    };
2501                    // Enforce the delete precondition under lock; the returned
2502                    // OID is unused because git unlinks logs/refs/<name> on
2503                    // delete rather than appending a deletion reflog entry.
2504                    if let Err(err) = verify_delete_precondition(
2505                        self,
2506                        &delete.name,
2507                        state.current.as_ref(),
2508                        &delete.precondition,
2509                    ) {
2510                        release_pending_locks(&pending);
2511                        return Err(err);
2512                    }
2513                    pending[index].original = if state.has_loose {
2514                        match read_optional_file(&pending[index].path) {
2515                            Ok(original) => original,
2516                            Err(err) => {
2517                                release_pending_locks(&pending);
2518                                return Err(err);
2519                            }
2520                        }
2521                    } else {
2522                        None
2523                    };
2524                    delete_names.insert(delete.name.clone());
2525                }
2526            }
2527        }
2528
2529        if has_delete {
2530            let old_len = packed_refs.len();
2531            packed_refs.retain(|reference| !delete_names.contains(&reference.reference.name));
2532            if packed_refs.len() != old_len {
2533                let packed_bytes = match write_packed_refs(&packed_refs) {
2534                    Ok(bytes) => bytes,
2535                    Err(err) => {
2536                        release_pending_locks(&pending);
2537                        return Err(err);
2538                    }
2539                };
2540                if let Some(packed) = pending.last_mut() {
2541                    packed.action = PendingPathAction::Write {
2542                        contents: packed_bytes,
2543                    };
2544                }
2545            }
2546        }
2547
2548        // Stage every new value or delete marker into its lock file. Nothing has
2549        // been renamed or removed yet, so on failure we only drop lock files.
2550        for change in &pending {
2551            if let Err(err) = stage_pending_change(change) {
2552                release_pending_locks(&pending);
2553                return Err(err);
2554            }
2555        }
2556
2557        // git fires the `prepared` hook once every ref is locked, before any
2558        // value is renamed into place. A nonzero exit drops the staged lock
2559        // files (no on-disk change happened yet) and aborts.
2560        if let (Some(hook), Some(updates)) = (hook, hook_updates)
2561            && hook.run(RefTransactionPhase::Prepared, updates)?
2562        {
2563            release_pending_locks(&pending);
2564            return Err(ref_transaction_hook_abort(RefTransactionPhase::Prepared));
2565        }
2566
2567        // Apply each staged path change; on failure restore paths already
2568        // changed and drop the remaining lock files.
2569        for index in 0..pending.len() {
2570            if let Err(err) = maybe_fail_loose_commit_action(index) {
2571                rollback_after_apply(&pending, index);
2572                return Err(err);
2573            }
2574            if let Err(err) = apply_pending_change(&pending[index]) {
2575                rollback_after_apply(&pending, index + 1);
2576                return Err(err);
2577            }
2578        }
2579
2580        for change in &pending {
2581            if matches!(change.action, PendingPathAction::Delete) && change.original.is_some() {
2582                self.prune_empty_ref_dirs(&change.name);
2583            }
2584        }
2585        // Git unlinks logs/refs/<name> (and prunes now-empty log dirs) on delete;
2586        // do this before appending update reflogs so a delete+recreate in the
2587        // same direction does not race the new ref's reflog file.
2588        for name in &delete_names {
2589            self.remove_reflog_file(name);
2590        }
2591        // git's `files_log_ref_write` mirrors a checked-out branch's reflog
2592        // entry into logs/HEAD; `head_branch` was captured before any mutation.
2593        let head_mirror = Self::head_reflog_mirror(head_branch.as_deref(), &reflogs);
2594        reflogs.extend(head_mirror);
2595        // All refs are durable; append reflogs last, matching git's ordering.
2596        for (name, entry) in reflogs {
2597            self.append_reflog(&name, &entry)?;
2598        }
2599        // git fires the `committed` hook once every ref change has landed. Its
2600        // exit status is ignored — the transaction has already succeeded.
2601        if let (Some(hook), Some(updates)) = (hook, hook_updates) {
2602            hook.run(RefTransactionPhase::Committed, updates)?;
2603        }
2604        Ok(())
2605    }
2606
2607    fn read_ref_from_locked_packed(
2608        &self,
2609        name: &str,
2610        packed_refs: &[PackedRef],
2611    ) -> Result<Option<RefTarget>> {
2612        let state = self.read_locked_ref_state(name, packed_refs)?;
2613        Ok(state.current)
2614    }
2615
2616    fn read_locked_ref_state(
2617        &self,
2618        name: &str,
2619        packed_refs: &[PackedRef],
2620    ) -> Result<LockedRefState> {
2621        let loose = self.read_loose_ref(name)?;
2622        let packed_index = packed_refs
2623            .iter()
2624            .position(|reference| reference.reference.name == name);
2625        let current = if let Some(reference) = loose.as_ref() {
2626            Some(reference.target.clone())
2627        } else {
2628            packed_index.map(|index| packed_refs[index].reference.target.clone())
2629        };
2630        Ok(LockedRefState {
2631            current,
2632            has_loose: loose.is_some(),
2633        })
2634    }
2635}
2636
2637struct LockedRefState {
2638    current: Option<RefTarget>,
2639    has_loose: bool,
2640}
2641
2642enum CoalescedRefChange {
2643    Update(CoalescedRefUpdate),
2644    Delete(CoalescedRefDelete),
2645}
2646
2647impl CoalescedRefChange {
2648    fn name(&self) -> &str {
2649        match self {
2650            Self::Update(update) => &update.name,
2651            Self::Delete(delete) => &delete.name,
2652        }
2653    }
2654}
2655
2656/// A ref update with all writes that targeted the same name folded together.
2657struct CoalescedRefUpdate {
2658    name: String,
2659    precondition: RefPrecondition,
2660    new: RefTarget,
2661    reflog: Vec<ReflogEntry>,
2662}
2663
2664struct CoalescedRefDelete {
2665    name: String,
2666    precondition: RefDeletePrecondition,
2667}
2668
2669fn coalesce_ref_changes(changes: Vec<QueuedRefChange>) -> Result<Vec<CoalescedRefChange>> {
2670    let has_delete = changes
2671        .iter()
2672        .any(|change| matches!(change, QueuedRefChange::Delete(_)));
2673    if !has_delete {
2674        let updates = changes
2675            .into_iter()
2676            .map(|change| match change {
2677                QueuedRefChange::Update(update) => update,
2678                QueuedRefChange::Delete(_) => unreachable!("has_delete was false"),
2679            })
2680            .collect::<Vec<_>>();
2681        return coalesce_ref_updates(updates).map(|updates| {
2682            updates
2683                .into_iter()
2684                .map(CoalescedRefChange::Update)
2685                .collect()
2686        });
2687    }
2688
2689    let mut seen = BTreeSet::new();
2690    let mut coalesced = Vec::with_capacity(changes.len());
2691    for change in changes {
2692        let name = match &change {
2693            QueuedRefChange::Update(update) => &update.name,
2694            QueuedRefChange::Delete(delete) => &delete.name,
2695        };
2696        validate_ref_name_for_update(name)?;
2697        if !seen.insert(name.clone()) {
2698            return Err(GitError::Transaction(format!(
2699                "ref {name} appears more than once in transaction"
2700            )));
2701        }
2702        coalesced.push(match change {
2703            QueuedRefChange::Update(update) => CoalescedRefChange::Update(CoalescedRefUpdate {
2704                name: update.name,
2705                precondition: update.precondition,
2706                new: update.new,
2707                reflog: update.reflog.into_iter().collect(),
2708            }),
2709            QueuedRefChange::Delete(delete) => CoalescedRefChange::Delete(CoalescedRefDelete {
2710                name: delete.name,
2711                precondition: delete.precondition,
2712            }),
2713        });
2714    }
2715    Ok(coalesced)
2716}
2717
2718/// Fold repeated updates to the same ref into one, preserving first-seen order.
2719/// The last queued value wins, reflog entries accumulate in order, and the
2720/// precondition is taken from the first update (the state the caller
2721/// asserted before any change in this transaction).
2722fn coalesce_ref_updates(updates: Vec<QueuedUpdate>) -> Result<Vec<CoalescedRefUpdate>> {
2723    let mut order: Vec<String> = Vec::new();
2724    let mut by_name: HashMap<String, CoalescedRefUpdate> = HashMap::new();
2725    for update in updates {
2726        validate_ref_name_for_update(&update.name)?;
2727        match by_name.get_mut(&update.name) {
2728            Some(existing) => {
2729                existing.new = update.new;
2730                if let Some(entry) = update.reflog {
2731                    existing.reflog.push(entry);
2732                }
2733            }
2734            None => {
2735                order.push(update.name.clone());
2736                by_name.insert(
2737                    update.name.clone(),
2738                    CoalescedRefUpdate {
2739                        name: update.name,
2740                        precondition: update.precondition,
2741                        new: update.new,
2742                        reflog: update.reflog.into_iter().collect(),
2743                    },
2744                );
2745            }
2746        }
2747    }
2748    let mut coalesced = Vec::with_capacity(order.len());
2749    for name in order {
2750        if let Some(update) = by_name.remove(&name) {
2751            coalesced.push(update);
2752        }
2753    }
2754    Ok(coalesced)
2755}
2756
2757/// A staged path change: the target path, its lock file, and original bytes for
2758/// rollback.
2759struct PendingPathChange {
2760    name: String,
2761    path: PathBuf,
2762    lock_path: PathBuf,
2763    original: Option<Vec<u8>>,
2764    action: PendingPathAction,
2765}
2766
2767enum PendingPathAction {
2768    Write { contents: Vec<u8> },
2769    Delete,
2770    ReleaseLock,
2771}
2772
2773struct RefDirPruneGuard<'a> {
2774    store: &'a FileRefStore,
2775    name: String,
2776}
2777
2778impl Drop for RefDirPruneGuard<'_> {
2779    fn drop(&mut self) {
2780        self.store.prune_empty_ref_dirs(&self.name);
2781    }
2782}
2783
2784struct DeleteLock {
2785    path: PathBuf,
2786    file: Option<fs::File>,
2787    active: bool,
2788}
2789
2790impl DeleteLock {
2791    fn acquire(path: PathBuf) -> std::result::Result<Self, RefDeleteError> {
2792        match fs::OpenOptions::new()
2793            .write(true)
2794            .create_new(true)
2795            .open(&path)
2796        {
2797            Ok(file) => Ok(Self {
2798                path,
2799                file: Some(file),
2800                active: true,
2801            }),
2802            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
2803                Err(RefDeleteError::Locked)
2804            }
2805            Err(err) => Err(RefDeleteError::Io(err)),
2806        }
2807    }
2808
2809    fn write_all(&mut self, bytes: &[u8]) -> std::result::Result<(), RefDeleteError> {
2810        let Some(file) = self.file.as_mut() else {
2811            return Err(RefDeleteError::Io(std::io::Error::other(
2812                "lock file is already closed",
2813            )));
2814        };
2815        file.set_len(0)?;
2816        file.write_all(bytes)?;
2817        file.sync_all()?;
2818        Ok(())
2819    }
2820
2821    fn close(mut self) -> PathBuf {
2822        self.active = false;
2823        let _ = self.file.take();
2824        self.path.clone()
2825    }
2826
2827    fn remove(mut self) {
2828        self.active = false;
2829        let _ = self.file.take();
2830        let _ = fs::remove_file(&self.path);
2831    }
2832}
2833
2834impl Drop for DeleteLock {
2835    fn drop(&mut self) {
2836        if self.active {
2837            let _ = self.file.take();
2838            let _ = fs::remove_file(&self.path);
2839        }
2840    }
2841}
2842
2843fn checked_delete_oid(
2844    expected: Option<ObjectId>,
2845    current: Option<RefTarget>,
2846) -> std::result::Result<ObjectId, RefDeleteError> {
2847    let Some(current) = current else {
2848        return Err(RefDeleteError::NotFound);
2849    };
2850    let RefTarget::Direct(actual) = current else {
2851        return Err(RefDeleteError::ExpectedMismatch {
2852            expected,
2853            actual: None,
2854        });
2855    };
2856    if let Some(expected_oid) = expected
2857        && expected_oid != actual
2858    {
2859        return Err(RefDeleteError::ExpectedMismatch {
2860            expected: Some(expected_oid),
2861            actual: Some(actual),
2862        });
2863    }
2864    Ok(actual)
2865}
2866
2867/// Verify a queued/checked delete may proceed, dying on a precondition
2868/// mismatch. Git unlinks the reflog on delete (it never writes a deletion
2869/// entry), so this validates only — the peeled OID is no longer plumbed out.
2870/// `peeled_oid_for_delete` is still invoked where the precondition requires the
2871/// peeled value, so a broken/unpeelable ref is still reported.
2872fn verify_delete_precondition(
2873    store: &FileRefStore,
2874    name: &str,
2875    current: Option<&RefTarget>,
2876    precondition: &RefDeletePrecondition,
2877) -> Result<()> {
2878    let Some(current) = current else {
2879        return Err(GitError::Transaction(format!("ref {name} not found")));
2880    };
2881    match precondition {
2882        RefDeletePrecondition::Any => {
2883            peeled_oid_for_delete(store, current)?;
2884            Ok(())
2885        }
2886        RefDeletePrecondition::Immediate(expected) if current == expected => {
2887            peeled_oid_for_delete(store, current)?;
2888            Ok(())
2889        }
2890        RefDeletePrecondition::Immediate(_) => Err(delete_precondition_mismatch(name)),
2891        RefDeletePrecondition::Direct(expected) => {
2892            let RefTarget::Direct(actual) = current else {
2893                return Err(delete_precondition_mismatch(name));
2894            };
2895            if let Some(expected) = expected
2896                && expected != actual
2897            {
2898                return Err(delete_precondition_mismatch(name));
2899            }
2900            Ok(())
2901        }
2902        RefDeletePrecondition::Peeled(expected) => {
2903            let actual = peeled_oid_for_delete(store, current)?;
2904            if actual == Some(*expected) {
2905                Ok(())
2906            } else {
2907                Err(delete_precondition_mismatch(name))
2908            }
2909        }
2910    }
2911}
2912
2913fn peeled_oid_for_delete(store: &FileRefStore, target: &RefTarget) -> Result<Option<ObjectId>> {
2914    match target {
2915        RefTarget::Direct(oid) => Ok(Some(*oid)),
2916        RefTarget::Symbolic(name) => resolve_ref_peeled(store, name),
2917    }
2918}
2919
2920fn delete_precondition_mismatch(name: &str) -> GitError {
2921    GitError::Transaction(format!("expected ref {name} to match"))
2922}
2923
2924fn ref_delete_error_from_git(err: GitError) -> RefDeleteError {
2925    match err {
2926        GitError::InvalidPath(_) => RefDeleteError::InvalidName,
2927        GitError::NotFound(_) => RefDeleteError::NotFound,
2928        GitError::Io(message) if message.contains("File exists") => RefDeleteError::Locked,
2929        GitError::Io(message) if message.contains("could not lock") => RefDeleteError::Locked,
2930        GitError::Transaction(message) if message.contains("could not lock") => {
2931            RefDeleteError::Locked
2932        }
2933        other => RefDeleteError::Io(std::io::Error::other(other.to_string())),
2934    }
2935}
2936
2937fn read_optional_file(path: &Path) -> Result<Option<Vec<u8>>> {
2938    match fs::read(path) {
2939        Ok(bytes) => Ok(Some(bytes)),
2940        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
2941        Err(err) => Err(GitError::Io(err.to_string())),
2942    }
2943}
2944
2945fn stage_lock_file(lock_path: &Path, contents: &[u8]) -> Result<()> {
2946    let mut file = fs::OpenOptions::new()
2947        .write(true)
2948        .truncate(true)
2949        .open(lock_path)?;
2950    file.write_all(contents)?;
2951    file.sync_all()?;
2952    Ok(())
2953}
2954
2955fn stage_pending_change(change: &PendingPathChange) -> Result<()> {
2956    match &change.action {
2957        PendingPathAction::Write { contents } => stage_lock_file(&change.lock_path, contents),
2958        PendingPathAction::Delete => stage_lock_file(&change.lock_path, b"delete\n"),
2959        PendingPathAction::ReleaseLock => Ok(()),
2960    }
2961}
2962
2963fn apply_pending_change(change: &PendingPathChange) -> Result<()> {
2964    match &change.action {
2965        PendingPathAction::Write { .. } => {
2966            fs::rename(&change.lock_path, &change.path).map_err(|err| GitError::Io(err.to_string()))
2967        }
2968        PendingPathAction::Delete => {
2969            if change.original.is_some() {
2970                fs::remove_file(&change.path).map_err(|err| GitError::Io(err.to_string()))?;
2971            }
2972            fs::remove_file(&change.lock_path).map_err(|err| GitError::Io(err.to_string()))
2973        }
2974        PendingPathAction::ReleaseLock => {
2975            fs::remove_file(&change.lock_path).map_err(|err| GitError::Io(err.to_string()))
2976        }
2977    }
2978}
2979
2980/// Delete every still-held lock file. Used when a transaction aborts before any
2981/// path change, so nothing on disk has changed yet.
2982fn release_pending_locks(pending: &[PendingPathChange]) {
2983    for change in pending {
2984        let _ = fs::remove_file(&change.lock_path);
2985    }
2986}
2987
2988/// Roll back after `applied` path changes have already landed: restore each to
2989/// its captured bytes (or remove it if it did not previously exist), then drop
2990/// the lock files that have not yet been applied.
2991fn rollback_after_apply(pending: &[PendingPathChange], applied: usize) {
2992    for change in pending.iter().take(applied) {
2993        if matches!(change.action, PendingPathAction::ReleaseLock) {
2994            let _ = fs::remove_file(&change.lock_path);
2995            continue;
2996        }
2997        match &change.original {
2998            Some(bytes) => {
2999                let _ = restore_file_atomically(&change.path, bytes);
3000            }
3001            None => {
3002                let _ = fs::remove_file(&change.path);
3003            }
3004        }
3005        let _ = fs::remove_file(&change.lock_path);
3006    }
3007    for change in pending.iter().skip(applied) {
3008        let _ = fs::remove_file(&change.lock_path);
3009    }
3010}
3011
3012#[cfg(test)]
3013thread_local! {
3014    static FAIL_LOOSE_COMMIT_ACTION: std::cell::Cell<Option<usize>> =
3015        const { std::cell::Cell::new(None) };
3016}
3017
3018#[cfg(test)]
3019fn set_fail_loose_commit_action_for_test(index: Option<usize>) {
3020    FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.set(index));
3021}
3022
3023#[cfg(test)]
3024fn maybe_fail_loose_commit_action(index: usize) -> Result<()> {
3025    let should_fail = FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.get() == Some(index));
3026    if should_fail {
3027        FAIL_LOOSE_COMMIT_ACTION.with(|cell| cell.set(None));
3028        return Err(GitError::Io(format!(
3029            "injected loose ref transaction failure at action {index}"
3030        )));
3031    }
3032    Ok(())
3033}
3034
3035#[cfg(not(test))]
3036fn maybe_fail_loose_commit_action(_index: usize) -> Result<()> {
3037    Ok(())
3038}
3039
3040/// Best-effort atomic restore of `path` to `bytes` during rollback, reusing the
3041/// write-to-temp-then-rename dance so a crash mid-rollback cannot truncate a ref.
3042fn restore_file_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
3043    write_locked(path, bytes)
3044}
3045
3046#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3047pub struct FullRefName<'a> {
3048    name: &'a str,
3049}
3050
3051impl<'a> FullRefName<'a> {
3052    pub fn new(name: &'a str) -> Result<Self> {
3053        validate_ref_name(name)?;
3054        Ok(Self { name })
3055    }
3056
3057    pub fn as_str(&self) -> &str {
3058        self.name
3059    }
3060
3061    pub fn into_str(self) -> &'a str {
3062        self.name
3063    }
3064
3065    pub fn to_owned(&self) -> FullRefNameBuf {
3066        FullRefNameBuf {
3067            name: self.name.to_string(),
3068        }
3069    }
3070
3071    pub fn as_branch(&self) -> Result<BranchRefName<'a>> {
3072        BranchRefName::from_full_ref(*self)
3073    }
3074
3075    pub fn as_tag(&self) -> Result<TagRefName<'a>> {
3076        TagRefName::from_full_ref(*self)
3077    }
3078
3079    pub fn as_remote(&self) -> Result<RemoteRefName<'a>> {
3080        RemoteRefName::from_full_ref(*self)
3081    }
3082}
3083
3084impl AsRef<str> for FullRefName<'_> {
3085    fn as_ref(&self) -> &str {
3086        self.as_str()
3087    }
3088}
3089
3090impl fmt::Display for FullRefName<'_> {
3091    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3092        f.write_str(self.as_str())
3093    }
3094}
3095
3096#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3097pub struct FullRefNameBuf {
3098    name: String,
3099}
3100
3101impl FullRefNameBuf {
3102    pub fn new(name: impl Into<String>) -> Result<Self> {
3103        let name = name.into();
3104        validate_ref_name(&name)?;
3105        Ok(Self { name })
3106    }
3107
3108    pub fn as_ref_name(&self) -> FullRefName<'_> {
3109        FullRefName { name: &self.name }
3110    }
3111
3112    pub fn as_str(&self) -> &str {
3113        &self.name
3114    }
3115
3116    pub fn into_string(self) -> String {
3117        self.name
3118    }
3119
3120    pub fn as_branch(&self) -> Result<BranchRefName<'_>> {
3121        self.as_ref_name().as_branch()
3122    }
3123
3124    pub fn as_tag(&self) -> Result<TagRefName<'_>> {
3125        self.as_ref_name().as_tag()
3126    }
3127
3128    pub fn as_remote(&self) -> Result<RemoteRefName<'_>> {
3129        self.as_ref_name().as_remote()
3130    }
3131}
3132
3133impl AsRef<str> for FullRefNameBuf {
3134    fn as_ref(&self) -> &str {
3135        self.as_str()
3136    }
3137}
3138
3139impl Borrow<str> for FullRefNameBuf {
3140    fn borrow(&self) -> &str {
3141        self.as_str()
3142    }
3143}
3144
3145impl Deref for FullRefNameBuf {
3146    type Target = str;
3147
3148    fn deref(&self) -> &Self::Target {
3149        self.as_str()
3150    }
3151}
3152
3153impl fmt::Display for FullRefNameBuf {
3154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3155        f.write_str(self.as_str())
3156    }
3157}
3158
3159#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3160pub struct BranchRefName<'a> {
3161    name: &'a str,
3162}
3163
3164impl<'a> BranchRefName<'a> {
3165    pub const PREFIX: &'static str = "refs/heads/";
3166
3167    pub fn from_full(name: &'a str) -> Result<Self> {
3168        let full = FullRefName::new(name)?;
3169        Self::from_full_ref(full)
3170    }
3171
3172    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
3173        validate_namespaced_ref(name.as_str(), Self::PREFIX, "branch")?;
3174        Ok(Self {
3175            name: name.into_str(),
3176        })
3177    }
3178
3179    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
3180        FullRefName { name: self.name }
3181    }
3182
3183    pub fn as_str(&self) -> &str {
3184        self.name
3185    }
3186
3187    pub fn branch_name(&self) -> &str {
3188        self.short_name()
3189    }
3190
3191    pub fn short_name(&self) -> &str {
3192        &self.name[Self::PREFIX.len()..]
3193    }
3194
3195    pub fn into_str(self) -> &'a str {
3196        self.name
3197    }
3198
3199    pub fn to_owned(&self) -> BranchRefNameBuf {
3200        BranchRefNameBuf {
3201            name: self.name.to_string(),
3202        }
3203    }
3204}
3205
3206impl AsRef<str> for BranchRefName<'_> {
3207    fn as_ref(&self) -> &str {
3208        self.as_str()
3209    }
3210}
3211
3212impl fmt::Display for BranchRefName<'_> {
3213    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3214        f.write_str(self.as_str())
3215    }
3216}
3217
3218impl<'a> From<BranchRefName<'a>> for FullRefName<'a> {
3219    fn from(name: BranchRefName<'a>) -> Self {
3220        name.as_full_ref_name()
3221    }
3222}
3223
3224#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3225pub struct BranchRefNameBuf {
3226    name: String,
3227}
3228
3229impl BranchRefNameBuf {
3230    pub fn from_branch_name(branch: &str) -> Result<Self> {
3231        validate_short_ref_name("branch", branch)?;
3232        let name = format!("{}{}", BranchRefName::PREFIX, branch);
3233        Self::from_full(name)
3234    }
3235
3236    pub fn from_full(name: impl Into<String>) -> Result<Self> {
3237        let name = name.into();
3238        BranchRefName::from_full(&name)?;
3239        Ok(Self { name })
3240    }
3241
3242    pub fn as_ref_name(&self) -> BranchRefName<'_> {
3243        BranchRefName { name: &self.name }
3244    }
3245
3246    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
3247        FullRefName { name: &self.name }
3248    }
3249
3250    pub fn as_str(&self) -> &str {
3251        &self.name
3252    }
3253
3254    pub fn branch_name(&self) -> &str {
3255        self.short_name()
3256    }
3257
3258    pub fn short_name(&self) -> &str {
3259        &self.name[BranchRefName::PREFIX.len()..]
3260    }
3261
3262    pub fn into_string(self) -> String {
3263        self.name
3264    }
3265}
3266
3267impl AsRef<str> for BranchRefNameBuf {
3268    fn as_ref(&self) -> &str {
3269        self.as_str()
3270    }
3271}
3272
3273impl Borrow<str> for BranchRefNameBuf {
3274    fn borrow(&self) -> &str {
3275        self.as_str()
3276    }
3277}
3278
3279impl Deref for BranchRefNameBuf {
3280    type Target = str;
3281
3282    fn deref(&self) -> &Self::Target {
3283        self.as_str()
3284    }
3285}
3286
3287impl fmt::Display for BranchRefNameBuf {
3288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3289        f.write_str(self.as_str())
3290    }
3291}
3292
3293impl From<BranchRefNameBuf> for FullRefNameBuf {
3294    fn from(name: BranchRefNameBuf) -> Self {
3295        Self { name: name.name }
3296    }
3297}
3298
3299#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3300pub struct TagRefName<'a> {
3301    name: &'a str,
3302}
3303
3304impl<'a> TagRefName<'a> {
3305    pub const PREFIX: &'static str = "refs/tags/";
3306
3307    pub fn from_full(name: &'a str) -> Result<Self> {
3308        let full = FullRefName::new(name)?;
3309        Self::from_full_ref(full)
3310    }
3311
3312    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
3313        validate_namespaced_ref(name.as_str(), Self::PREFIX, "tag")?;
3314        Ok(Self {
3315            name: name.into_str(),
3316        })
3317    }
3318
3319    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
3320        FullRefName { name: self.name }
3321    }
3322
3323    pub fn as_str(&self) -> &str {
3324        self.name
3325    }
3326
3327    pub fn tag_name(&self) -> &str {
3328        self.short_name()
3329    }
3330
3331    pub fn short_name(&self) -> &str {
3332        &self.name[Self::PREFIX.len()..]
3333    }
3334
3335    pub fn into_str(self) -> &'a str {
3336        self.name
3337    }
3338
3339    pub fn to_owned(&self) -> TagRefNameBuf {
3340        TagRefNameBuf {
3341            name: self.name.to_string(),
3342        }
3343    }
3344}
3345
3346impl AsRef<str> for TagRefName<'_> {
3347    fn as_ref(&self) -> &str {
3348        self.as_str()
3349    }
3350}
3351
3352impl fmt::Display for TagRefName<'_> {
3353    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3354        f.write_str(self.as_str())
3355    }
3356}
3357
3358impl<'a> From<TagRefName<'a>> for FullRefName<'a> {
3359    fn from(name: TagRefName<'a>) -> Self {
3360        name.as_full_ref_name()
3361    }
3362}
3363
3364#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3365pub struct TagRefNameBuf {
3366    name: String,
3367}
3368
3369impl TagRefNameBuf {
3370    pub fn from_tag_name(tag: &str) -> Result<Self> {
3371        // Mirror git's check_tag_ref(): reject a leading '-' or the literal
3372        // "HEAD", then validate refs/tags/<tag> with check_refname_format().
3373        if tag.starts_with('-') || tag == "HEAD" {
3374            return Err(GitError::InvalidPath(format!("invalid tag name {tag}")));
3375        }
3376        Self::from_tag_name_unrestricted(tag)
3377    }
3378
3379    /// Build `refs/tags/<tag>` validating only the refname format, without the
3380    /// creation-only restrictions (leading `-`, literal `HEAD`). Git's delete
3381    /// path does not run check_tag_ref(), so a tag literally named `HEAD` can
3382    /// still be removed.
3383    pub fn from_tag_name_unrestricted(tag: &str) -> Result<Self> {
3384        let name = format!("{}{}", TagRefName::PREFIX, tag);
3385        check_refname_format(&name, false)?;
3386        Ok(Self { name })
3387    }
3388
3389    pub fn from_full(name: impl Into<String>) -> Result<Self> {
3390        let name = name.into();
3391        TagRefName::from_full(&name)?;
3392        Ok(Self { name })
3393    }
3394
3395    pub fn as_ref_name(&self) -> TagRefName<'_> {
3396        TagRefName { name: &self.name }
3397    }
3398
3399    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
3400        FullRefName { name: &self.name }
3401    }
3402
3403    pub fn as_str(&self) -> &str {
3404        &self.name
3405    }
3406
3407    pub fn tag_name(&self) -> &str {
3408        self.short_name()
3409    }
3410
3411    pub fn short_name(&self) -> &str {
3412        &self.name[TagRefName::PREFIX.len()..]
3413    }
3414
3415    pub fn into_string(self) -> String {
3416        self.name
3417    }
3418}
3419
3420impl AsRef<str> for TagRefNameBuf {
3421    fn as_ref(&self) -> &str {
3422        self.as_str()
3423    }
3424}
3425
3426impl Borrow<str> for TagRefNameBuf {
3427    fn borrow(&self) -> &str {
3428        self.as_str()
3429    }
3430}
3431
3432impl Deref for TagRefNameBuf {
3433    type Target = str;
3434
3435    fn deref(&self) -> &Self::Target {
3436        self.as_str()
3437    }
3438}
3439
3440impl fmt::Display for TagRefNameBuf {
3441    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3442        f.write_str(self.as_str())
3443    }
3444}
3445
3446impl From<TagRefNameBuf> for FullRefNameBuf {
3447    fn from(name: TagRefNameBuf) -> Self {
3448        Self { name: name.name }
3449    }
3450}
3451
3452#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3453pub struct RemoteRefName<'a> {
3454    name: &'a str,
3455}
3456
3457impl<'a> RemoteRefName<'a> {
3458    pub const PREFIX: &'static str = "refs/remotes/";
3459
3460    pub fn from_full(name: &'a str) -> Result<Self> {
3461        let full = FullRefName::new(name)?;
3462        Self::from_full_ref(full)
3463    }
3464
3465    pub fn from_full_ref(name: FullRefName<'a>) -> Result<Self> {
3466        validate_namespaced_ref(name.as_str(), Self::PREFIX, "remote")?;
3467        Ok(Self {
3468            name: name.into_str(),
3469        })
3470    }
3471
3472    pub fn as_full_ref_name(&self) -> FullRefName<'a> {
3473        FullRefName { name: self.name }
3474    }
3475
3476    pub fn as_str(&self) -> &str {
3477        self.name
3478    }
3479
3480    pub fn short_name(&self) -> &str {
3481        &self.name[Self::PREFIX.len()..]
3482    }
3483
3484    pub fn remote_name(&self) -> &str {
3485        match self.short_name().split_once('/') {
3486            Some((remote, _branch)) => remote,
3487            None => self.short_name(),
3488        }
3489    }
3490
3491    pub fn remote_branch(&self) -> Option<&str> {
3492        self.short_name()
3493            .split_once('/')
3494            .map(|(_remote, branch)| branch)
3495    }
3496
3497    pub fn into_str(self) -> &'a str {
3498        self.name
3499    }
3500
3501    pub fn to_owned(&self) -> RemoteRefNameBuf {
3502        RemoteRefNameBuf {
3503            name: self.name.to_string(),
3504        }
3505    }
3506}
3507
3508impl AsRef<str> for RemoteRefName<'_> {
3509    fn as_ref(&self) -> &str {
3510        self.as_str()
3511    }
3512}
3513
3514impl fmt::Display for RemoteRefName<'_> {
3515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3516        f.write_str(self.as_str())
3517    }
3518}
3519
3520impl<'a> From<RemoteRefName<'a>> for FullRefName<'a> {
3521    fn from(name: RemoteRefName<'a>) -> Self {
3522        name.as_full_ref_name()
3523    }
3524}
3525
3526#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3527pub struct RemoteRefNameBuf {
3528    name: String,
3529}
3530
3531impl RemoteRefNameBuf {
3532    pub fn from_short_name(name: &str) -> Result<Self> {
3533        validate_short_ref_name("remote ref", name)?;
3534        let name = format!("{}{}", RemoteRefName::PREFIX, name);
3535        Self::from_full(name)
3536    }
3537
3538    pub fn from_remote_branch(remote: &str, branch: &str) -> Result<Self> {
3539        validate_remote_name(remote)?;
3540        validate_short_ref_name("remote branch", branch)?;
3541        let name = format!("{}{}/{}", RemoteRefName::PREFIX, remote, branch);
3542        Self::from_full(name)
3543    }
3544
3545    pub fn from_full(name: impl Into<String>) -> Result<Self> {
3546        let name = name.into();
3547        RemoteRefName::from_full(&name)?;
3548        Ok(Self { name })
3549    }
3550
3551    pub fn as_ref_name(&self) -> RemoteRefName<'_> {
3552        RemoteRefName { name: &self.name }
3553    }
3554
3555    pub fn as_full_ref_name(&self) -> FullRefName<'_> {
3556        FullRefName { name: &self.name }
3557    }
3558
3559    pub fn as_str(&self) -> &str {
3560        &self.name
3561    }
3562
3563    pub fn short_name(&self) -> &str {
3564        &self.name[RemoteRefName::PREFIX.len()..]
3565    }
3566
3567    pub fn remote_name(&self) -> &str {
3568        match self.short_name().split_once('/') {
3569            Some((remote, _branch)) => remote,
3570            None => self.short_name(),
3571        }
3572    }
3573
3574    pub fn remote_branch(&self) -> Option<&str> {
3575        self.short_name()
3576            .split_once('/')
3577            .map(|(_remote, branch)| branch)
3578    }
3579
3580    pub fn into_string(self) -> String {
3581        self.name
3582    }
3583}
3584
3585impl AsRef<str> for RemoteRefNameBuf {
3586    fn as_ref(&self) -> &str {
3587        self.as_str()
3588    }
3589}
3590
3591impl Borrow<str> for RemoteRefNameBuf {
3592    fn borrow(&self) -> &str {
3593        self.as_str()
3594    }
3595}
3596
3597impl Deref for RemoteRefNameBuf {
3598    type Target = str;
3599
3600    fn deref(&self) -> &Self::Target {
3601        self.as_str()
3602    }
3603}
3604
3605impl fmt::Display for RemoteRefNameBuf {
3606    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3607        f.write_str(self.as_str())
3608    }
3609}
3610
3611impl From<RemoteRefNameBuf> for FullRefNameBuf {
3612    fn from(name: RemoteRefNameBuf) -> Self {
3613        Self { name: name.name }
3614    }
3615}
3616
3617pub fn branch_ref_name(branch: &str) -> Result<String> {
3618    BranchRefNameBuf::from_branch_name(branch).map(BranchRefNameBuf::into_string)
3619}
3620
3621pub fn tag_ref_name(tag: &str) -> Result<String> {
3622    TagRefNameBuf::from_tag_name(tag).map(TagRefNameBuf::into_string)
3623}
3624
3625fn write_locked(path: &Path, bytes: &[u8]) -> Result<()> {
3626    let lock_path = lock_path_for(path)?;
3627    {
3628        let mut file = fs::OpenOptions::new()
3629            .write(true)
3630            .create_new(true)
3631            .open(&lock_path)?;
3632        file.write_all(bytes)?;
3633        file.sync_all()?;
3634    }
3635    match fs::rename(&lock_path, path) {
3636        Ok(()) => Ok(()),
3637        Err(err) => {
3638            let _ = fs::remove_file(lock_path);
3639            Err(GitError::Io(err.to_string()))
3640        }
3641    }
3642}
3643
3644fn lock_path_for(path: &Path) -> Result<PathBuf> {
3645    let file_name = path
3646        .file_name()
3647        .ok_or_else(|| GitError::InvalidPath("ref path has no filename".into()))?;
3648    let mut lock_name = file_name.to_os_string();
3649    lock_name.push(".lock");
3650    Ok(path.with_file_name(lock_name))
3651}
3652
3653/// Validate a ref name using git's `check_refname_format` rules.
3654pub fn check_refname_format(name: &str, allow_onelevel: bool) -> Result<()> {
3655    if name.is_empty()
3656        || name == "@"
3657        || name.starts_with('/')
3658        || name.ends_with('/')
3659        || name.ends_with('.')
3660        || name.contains("..")
3661        || name.contains("//")
3662        || name.contains("@{")
3663        || (!allow_onelevel && !name.contains('/'))
3664    {
3665        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3666    }
3667    for component in name.split('/') {
3668        if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") {
3669            return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3670        }
3671        for (idx, byte) in component.bytes().enumerate() {
3672            if byte <= b' '
3673                || byte == 0x7f
3674                || matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
3675            {
3676                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3677            }
3678            if byte == b'.' && component.as_bytes().get(idx + 1) == Some(&b'.') {
3679                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3680            }
3681            if byte == b'@' && component.as_bytes().get(idx + 1) == Some(&b'{') {
3682                return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3683            }
3684        }
3685    }
3686    Ok(())
3687}
3688
3689/// Validate a symbolic ref name (HEAD, one-level pseudo-refs, or `refs/...`).
3690pub fn validate_symref_name(name: &str) -> Result<()> {
3691    if name == "HEAD" {
3692        return Ok(());
3693    }
3694    check_refname_format(name, true)
3695}
3696
3697/// Validate a symbolic ref target (one-level pseudo-refs or `refs/...`).
3698pub fn validate_symref_target(name: &str) -> Result<()> {
3699    check_refname_format(name, true)
3700}
3701
3702/// Follow symbolic ref chains until a direct OID is reached.
3703/// Remove empty directories starting at `start` and walking up toward
3704/// `boundary`, stopping at the first non-empty directory or when `boundary` is
3705/// reached (exclusive). `boundary` itself is never removed.
3706fn prune_empty_dirs_up_to(start: &Path, boundary: &Path) {
3707    let mut dir = start.to_path_buf();
3708    while dir.starts_with(boundary) && dir != *boundary {
3709        if fs::remove_dir(&dir).is_err() {
3710            break;
3711        }
3712        dir = match dir.parent() {
3713            Some(parent) => parent.to_path_buf(),
3714            None => break,
3715        };
3716    }
3717}
3718
3719pub fn resolve_ref_peeled(store: &FileRefStore, name: &str) -> Result<Option<ObjectId>> {
3720    let mut current = name.to_string();
3721    for _ in 0..16 {
3722        match store.read_ref(&current)? {
3723            Some(RefTarget::Direct(oid)) => return Ok(Some(oid)),
3724            Some(RefTarget::Symbolic(next)) => current = next,
3725            None => return Ok(None),
3726        }
3727    }
3728    Ok(None)
3729}
3730
3731fn validate_ref_name_for_read(name: &str) -> Result<()> {
3732    if validate_ref_name(name).is_ok() {
3733        return Ok(());
3734    }
3735    if is_root_ref_syntax(name) {
3736        return Ok(());
3737    }
3738    validate_symref_name(name)
3739}
3740
3741fn validate_ref_name_for_update(name: &str) -> Result<()> {
3742    if validate_ref_name(name).is_ok() {
3743        return Ok(());
3744    }
3745    if is_root_ref_syntax(name) {
3746        return Ok(());
3747    }
3748    validate_symref_name(name)
3749}
3750
3751/// git's is_root_ref_syntax (refs.c): a ref name made only of uppercase ASCII,
3752/// `-`, and `_` (e.g. HEAD, FETCH_HEAD, MERGE_HEAD). Such names live in the
3753/// per-worktree gitdir rather than the common refs/ tree. An empty name is not
3754/// root-ref syntax.
3755fn is_root_ref_syntax(name: &str) -> bool {
3756    !name.is_empty()
3757        && name
3758            .bytes()
3759            .all(|b| b.is_ascii_uppercase() || b == b'-' || b == b'_')
3760}
3761
3762pub fn validate_ref_name(name: &str) -> Result<()> {
3763    if name == "HEAD" {
3764        return Ok(());
3765    }
3766    let path = Path::new(name);
3767    if !name.starts_with("refs/")
3768        || name.contains("..")
3769        || name.contains('\\')
3770        || name.ends_with('/')
3771        || name.ends_with(".lock")
3772        || path.is_absolute()
3773        || path.components().any(|component| {
3774            matches!(
3775                component,
3776                std::path::Component::ParentDir | std::path::Component::Prefix(_)
3777            )
3778        })
3779    {
3780        return Err(GitError::InvalidPath(format!("invalid ref name {name}")));
3781    }
3782    Ok(())
3783}
3784
3785fn ref_directory_conflict_error(new_ref: &str, existing_ref: &str) -> GitError {
3786    GitError::Transaction(format!(
3787        "cannot lock ref '{new_ref}': '{existing_ref}' exists; cannot create '{new_ref}'"
3788    ))
3789}
3790
3791fn parent_to_ref_name(base: &Path, parent: &Path) -> String {
3792    match parent.strip_prefix(base) {
3793        Ok(suffix) => suffix.to_string_lossy().replace('\\', "/"),
3794        Err(_) => parent.to_string_lossy().into_owned(),
3795    }
3796}
3797
3798fn validate_namespaced_ref(name: &str, prefix: &str, kind: &str) -> Result<()> {
3799    validate_ref_name(name)?;
3800    if name
3801        .strip_prefix(prefix)
3802        .is_none_or(|short_name| short_name.is_empty())
3803    {
3804        return Err(GitError::InvalidPath(format!(
3805            "invalid {kind} ref name {name}"
3806        )));
3807    }
3808    Ok(())
3809}
3810
3811fn validate_short_ref_name(kind: &str, name: &str) -> Result<()> {
3812    if name.is_empty()
3813        || name.starts_with('-')
3814        || name.starts_with('/')
3815        || name.ends_with('/')
3816        || name.contains(' ')
3817        || name.contains('\\')
3818    {
3819        return Err(GitError::InvalidPath(format!("invalid {kind} name {name}")));
3820    }
3821    Ok(())
3822}
3823
3824fn validate_remote_name(remote: &str) -> Result<()> {
3825    validate_short_ref_name("remote", remote)?;
3826    if remote.contains('/') {
3827        return Err(GitError::InvalidPath(format!(
3828            "invalid remote name {remote}"
3829        )));
3830    }
3831    Ok(())
3832}
3833
3834fn prepare_bundle_ref_updates<F>(
3835    refs: &[BundleRefUpdate],
3836    reflog: Option<&BundleRefUpdateReflog>,
3837    mut read_ref: F,
3838) -> Result<(Vec<RefUpdate>, Vec<AppliedBundleRefUpdate>)>
3839where
3840    F: FnMut(&str, &ObjectId) -> Result<Option<RefTarget>>,
3841{
3842    let mut seen = BTreeSet::new();
3843    let mut updates = Vec::with_capacity(refs.len());
3844    let mut applied = Vec::with_capacity(refs.len());
3845    for bundle_ref in refs {
3846        validate_ref_name(&bundle_ref.name)?;
3847        if !seen.insert(bundle_ref.name.clone()) {
3848            return Err(GitError::Transaction(format!(
3849                "duplicate bundle ref {}",
3850                bundle_ref.name
3851            )));
3852        }
3853        let old_oid = match read_ref(&bundle_ref.name, &bundle_ref.oid)? {
3854            Some(RefTarget::Direct(oid)) => Some(oid),
3855            Some(RefTarget::Symbolic(target)) => {
3856                return Err(GitError::Transaction(format!(
3857                    "bundle ref {} would overwrite symbolic ref {target}",
3858                    bundle_ref.name
3859                )));
3860            }
3861            None => None,
3862        };
3863        let reflog = match reflog {
3864            Some(reflog) => Some(ReflogEntry {
3865                old_oid: match &old_oid {
3866                    Some(oid) => *oid,
3867                    None => null_oid(bundle_ref.oid.format())?,
3868                },
3869                new_oid: bundle_ref.oid,
3870                committer: reflog.committer.clone(),
3871                message: reflog.message.clone(),
3872            }),
3873            None => None,
3874        };
3875        updates.push(RefUpdate {
3876            name: bundle_ref.name.clone(),
3877            expected: old_oid.map(RefTarget::Direct),
3878            new: RefTarget::Direct(bundle_ref.oid),
3879            reflog,
3880        });
3881        applied.push(AppliedBundleRefUpdate {
3882            name: bundle_ref.name.clone(),
3883            old_oid,
3884            new_oid: bundle_ref.oid,
3885        });
3886    }
3887    Ok((updates, applied))
3888}
3889
3890fn null_oid(format: ObjectFormat) -> Result<ObjectId> {
3891    Ok(ObjectId::null(format))
3892}
3893
3894#[cfg(test)]
3895mod tests {
3896    use super::*;
3897    use std::sync::atomic::{AtomicU64, Ordering};
3898
3899    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
3900
3901    #[test]
3902    fn loose_ref_round_trips_direct() {
3903        let oid = "ce013625030ba8dba906f756967f9e9ca394464a";
3904        let reference = parse_loose_ref(ObjectFormat::Sha1, "refs/heads/main", oid.as_bytes())
3905            .expect("test operation should succeed");
3906        assert_eq!(write_loose_ref(&reference), format!("{oid}\n").into_bytes());
3907    }
3908
3909    #[test]
3910    fn symref_names_allow_onelevel_pseudo_refs() {
3911        for name in ["NOTHEAD", "FOO", "ORIG_HEAD", "TEST_SYMREF"] {
3912            validate_symref_name(name).expect("symref name should be valid");
3913        }
3914        assert!(validate_ref_name("NOTHEAD").is_err());
3915        assert!(validate_symref_target("refs/heads/foo").is_ok());
3916        assert!(validate_symref_target("ORIG_HEAD").is_ok());
3917        assert!(validate_symref_target("foo..bar").is_err());
3918    }
3919
3920    #[test]
3921    fn resolve_ref_peeled_follows_symref_chains() {
3922        let git_dir = temp_git_dir();
3923        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3924        let oid = ObjectId::from_hex(
3925            ObjectFormat::Sha1,
3926            "ce013625030ba8dba906f756967f9e9ca394464a",
3927        )
3928        .expect("test operation should succeed");
3929        let mut tx = store.transaction();
3930        tx.update(RefUpdate {
3931            name: "refs/heads/target".into(),
3932            expected: None,
3933            new: RefTarget::Direct(oid),
3934            reflog: None,
3935        });
3936        tx.commit().expect("seed target ref");
3937        let mut tx = store.transaction();
3938        tx.update(RefUpdate {
3939            name: "refs/heads/alias".into(),
3940            expected: None,
3941            new: RefTarget::Symbolic("refs/heads/target".into()),
3942            reflog: None,
3943        });
3944        tx.commit().expect("seed alias ref");
3945        let mut tx = store.transaction();
3946        tx.update(RefUpdate {
3947            name: "ORIG_HEAD".into(),
3948            expected: None,
3949            new: RefTarget::Symbolic("refs/heads/alias".into()),
3950            reflog: None,
3951        });
3952        tx.commit().expect("seed ORIG_HEAD symref");
3953        assert_eq!(
3954            resolve_ref_peeled(&store, "ORIG_HEAD").expect("resolve ORIG_HEAD"),
3955            Some(oid)
3956        );
3957        let _ = fs::remove_dir_all(git_dir);
3958    }
3959
3960    #[test]
3961    fn symref_directory_conflict_is_reported_gracefully() {
3962        let git_dir = temp_git_dir();
3963        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
3964        let oid = ObjectId::from_hex(
3965            ObjectFormat::Sha1,
3966            "ce013625030ba8dba906f756967f9e9ca394464a",
3967        )
3968        .expect("test operation should succeed");
3969        let mut tx = store.transaction();
3970        tx.update(RefUpdate {
3971            name: "refs/heads/df".into(),
3972            expected: None,
3973            new: RefTarget::Direct(oid),
3974            reflog: None,
3975        });
3976        tx.commit().expect("seed branch ref");
3977
3978        let mut tx = store.transaction();
3979        tx.update(RefUpdate {
3980            name: "refs/heads/df/conflict".into(),
3981            expected: None,
3982            new: RefTarget::Symbolic("refs/heads/df".into()),
3983            reflog: None,
3984        });
3985        let err = tx.commit().expect_err("child ref should conflict");
3986        assert!(
3987            matches!(err, GitError::Transaction(message) if message.contains(
3988            "cannot lock ref 'refs/heads/df/conflict'"
3989        ) && message.contains("refs/heads/df"))
3990        );
3991        let _ = fs::remove_dir_all(git_dir);
3992    }
3993
3994    #[test]
3995    fn transaction_checks_expected_value() {
3996        let oid = ObjectId::from_hex(
3997            ObjectFormat::Sha1,
3998            "ce013625030ba8dba906f756967f9e9ca394464a",
3999        )
4000        .expect("test operation should succeed");
4001        let mut store = RefStore::new();
4002        let mut tx = store.transaction();
4003        tx.update(RefUpdate {
4004            name: "refs/heads/main".into(),
4005            expected: None,
4006            new: RefTarget::Direct(oid),
4007            reflog: None,
4008        });
4009        tx.commit().expect("test operation should succeed");
4010        assert_eq!(store.get("refs/heads/main"), Some(&RefTarget::Direct(oid)));
4011    }
4012
4013    #[test]
4014    fn packed_refs_parse_peeled_refs() {
4015        let packed = b"# pack-refs with: peeled fully-peeled sorted \n\
4016ce013625030ba8dba906f756967f9e9ca394464a refs/tags/v1\n\
4017^e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n";
4018        let refs =
4019            parse_packed_refs(ObjectFormat::Sha1, packed).expect("test operation should succeed");
4020        assert_eq!(refs.len(), 1);
4021        assert_eq!(refs[0].reference.name, "refs/tags/v1");
4022        assert_eq!(
4023            refs[0]
4024                .peeled
4025                .as_ref()
4026                .expect("test operation should succeed")
4027                .to_hex(),
4028            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
4029        );
4030    }
4031
4032    #[test]
4033    fn packed_refs_write_sorted_with_peeled_refs() {
4034        let head_oid = ObjectId::from_hex(
4035            ObjectFormat::Sha1,
4036            "ce013625030ba8dba906f756967f9e9ca394464a",
4037        )
4038        .expect("test operation should succeed");
4039        let tag_oid = ObjectId::from_hex(
4040            ObjectFormat::Sha1,
4041            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
4042        )
4043        .expect("test operation should succeed");
4044        let peeled_oid = ObjectId::from_hex(
4045            ObjectFormat::Sha1,
4046            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4047        )
4048        .expect("test operation should succeed");
4049        let refs = vec![
4050            PackedRef {
4051                reference: Ref {
4052                    name: "refs/tags/v1".into(),
4053                    target: RefTarget::Direct(tag_oid),
4054                },
4055                peeled: Some(peeled_oid),
4056            },
4057            PackedRef {
4058                reference: Ref {
4059                    name: "refs/heads/main".into(),
4060                    target: RefTarget::Direct(head_oid),
4061                },
4062                peeled: None,
4063            },
4064        ];
4065        let bytes = write_packed_refs(&refs).expect("test operation should succeed");
4066        let expected = format!(
4067            "# pack-refs with: peeled fully-peeled sorted \n\
4068{head_oid} refs/heads/main\n\
4069{tag_oid} refs/tags/v1\n\
4070^{peeled_oid}\n"
4071        );
4072        assert_eq!(
4073            String::from_utf8(bytes.clone()).expect("test operation should succeed"),
4074            expected
4075        );
4076        let parsed =
4077            parse_packed_refs(ObjectFormat::Sha1, &bytes).expect("test operation should succeed");
4078        assert_eq!(parsed[0], refs[1]);
4079        assert_eq!(parsed[1], refs[0]);
4080    }
4081
4082    #[test]
4083    fn full_ref_name_validates_and_round_trips_owned() {
4084        let full = FullRefName::new("refs/heads/main").expect("valid full branch ref");
4085        assert_eq!(full.as_str(), "refs/heads/main");
4086        assert_eq!(full.to_string(), "refs/heads/main");
4087        assert_eq!(full.to_owned().into_string(), "refs/heads/main");
4088
4089        let head = FullRefNameBuf::new("HEAD").expect("valid HEAD ref");
4090        assert_eq!(head.as_ref_name().into_str(), "HEAD");
4091
4092        assert!(FullRefName::new("main").is_err());
4093        assert!(FullRefNameBuf::new("refs/heads/bad.lock").is_err());
4094    }
4095
4096    #[test]
4097    fn branch_ref_name_helpers_validate_short_and_full_names() {
4098        let branch =
4099            BranchRefNameBuf::from_branch_name("feature/topic").expect("valid branch short name");
4100        assert_eq!(branch.as_str(), "refs/heads/feature/topic");
4101        assert_eq!(branch.branch_name(), "feature/topic");
4102        assert_eq!(
4103            branch.as_full_ref_name().as_str(),
4104            "refs/heads/feature/topic"
4105        );
4106        assert_eq!(
4107            branch_ref_name("feature/topic").expect("valid branch short name"),
4108            branch.as_str()
4109        );
4110
4111        let borrowed = BranchRefName::from_full("refs/heads/main").expect("valid full branch ref");
4112        assert_eq!(borrowed.branch_name(), "main");
4113        assert_eq!(borrowed.to_owned().into_string(), "refs/heads/main");
4114        assert_eq!(
4115            FullRefName::new("refs/heads/main")
4116                .expect("valid full branch ref")
4117                .as_branch()
4118                .expect("full ref is a branch")
4119                .branch_name(),
4120            "main"
4121        );
4122
4123        assert!(BranchRefName::from_full("refs/tags/main").is_err());
4124        assert!(BranchRefName::from_full("refs/heads").is_err());
4125        assert!(BranchRefNameBuf::from_branch_name("-bad").is_err());
4126    }
4127
4128    #[test]
4129    fn tag_ref_name_helpers_validate_short_and_full_names() {
4130        let tag = TagRefNameBuf::from_tag_name("v1.0").expect("valid tag short name");
4131        assert_eq!(tag.as_str(), "refs/tags/v1.0");
4132        assert_eq!(tag.tag_name(), "v1.0");
4133        assert_eq!(tag.as_full_ref_name().as_str(), "refs/tags/v1.0");
4134        assert_eq!(
4135            tag_ref_name("v1.0").expect("valid tag short name"),
4136            tag.as_str()
4137        );
4138
4139        let borrowed = TagRefName::from_full("refs/tags/release/1").expect("valid full tag ref");
4140        assert_eq!(borrowed.tag_name(), "release/1");
4141        assert_eq!(borrowed.to_owned().into_string(), "refs/tags/release/1");
4142        assert_eq!(
4143            FullRefName::new("refs/tags/release/1")
4144                .expect("valid full tag ref")
4145                .as_tag()
4146                .expect("full ref is a tag")
4147                .tag_name(),
4148            "release/1"
4149        );
4150
4151        assert!(TagRefName::from_full("refs/heads/v1.0").is_err());
4152        assert!(TagRefName::from_full("refs/tags").is_err());
4153        assert!(TagRefNameBuf::from_tag_name("bad tag").is_err());
4154    }
4155
4156    #[test]
4157    fn remote_ref_name_helpers_validate_namespace_and_components() {
4158        let remote = RemoteRefNameBuf::from_remote_branch("origin", "feature/topic")
4159            .expect("valid remote branch ref");
4160        assert_eq!(remote.as_str(), "refs/remotes/origin/feature/topic");
4161        assert_eq!(remote.short_name(), "origin/feature/topic");
4162        assert_eq!(remote.remote_name(), "origin");
4163        assert_eq!(remote.remote_branch(), Some("feature/topic"));
4164        assert_eq!(
4165            remote.as_full_ref_name().as_str(),
4166            "refs/remotes/origin/feature/topic"
4167        );
4168
4169        let head =
4170            RemoteRefName::from_full("refs/remotes/origin/HEAD").expect("valid remote HEAD ref");
4171        assert_eq!(head.remote_name(), "origin");
4172        assert_eq!(head.remote_branch(), Some("HEAD"));
4173        assert_eq!(
4174            FullRefName::new("refs/remotes/upstream/main")
4175                .expect("valid full remote ref")
4176                .as_remote()
4177                .expect("full ref is remote-tracking")
4178                .remote_name(),
4179            "upstream"
4180        );
4181
4182        let short =
4183            RemoteRefNameBuf::from_short_name("origin/main").expect("valid remote short ref");
4184        assert_eq!(short.as_str(), "refs/remotes/origin/main");
4185
4186        assert!(RemoteRefName::from_full("refs/heads/origin/main").is_err());
4187        assert!(RemoteRefName::from_full("refs/remotes/").is_err());
4188        assert!(RemoteRefNameBuf::from_remote_branch("origin/fork", "main").is_err());
4189    }
4190
4191    #[test]
4192    fn file_ref_store_writes_ref_and_reflog() {
4193        let git_dir = temp_git_dir();
4194        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4195        let oid = ObjectId::from_hex(
4196            ObjectFormat::Sha1,
4197            "ce013625030ba8dba906f756967f9e9ca394464a",
4198        )
4199        .expect("test operation should succeed");
4200        let mut tx = store.transaction();
4201        tx.update(RefUpdate {
4202            name: "refs/heads/main".into(),
4203            expected: None,
4204            new: RefTarget::Direct(oid),
4205            reflog: Some(ReflogEntry {
4206                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
4207                new_oid: oid,
4208                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
4209                message: b"update by test".to_vec(),
4210            }),
4211        });
4212        tx.commit().expect("test operation should succeed");
4213        assert_eq!(
4214            store
4215                .read_ref("refs/heads/main")
4216                .expect("test operation should succeed"),
4217            Some(RefTarget::Direct(oid))
4218        );
4219        let log = store
4220            .read_reflog("refs/heads/main")
4221            .expect("test operation should succeed");
4222        assert_eq!(log.len(), 1);
4223        assert_eq!(log[0].message, b"update by test");
4224        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4225    }
4226
4227    #[test]
4228    fn file_ref_store_applies_bundle_refs_with_reflog() {
4229        let git_dir = temp_git_dir();
4230        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4231        let old_main = ObjectId::from_hex(
4232            ObjectFormat::Sha1,
4233            "ce013625030ba8dba906f756967f9e9ca394464a",
4234        )
4235        .expect("test operation should succeed");
4236        let new_main = ObjectId::from_hex(
4237            ObjectFormat::Sha1,
4238            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4239        )
4240        .expect("test operation should succeed");
4241        let tag_oid = ObjectId::from_hex(
4242            ObjectFormat::Sha1,
4243            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
4244        )
4245        .expect("test operation should succeed");
4246        let mut tx = store.transaction();
4247        tx.update(RefUpdate {
4248            name: "refs/heads/main".into(),
4249            expected: None,
4250            new: RefTarget::Direct(old_main.clone()),
4251            reflog: None,
4252        });
4253        tx.commit().expect("test operation should succeed");
4254
4255        let applied = store
4256            .apply_bundle_ref_updates(
4257                &[
4258                    BundleRefUpdate {
4259                        name: "refs/heads/main".into(),
4260                        oid: new_main.clone(),
4261                    },
4262                    BundleRefUpdate {
4263                        name: "refs/tags/v1.0".into(),
4264                        oid: tag_oid,
4265                    },
4266                ],
4267                Some(BundleRefUpdateReflog {
4268                    committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
4269                    message: b"bundle: import refs".to_vec(),
4270                }),
4271            )
4272            .expect("test operation should succeed");
4273
4274        assert_eq!(
4275            applied,
4276            vec![
4277                AppliedBundleRefUpdate {
4278                    name: "refs/heads/main".into(),
4279                    old_oid: Some(old_main.clone()),
4280                    new_oid: new_main.clone(),
4281                },
4282                AppliedBundleRefUpdate {
4283                    name: "refs/tags/v1.0".into(),
4284                    old_oid: None,
4285                    new_oid: tag_oid,
4286                }
4287            ]
4288        );
4289        assert_eq!(
4290            store
4291                .read_ref("refs/heads/main")
4292                .expect("test operation should succeed"),
4293            Some(RefTarget::Direct(new_main.clone()))
4294        );
4295        assert_eq!(
4296            store
4297                .read_ref("refs/tags/v1.0")
4298                .expect("test operation should succeed"),
4299            Some(RefTarget::Direct(tag_oid))
4300        );
4301        let main_log = store
4302            .read_reflog("refs/heads/main")
4303            .expect("test operation should succeed");
4304        assert_eq!(main_log.len(), 1);
4305        assert_eq!(main_log[0].old_oid, old_main);
4306        assert_eq!(main_log[0].new_oid, new_main);
4307        assert_eq!(main_log[0].message, b"bundle: import refs");
4308        let tag_log = store
4309            .read_reflog("refs/tags/v1.0")
4310            .expect("test operation should succeed");
4311        assert_eq!(tag_log.len(), 1);
4312        assert_eq!(
4313            tag_log[0].old_oid,
4314            zero_oid(ObjectFormat::Sha1).expect("test operation should succeed")
4315        );
4316        assert_eq!(tag_log[0].new_oid, tag_oid);
4317        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4318    }
4319
4320    #[test]
4321    fn file_ref_store_rejects_bad_bundle_ref_before_writing() {
4322        let git_dir = temp_git_dir();
4323        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4324        let oid = ObjectId::from_hex(
4325            ObjectFormat::Sha1,
4326            "ce013625030ba8dba906f756967f9e9ca394464a",
4327        )
4328        .expect("test operation should succeed");
4329
4330        let result = store.apply_bundle_ref_updates(
4331            &[
4332                BundleRefUpdate {
4333                    name: "refs/heads/main".into(),
4334                    oid,
4335                },
4336                BundleRefUpdate {
4337                    name: "refs/heads/bad.lock".into(),
4338                    oid,
4339                },
4340            ],
4341            None,
4342        );
4343
4344        assert!(result.is_err());
4345        assert_eq!(
4346            store
4347                .read_ref("refs/heads/main")
4348                .expect("test operation should succeed"),
4349            None
4350        );
4351        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4352    }
4353
4354    #[test]
4355    fn file_ref_store_rejects_bundle_ref_over_symbolic_ref() {
4356        let git_dir = temp_git_dir();
4357        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4358        let oid = ObjectId::from_hex(
4359            ObjectFormat::Sha1,
4360            "ce013625030ba8dba906f756967f9e9ca394464a",
4361        )
4362        .expect("test operation should succeed");
4363        let mut tx = store.transaction();
4364        tx.update(RefUpdate {
4365            name: "refs/heads/main".into(),
4366            expected: None,
4367            new: RefTarget::Symbolic("refs/heads/base".into()),
4368            reflog: None,
4369        });
4370        tx.commit().expect("test operation should succeed");
4371
4372        let result = store.apply_bundle_ref_updates(
4373            &[BundleRefUpdate {
4374                name: "refs/heads/main".into(),
4375                oid,
4376            }],
4377            None,
4378        );
4379
4380        assert!(result.is_err());
4381        assert_eq!(
4382            store
4383                .read_ref("refs/heads/main")
4384                .expect("test operation should succeed"),
4385            Some(RefTarget::Symbolic("refs/heads/base".into()))
4386        );
4387        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4388    }
4389
4390    #[test]
4391    fn file_ref_store_expires_reflog_entries_by_timestamp() {
4392        let git_dir = temp_git_dir();
4393        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4394        let first = ObjectId::from_hex(
4395            ObjectFormat::Sha1,
4396            "ce013625030ba8dba906f756967f9e9ca394464a",
4397        )
4398        .expect("test operation should succeed");
4399        let second = ObjectId::from_hex(
4400            ObjectFormat::Sha1,
4401            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4402        )
4403        .expect("test operation should succeed");
4404        let mut tx = store.transaction();
4405        tx.update(RefUpdate {
4406            name: "refs/heads/main".into(),
4407            expected: None,
4408            new: RefTarget::Direct(first.clone()),
4409            reflog: Some(ReflogEntry {
4410                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
4411                new_oid: first.clone(),
4412                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
4413                message: b"old".to_vec(),
4414            }),
4415        });
4416        tx.update(RefUpdate {
4417            name: "refs/heads/main".into(),
4418            expected: None,
4419            new: RefTarget::Direct(second.clone()),
4420            reflog: Some(ReflogEntry {
4421                old_oid: first,
4422                new_oid: second.clone(),
4423                committer: b"Git Rs <sley@example.invalid> 100 +0000".to_vec(),
4424                message: b"new".to_vec(),
4425            }),
4426        });
4427        tx.commit().expect("test operation should succeed");
4428
4429        let removed = store
4430            .expire_reflog_older_than("refs/heads/main", 50)
4431            .expect("test operation should succeed");
4432        assert_eq!(removed, 1);
4433        let log = store
4434            .read_reflog("refs/heads/main")
4435            .expect("test operation should succeed");
4436        assert_eq!(log.len(), 1);
4437        assert_eq!(log[0].new_oid, second);
4438        assert_eq!(log[0].message, b"new");
4439        assert!(
4440            !git_dir
4441                .join("logs")
4442                .join("refs")
4443                .join("heads")
4444                .join("main.lock")
4445                .exists()
4446        );
4447        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4448    }
4449
4450    #[test]
4451    fn file_ref_store_creates_branch() {
4452        let git_dir = temp_git_dir();
4453        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4454        let oid = ObjectId::from_hex(
4455            ObjectFormat::Sha1,
4456            "ce013625030ba8dba906f756967f9e9ca394464a",
4457        )
4458        .expect("test operation should succeed");
4459        let branch = store
4460            .create_branch(
4461                "feature",
4462                oid,
4463                b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
4464                b"branch: Created from main".to_vec(),
4465            )
4466            .expect("test operation should succeed");
4467        assert_eq!(branch.name, "refs/heads/feature");
4468        assert_eq!(
4469            store
4470                .read_ref("refs/heads/feature")
4471                .expect("test operation should succeed"),
4472            Some(RefTarget::Direct(oid))
4473        );
4474        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4475    }
4476
4477    #[test]
4478    fn file_ref_store_deletes_loose_branch() {
4479        let git_dir = temp_git_dir();
4480        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4481        let oid = ObjectId::from_hex(
4482            ObjectFormat::Sha1,
4483            "ce013625030ba8dba906f756967f9e9ca394464a",
4484        )
4485        .expect("test operation should succeed");
4486        store
4487            .create_branch(
4488                "feature",
4489                oid,
4490                b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
4491                b"branch: Created from main".to_vec(),
4492            )
4493            .expect("test operation should succeed");
4494        let deleted = store
4495            .delete_branch("feature")
4496            .expect("test operation should succeed");
4497        assert_eq!(deleted.name, "refs/heads/feature");
4498        assert_eq!(deleted.oid, oid);
4499        assert_eq!(
4500            store
4501                .read_ref("refs/heads/feature")
4502                .expect("test operation should succeed"),
4503            None
4504        );
4505        assert!(!git_dir.join("refs").join("heads").join("feature").exists());
4506        assert!(
4507            !git_dir
4508                .join("logs")
4509                .join("refs")
4510                .join("heads")
4511                .join("feature")
4512                .exists()
4513        );
4514        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4515    }
4516
4517    #[test]
4518    fn file_ref_store_deletes_generic_loose_ref() {
4519        let git_dir = temp_git_dir();
4520        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4521        let oid = ObjectId::from_hex(
4522            ObjectFormat::Sha1,
4523            "ce013625030ba8dba906f756967f9e9ca394464a",
4524        )
4525        .expect("test operation should succeed");
4526        let mut tx = store.transaction();
4527        tx.update(RefUpdate {
4528            name: "refs/heads/topic".into(),
4529            expected: None,
4530            new: RefTarget::Direct(oid),
4531            reflog: Some(ReflogEntry {
4532                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
4533                new_oid: oid,
4534                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
4535                message: b"update by test".to_vec(),
4536            }),
4537        });
4538        tx.commit().expect("test operation should succeed");
4539        let deleted = store
4540            .delete_ref("refs/heads/topic")
4541            .expect("test operation should succeed");
4542        assert_eq!(deleted.name, "refs/heads/topic");
4543        assert_eq!(deleted.oid, oid);
4544        assert_eq!(
4545            store
4546                .read_ref("refs/heads/topic")
4547                .expect("test operation should succeed"),
4548            None
4549        );
4550        assert!(!git_dir.join("refs").join("heads").join("topic").exists());
4551        assert!(
4552            !git_dir
4553                .join("logs")
4554                .join("refs")
4555                .join("heads")
4556                .join("topic")
4557                .exists()
4558        );
4559        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4560    }
4561
4562    #[test]
4563    fn file_ref_store_delete_ref_checked_removes_reflog() {
4564        let git_dir = temp_git_dir();
4565        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4566        let oid = ObjectId::from_hex(
4567            ObjectFormat::Sha1,
4568            "ce013625030ba8dba906f756967f9e9ca394464a",
4569        )
4570        .expect("test operation should succeed");
4571        // Create the ref *with* a reflog entry so logs/refs/heads/main exists on
4572        // disk; git unlinks that file on delete rather than appending a deletion
4573        // entry, so the checked delete must remove it (mirroring delete_ref).
4574        let mut tx = store.transaction();
4575        tx.update(RefUpdate {
4576            name: "refs/heads/main".into(),
4577            expected: None,
4578            new: RefTarget::Direct(oid),
4579            reflog: Some(ReflogEntry {
4580                old_oid: zero_oid(ObjectFormat::Sha1).expect("test operation should succeed"),
4581                new_oid: oid,
4582                committer: b"Git Rs <sley@example.invalid> 0 +0000".to_vec(),
4583                message: b"create main".to_vec(),
4584            }),
4585        });
4586        tx.commit().expect("test operation should succeed");
4587        assert!(
4588            git_dir
4589                .join("logs")
4590                .join("refs")
4591                .join("heads")
4592                .join("main")
4593                .exists(),
4594            "reflog file should exist before the checked delete"
4595        );
4596
4597        let deleted = store
4598            .delete_ref_checked(DeleteRef {
4599                name: "refs/heads/main".into(),
4600                expected_old: Some(oid),
4601                reflog: Some(DeleteRefReflog {
4602                    committer: b"Git Rs <sley@example.invalid> 123 +0000".to_vec(),
4603                    message: b"delete main".to_vec(),
4604                }),
4605            })
4606            .expect("test operation should succeed");
4607
4608        assert_eq!(deleted.name, "refs/heads/main");
4609        assert_eq!(deleted.oid, oid);
4610        assert_eq!(
4611            store
4612                .read_ref("refs/heads/main")
4613                .expect("test operation should succeed"),
4614            None
4615        );
4616        // Git unlinks the reflog on delete: the file is gone and there is no
4617        // lingering deletion entry to read back.
4618        assert!(
4619            !git_dir
4620                .join("logs")
4621                .join("refs")
4622                .join("heads")
4623                .join("main")
4624                .exists(),
4625            "reflog file should be removed by the checked delete"
4626        );
4627        assert!(
4628            store
4629                .read_reflog("refs/heads/main")
4630                .expect("test operation should succeed")
4631                .is_empty()
4632        );
4633        assert!(
4634            !git_dir
4635                .join("refs")
4636                .join("heads")
4637                .join("main.lock")
4638                .exists()
4639        );
4640        assert!(!git_dir.join("packed-refs.lock").exists());
4641        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4642    }
4643
4644    #[test]
4645    fn file_ref_store_delete_ref_checked_stale_expected_leaves_ref_untouched() {
4646        let git_dir = temp_git_dir();
4647        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4648        let actual = ObjectId::from_hex(
4649            ObjectFormat::Sha1,
4650            "ce013625030ba8dba906f756967f9e9ca394464a",
4651        )
4652        .expect("test operation should succeed");
4653        let expected = ObjectId::from_hex(
4654            ObjectFormat::Sha1,
4655            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4656        )
4657        .expect("test operation should succeed");
4658        let mut tx = store.transaction();
4659        tx.update(RefUpdate {
4660            name: "refs/heads/main".into(),
4661            expected: None,
4662            new: RefTarget::Direct(actual),
4663            reflog: None,
4664        });
4665        tx.commit().expect("test operation should succeed");
4666
4667        let err = store
4668            .delete_ref_checked(DeleteRef {
4669                name: "refs/heads/main".into(),
4670                expected_old: Some(expected),
4671                reflog: None,
4672            })
4673            .expect_err("stale expected must fail");
4674
4675        assert!(matches!(
4676            err,
4677            RefDeleteError::ExpectedMismatch {
4678                expected: Some(got_expected),
4679                actual: Some(got_actual),
4680            } if got_expected == expected && got_actual == actual
4681        ));
4682        assert_eq!(
4683            store
4684                .read_ref("refs/heads/main")
4685                .expect("test operation should succeed"),
4686            Some(RefTarget::Direct(actual))
4687        );
4688        assert!(
4689            !git_dir
4690                .join("refs")
4691                .join("heads")
4692                .join("main.lock")
4693                .exists()
4694        );
4695        assert!(!git_dir.join("packed-refs.lock").exists());
4696        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4697    }
4698
4699    #[test]
4700    fn file_ref_store_delete_ref_checked_missing_returns_not_found() {
4701        let git_dir = temp_git_dir();
4702        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4703
4704        let err = store
4705            .delete_ref_checked(DeleteRef {
4706                name: "refs/heads/missing".into(),
4707                expected_old: None,
4708                reflog: None,
4709            })
4710            .expect_err("missing ref must fail");
4711
4712        assert!(matches!(err, RefDeleteError::NotFound));
4713        assert!(
4714            !git_dir
4715                .join("refs")
4716                .join("heads")
4717                .join("missing.lock")
4718                .exists()
4719        );
4720        assert!(!git_dir.join("packed-refs.lock").exists());
4721        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4722    }
4723
4724    #[test]
4725    fn file_ref_store_delete_ref_checked_removes_packed_ref() {
4726        let git_dir = temp_git_dir();
4727        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4728        let oid = ObjectId::from_hex(
4729            ObjectFormat::Sha1,
4730            "ce013625030ba8dba906f756967f9e9ca394464a",
4731        )
4732        .expect("test operation should succeed");
4733        let other = ObjectId::from_hex(
4734            ObjectFormat::Sha1,
4735            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
4736        )
4737        .expect("test operation should succeed");
4738        store
4739            .write_packed_refs(&[
4740                PackedRef {
4741                    reference: Ref {
4742                        name: "refs/heads/main".into(),
4743                        target: RefTarget::Direct(oid),
4744                    },
4745                    peeled: None,
4746                },
4747                PackedRef {
4748                    reference: Ref {
4749                        name: "refs/heads/other".into(),
4750                        target: RefTarget::Direct(other),
4751                    },
4752                    peeled: None,
4753                },
4754            ])
4755            .expect("test operation should succeed");
4756
4757        store
4758            .delete_ref_checked(DeleteRef {
4759                name: "refs/heads/main".into(),
4760                expected_old: Some(oid),
4761                reflog: None,
4762            })
4763            .expect("test operation should succeed");
4764
4765        assert_eq!(
4766            store
4767                .read_ref("refs/heads/main")
4768                .expect("test operation should succeed"),
4769            None
4770        );
4771        assert_eq!(
4772            store
4773                .read_ref("refs/heads/other")
4774                .expect("test operation should succeed"),
4775            Some(RefTarget::Direct(other))
4776        );
4777        let packed =
4778            fs::read_to_string(git_dir.join("packed-refs")).expect("test operation should succeed");
4779        assert!(!packed.contains("refs/heads/main"));
4780        assert!(packed.contains("refs/heads/other"));
4781        assert!(
4782            !git_dir
4783                .join("refs")
4784                .join("heads")
4785                .join("main.lock")
4786                .exists()
4787        );
4788        assert!(!git_dir.join("packed-refs.lock").exists());
4789        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4790    }
4791
4792    #[test]
4793    fn file_ref_store_delete_ref_checked_lock_conflict_returns_locked() {
4794        let git_dir = temp_git_dir();
4795        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4796        let oid = ObjectId::from_hex(
4797            ObjectFormat::Sha1,
4798            "ce013625030ba8dba906f756967f9e9ca394464a",
4799        )
4800        .expect("test operation should succeed");
4801        let mut tx = store.transaction();
4802        tx.update(RefUpdate {
4803            name: "refs/heads/main".into(),
4804            expected: None,
4805            new: RefTarget::Direct(oid),
4806            reflog: None,
4807        });
4808        tx.commit().expect("test operation should succeed");
4809        fs::write(
4810            git_dir.join("refs").join("heads").join("main.lock"),
4811            b"held\n",
4812        )
4813        .expect("test operation should succeed");
4814
4815        let err = store
4816            .delete_ref_checked(DeleteRef {
4817                name: "refs/heads/main".into(),
4818                expected_old: Some(oid),
4819                reflog: None,
4820            })
4821            .expect_err("held lock must fail");
4822
4823        assert!(matches!(err, RefDeleteError::Locked));
4824        assert_eq!(
4825            store
4826                .read_ref("refs/heads/main")
4827                .expect("test operation should succeed"),
4828            Some(RefTarget::Direct(oid))
4829        );
4830        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4831    }
4832
4833    #[test]
4834    fn file_ref_store_reports_current_branch() {
4835        let git_dir = temp_git_dir();
4836        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/main\n")
4837            .expect("test operation should succeed");
4838        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4839        assert_eq!(
4840            store
4841                .current_branch_ref()
4842                .expect("test operation should succeed"),
4843            Some("refs/heads/main".into())
4844        );
4845        assert_eq!(
4846            store
4847                .current_branch()
4848                .expect("test operation should succeed"),
4849            Some("main".into())
4850        );
4851        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4852    }
4853
4854    #[test]
4855    fn file_ref_store_resolves_linked_worktree_head_through_common_refs() {
4856        let common = temp_git_dir();
4857        let admin = common.join("worktrees").join("linked");
4858        fs::create_dir_all(&admin).expect("test operation should succeed");
4859        fs::write(admin.join("commondir"), "../..\n").expect("test operation should succeed");
4860        fs::write(admin.join("HEAD"), b"ref: refs/heads/topic\n")
4861            .expect("test operation should succeed");
4862        let oid = ObjectId::from_hex(
4863            ObjectFormat::Sha256,
4864            "08ffba112b648c22b5425f01bec2c37ffc524c4d48ef04337779df3973733050",
4865        )
4866        .expect("test operation should succeed");
4867        fs::create_dir_all(common.join("refs").join("heads"))
4868            .expect("test operation should succeed");
4869        fs::write(
4870            common.join("refs").join("heads").join("topic"),
4871            format!("{oid}\n"),
4872        )
4873        .expect("test operation should succeed");
4874
4875        let store = FileRefStore::new(&admin, ObjectFormat::Sha256);
4876        assert_eq!(
4877            store
4878                .read_ref("HEAD")
4879                .expect("test operation should succeed"),
4880            Some(RefTarget::Symbolic("refs/heads/topic".into()))
4881        );
4882        assert_eq!(
4883            store
4884                .read_ref("refs/heads/topic")
4885                .expect("test operation should succeed"),
4886            Some(RefTarget::Direct(oid))
4887        );
4888
4889        fs::remove_dir_all(common).expect("test operation should succeed");
4890    }
4891
4892    #[test]
4893    fn file_ref_store_creates_tag() {
4894        let git_dir = temp_git_dir();
4895        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4896        let oid = ObjectId::from_hex(
4897            ObjectFormat::Sha1,
4898            "ce013625030ba8dba906f756967f9e9ca394464a",
4899        )
4900        .expect("test operation should succeed");
4901        let tag = store
4902            .create_tag("v1.0", oid)
4903            .expect("test operation should succeed");
4904        assert_eq!(tag.name, "refs/tags/v1.0");
4905        assert_eq!(
4906            store
4907                .read_ref("refs/tags/v1.0")
4908                .expect("test operation should succeed"),
4909            Some(RefTarget::Direct(oid))
4910        );
4911        assert!(
4912            store
4913                .read_reflog("refs/tags/v1.0")
4914                .expect("test operation should succeed")
4915                .is_empty()
4916        );
4917        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4918    }
4919
4920    #[test]
4921    fn file_ref_store_deletes_loose_tag() {
4922        let git_dir = temp_git_dir();
4923        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4924        let oid = ObjectId::from_hex(
4925            ObjectFormat::Sha1,
4926            "ce013625030ba8dba906f756967f9e9ca394464a",
4927        )
4928        .expect("test operation should succeed");
4929        store
4930            .create_tag("v1.0", oid)
4931            .expect("test operation should succeed");
4932        let deleted = store
4933            .delete_tag("v1.0")
4934            .expect("test operation should succeed");
4935        assert_eq!(deleted.name, "refs/tags/v1.0");
4936        assert_eq!(deleted.oid, oid);
4937        assert_eq!(
4938            store
4939                .read_ref("refs/tags/v1.0")
4940                .expect("test operation should succeed"),
4941            None
4942        );
4943        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
4944        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4945    }
4946
4947    #[test]
4948    fn file_ref_store_reads_packed_ref() {
4949        let git_dir = temp_git_dir();
4950        fs::write(
4951            git_dir.join("packed-refs"),
4952            b"ce013625030ba8dba906f756967f9e9ca394464a refs/heads/main\n",
4953        )
4954        .expect("test operation should succeed");
4955        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4956        assert!(matches!(
4957            store
4958                .read_ref("refs/heads/main")
4959                .expect("test operation should succeed"),
4960            Some(RefTarget::Direct(_))
4961        ));
4962        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4963    }
4964
4965    #[test]
4966    fn file_ref_store_lists_loose_refs_over_packed_refs() {
4967        let git_dir = temp_git_dir();
4968        fs::write(
4969            git_dir.join("packed-refs"),
4970            b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 refs/heads/main\n",
4971        )
4972        .expect("test operation should succeed");
4973        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4974        let oid = ObjectId::from_hex(
4975            ObjectFormat::Sha1,
4976            "ce013625030ba8dba906f756967f9e9ca394464a",
4977        )
4978        .expect("test operation should succeed");
4979        let mut tx = store.transaction();
4980        tx.update(RefUpdate {
4981            name: "refs/heads/main".into(),
4982            expected: None,
4983            new: RefTarget::Direct(oid),
4984            reflog: None,
4985        });
4986        tx.commit().expect("test operation should succeed");
4987        let refs = store.list_refs().expect("test operation should succeed");
4988        assert_eq!(refs.len(), 1);
4989        assert_eq!(refs[0].target, RefTarget::Direct(oid));
4990        fs::remove_dir_all(git_dir).expect("test operation should succeed");
4991    }
4992
4993    #[test]
4994    fn file_ref_store_writes_packed_refs() {
4995        let git_dir = temp_git_dir();
4996        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
4997        let oid = ObjectId::from_hex(
4998            ObjectFormat::Sha1,
4999            "ce013625030ba8dba906f756967f9e9ca394464a",
5000        )
5001        .expect("test operation should succeed");
5002        store
5003            .write_packed_refs(&[PackedRef {
5004                reference: Ref {
5005                    name: "refs/heads/main".into(),
5006                    target: RefTarget::Direct(oid),
5007                },
5008                peeled: None,
5009            }])
5010            .expect("test operation should succeed");
5011        assert_eq!(
5012            store
5013                .read_ref("refs/heads/main")
5014                .expect("test operation should succeed"),
5015            Some(RefTarget::Direct(oid))
5016        );
5017        let refs = store.list_refs().expect("test operation should succeed");
5018        assert_eq!(refs.len(), 1);
5019        assert_eq!(refs[0].target, RefTarget::Direct(oid));
5020        assert!(git_dir.join("packed-refs").exists());
5021        assert!(!git_dir.join("packed-refs.lock").exists());
5022        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5023    }
5024
5025    #[test]
5026    fn file_ref_store_checks_ref_prefix_in_packed_refs() {
5027        let git_dir = temp_git_dir();
5028        fs::write(
5029            git_dir.join("packed-refs"),
5030            b"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 refs/heads/main\n\
5031              ce013625030ba8dba906f756967f9e9ca394464a refs/replace/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\n",
5032        )
5033        .expect("test operation should succeed");
5034        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5035        assert!(
5036            store
5037                .has_refs_with_prefix("refs/replace/")
5038                .expect("test operation should succeed")
5039        );
5040        assert!(
5041            !store
5042                .has_refs_with_prefix("refs/notes/")
5043                .expect("test operation should succeed")
5044        );
5045        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5046    }
5047
5048    #[test]
5049    fn file_ref_store_checks_ref_prefix_in_loose_refs() {
5050        let git_dir = temp_git_dir();
5051        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5052        let oid = ObjectId::from_hex(
5053            ObjectFormat::Sha1,
5054            "ce013625030ba8dba906f756967f9e9ca394464a",
5055        )
5056        .expect("test operation should succeed");
5057        let mut tx = store.transaction();
5058        tx.update(RefUpdate {
5059            name: "refs/replace/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391".into(),
5060            expected: None,
5061            new: RefTarget::Direct(oid),
5062            reflog: None,
5063        });
5064        tx.commit().expect("test operation should succeed");
5065        assert!(
5066            store
5067                .has_refs_with_prefix("refs/replace/")
5068                .expect("test operation should succeed")
5069        );
5070        assert!(
5071            !store
5072                .has_refs_with_prefix("refs/notes/")
5073                .expect("test operation should succeed")
5074        );
5075        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5076    }
5077
5078    #[test]
5079    fn file_ref_store_reads_reftable_stack_and_ignores_dummy_head() {
5080        let git_dir = temp_git_dir();
5081        write_reftable_config(&git_dir);
5082        fs::write(git_dir.join("HEAD"), b"ref: refs/heads/.invalid\n")
5083            .expect("test operation should succeed");
5084        let head_oid = ObjectId::from_hex(
5085            ObjectFormat::Sha1,
5086            "ce013625030ba8dba906f756967f9e9ca394464a",
5087        )
5088        .expect("test operation should succeed");
5089        let tag_oid = ObjectId::from_hex(
5090            ObjectFormat::Sha1,
5091            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5092        )
5093        .expect("test operation should succeed");
5094        let peeled_oid = ObjectId::from_hex(
5095            ObjectFormat::Sha1,
5096            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5097        )
5098        .expect("test operation should succeed");
5099        write_reftable_stack(
5100            &git_dir,
5101            &[(
5102                "000000000001-000000000001-rust.ref",
5103                vec![
5104                    sley_formats::ReftableRefRecord {
5105                        name: "HEAD".into(),
5106                        update_index: 1,
5107                        value: ReftableRefValue::Symbolic("refs/heads/main".into()),
5108                    },
5109                    sley_formats::ReftableRefRecord {
5110                        name: "refs/heads/main".into(),
5111                        update_index: 1,
5112                        value: ReftableRefValue::Direct(head_oid),
5113                    },
5114                    sley_formats::ReftableRefRecord {
5115                        name: "refs/tags/v1.0".into(),
5116                        update_index: 1,
5117                        value: ReftableRefValue::Peeled {
5118                            target: tag_oid,
5119                            peeled: peeled_oid,
5120                        },
5121                    },
5122                ],
5123            )],
5124        );
5125
5126        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5127        assert_eq!(
5128            store
5129                .read_ref("HEAD")
5130                .expect("test operation should succeed"),
5131            Some(RefTarget::Symbolic("refs/heads/main".into()))
5132        );
5133        assert_eq!(
5134            store
5135                .read_ref("refs/heads/main")
5136                .expect("test operation should succeed"),
5137            Some(RefTarget::Direct(head_oid))
5138        );
5139        assert_eq!(
5140            store
5141                .read_ref("refs/tags/v1.0")
5142                .expect("test operation should succeed"),
5143            Some(RefTarget::Direct(tag_oid))
5144        );
5145        let refs = store.list_refs().expect("test operation should succeed");
5146        assert_eq!(
5147            refs,
5148            vec![
5149                Ref {
5150                    name: "refs/heads/main".into(),
5151                    target: RefTarget::Direct(head_oid),
5152                },
5153                Ref {
5154                    name: "refs/tags/v1.0".into(),
5155                    target: RefTarget::Direct(tag_oid),
5156                },
5157            ]
5158        );
5159
5160        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5161    }
5162
5163    #[test]
5164    fn file_ref_store_applies_reftable_stack_overrides_and_deletions() {
5165        let git_dir = temp_git_dir();
5166        write_reftable_config(&git_dir);
5167        let first = ObjectId::from_hex(
5168            ObjectFormat::Sha1,
5169            "ce013625030ba8dba906f756967f9e9ca394464a",
5170        )
5171        .expect("test operation should succeed");
5172        let second = ObjectId::from_hex(
5173            ObjectFormat::Sha1,
5174            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5175        )
5176        .expect("test operation should succeed");
5177        write_reftable_stack(
5178            &git_dir,
5179            &[
5180                (
5181                    "000000000001-000000000001-base.ref",
5182                    vec![
5183                        sley_formats::ReftableRefRecord {
5184                            name: "refs/heads/main".into(),
5185                            update_index: 1,
5186                            value: ReftableRefValue::Direct(first),
5187                        },
5188                        sley_formats::ReftableRefRecord {
5189                            name: "refs/heads/topic".into(),
5190                            update_index: 1,
5191                            value: ReftableRefValue::Direct(second.clone()),
5192                        },
5193                    ],
5194                ),
5195                (
5196                    "000000000002-000000000002-tip.ref",
5197                    vec![
5198                        sley_formats::ReftableRefRecord {
5199                            name: "refs/heads/main".into(),
5200                            update_index: 2,
5201                            value: ReftableRefValue::Direct(second.clone()),
5202                        },
5203                        sley_formats::ReftableRefRecord {
5204                            name: "refs/heads/topic".into(),
5205                            update_index: 2,
5206                            value: ReftableRefValue::Deletion,
5207                        },
5208                    ],
5209                ),
5210            ],
5211        );
5212
5213        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5214        assert_eq!(
5215            store
5216                .read_ref("refs/heads/main")
5217                .expect("test operation should succeed"),
5218            Some(RefTarget::Direct(second.clone()))
5219        );
5220        assert_eq!(
5221            store
5222                .read_ref("refs/heads/topic")
5223                .expect("test operation should succeed"),
5224            None
5225        );
5226        assert_eq!(
5227            store.list_refs().expect("test operation should succeed"),
5228            vec![Ref {
5229                name: "refs/heads/main".into(),
5230                target: RefTarget::Direct(second),
5231            }]
5232        );
5233
5234        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5235    }
5236
5237    #[test]
5238    fn file_ref_store_writes_reftable_transaction_table() {
5239        let git_dir = temp_git_dir();
5240        write_reftable_config(&git_dir);
5241        let first = ObjectId::from_hex(
5242            ObjectFormat::Sha1,
5243            "ce013625030ba8dba906f756967f9e9ca394464a",
5244        )
5245        .expect("test operation should succeed");
5246        let second = ObjectId::from_hex(
5247            ObjectFormat::Sha1,
5248            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5249        )
5250        .expect("test operation should succeed");
5251        write_reftable_stack(
5252            &git_dir,
5253            &[(
5254                "000000000001-000000000001-base.ref",
5255                vec![sley_formats::ReftableRefRecord {
5256                    name: "refs/heads/main".into(),
5257                    update_index: 1,
5258                    value: ReftableRefValue::Direct(first),
5259                }],
5260            )],
5261        );
5262
5263        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5264        let mut tx = store.transaction();
5265        tx.update(RefUpdate {
5266            name: "HEAD".into(),
5267            expected: None,
5268            new: RefTarget::Symbolic("refs/heads/main".into()),
5269            reflog: None,
5270        });
5271        tx.update(RefUpdate {
5272            name: "refs/heads/main".into(),
5273            expected: None,
5274            new: RefTarget::Direct(second.clone()),
5275            reflog: None,
5276        });
5277        tx.commit().expect("test operation should succeed");
5278
5279        assert_eq!(
5280            store
5281                .read_ref("HEAD")
5282                .expect("test operation should succeed"),
5283            Some(RefTarget::Symbolic("refs/heads/main".into()))
5284        );
5285        assert_eq!(
5286            store
5287                .read_ref("refs/heads/main")
5288                .expect("test operation should succeed"),
5289            Some(RefTarget::Direct(second.clone()))
5290        );
5291        assert_eq!(
5292            store
5293                .list_refs()
5294                .expect("test operation should succeed")
5295                .len(),
5296            1
5297        );
5298        assert!(!git_dir.join("HEAD").exists());
5299        let tables = fs::read_to_string(git_dir.join("reftable").join("tables.list"))
5300            .expect("test operation should succeed");
5301        assert_eq!(tables.lines().count(), 2);
5302        let last = tables
5303            .lines()
5304            .last()
5305            .expect("test operation should succeed");
5306        // The rust-written table name follows git's `0x%012x-0x%012x-%08x.ref`
5307        // shape (reftable/stack.c::format_name) so `git fsck` accepts it; the
5308        // earlier `-sley-<nanos>` token tripped `badReftableTableName`.
5309        assert!(
5310            last.starts_with("0x") && last.ends_with(".ref"),
5311            "expected git-format reftable name in tables.list, got {tables}"
5312        );
5313        assert!(
5314            reftable_table_name_is_valid(last),
5315            "rust-written reftable name must parse as git's hex format, got {last}"
5316        );
5317
5318        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5319    }
5320
5321    #[test]
5322    fn file_ref_store_deletes_reftable_refs_with_tombstones() {
5323        let git_dir = temp_git_dir();
5324        write_reftable_config(&git_dir);
5325        let oid = ObjectId::from_hex(
5326            ObjectFormat::Sha1,
5327            "ce013625030ba8dba906f756967f9e9ca394464a",
5328        )
5329        .expect("test operation should succeed");
5330        write_reftable_stack(
5331            &git_dir,
5332            &[(
5333                "000000000001-000000000001-base.ref",
5334                vec![
5335                    sley_formats::ReftableRefRecord {
5336                        name: "refs/heads/main".into(),
5337                        update_index: 1,
5338                        value: ReftableRefValue::Direct(oid),
5339                    },
5340                    sley_formats::ReftableRefRecord {
5341                        name: "refs/alias/main".into(),
5342                        update_index: 1,
5343                        value: ReftableRefValue::Symbolic("refs/heads/main".into()),
5344                    },
5345                ],
5346            )],
5347        );
5348
5349        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5350        assert!(
5351            store
5352                .delete_symbolic_ref("refs/alias/main")
5353                .expect("test operation should succeed")
5354        );
5355        assert_eq!(
5356            store
5357                .read_ref("refs/alias/main")
5358                .expect("test operation should succeed"),
5359            None
5360        );
5361        let deleted = store
5362            .delete_ref("refs/heads/main")
5363            .expect("test operation should succeed");
5364        assert_eq!(deleted.oid, oid);
5365        assert_eq!(
5366            store
5367                .read_ref("refs/heads/main")
5368                .expect("test operation should succeed"),
5369            None
5370        );
5371        assert!(
5372            store
5373                .list_refs()
5374                .expect("test operation should succeed")
5375                .is_empty()
5376        );
5377        let tables = fs::read_to_string(git_dir.join("reftable").join("tables.list"))
5378            .expect("test operation should succeed");
5379        assert_eq!(tables.lines().count(), 3);
5380
5381        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5382    }
5383
5384    #[test]
5385    fn file_ref_store_deletes_packed_branch() {
5386        let git_dir = temp_git_dir();
5387        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5388        let branch_oid = ObjectId::from_hex(
5389            ObjectFormat::Sha1,
5390            "ce013625030ba8dba906f756967f9e9ca394464a",
5391        )
5392        .expect("test operation should succeed");
5393        let tag_oid = ObjectId::from_hex(
5394            ObjectFormat::Sha1,
5395            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5396        )
5397        .expect("test operation should succeed");
5398        store
5399            .write_packed_refs(&[
5400                PackedRef {
5401                    reference: Ref {
5402                        name: "refs/heads/feature".into(),
5403                        target: RefTarget::Direct(branch_oid),
5404                    },
5405                    peeled: None,
5406                },
5407                PackedRef {
5408                    reference: Ref {
5409                        name: "refs/tags/v1.0".into(),
5410                        target: RefTarget::Direct(tag_oid),
5411                    },
5412                    peeled: None,
5413                },
5414            ])
5415            .expect("test operation should succeed");
5416        let deleted = store
5417            .delete_branch("feature")
5418            .expect("test operation should succeed");
5419        assert_eq!(deleted.name, "refs/heads/feature");
5420        assert_eq!(deleted.oid, branch_oid);
5421        assert_eq!(
5422            store
5423                .read_ref("refs/heads/feature")
5424                .expect("test operation should succeed"),
5425            None
5426        );
5427        assert_eq!(
5428            store
5429                .read_ref("refs/tags/v1.0")
5430                .expect("test operation should succeed"),
5431            Some(RefTarget::Direct(tag_oid))
5432        );
5433        assert!(!git_dir.join("packed-refs.lock").exists());
5434        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5435    }
5436
5437    #[test]
5438    fn file_ref_store_deletes_packed_tag() {
5439        let git_dir = temp_git_dir();
5440        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5441        let oid = ObjectId::from_hex(
5442            ObjectFormat::Sha1,
5443            "ce013625030ba8dba906f756967f9e9ca394464a",
5444        )
5445        .expect("test operation should succeed");
5446        store
5447            .write_packed_refs(&[PackedRef {
5448                reference: Ref {
5449                    name: "refs/tags/v1.0".into(),
5450                    target: RefTarget::Direct(oid),
5451                },
5452                peeled: None,
5453            }])
5454            .expect("test operation should succeed");
5455        let deleted = store
5456            .delete_tag("v1.0")
5457            .expect("test operation should succeed");
5458        assert_eq!(deleted.name, "refs/tags/v1.0");
5459        assert_eq!(deleted.oid, oid);
5460        assert_eq!(
5461            store
5462                .read_ref("refs/tags/v1.0")
5463                .expect("test operation should succeed"),
5464            None
5465        );
5466        assert!(!git_dir.join("packed-refs.lock").exists());
5467        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5468    }
5469
5470    #[test]
5471    fn file_ref_store_packs_loose_refs_and_prunes() {
5472        let git_dir = temp_git_dir();
5473        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5474        let main_oid = ObjectId::from_hex(
5475            ObjectFormat::Sha1,
5476            "ce013625030ba8dba906f756967f9e9ca394464a",
5477        )
5478        .expect("test operation should succeed");
5479        let tag_oid = ObjectId::from_hex(
5480            ObjectFormat::Sha1,
5481            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5482        )
5483        .expect("test operation should succeed");
5484        let mut tx = store.transaction();
5485        tx.update(RefUpdate {
5486            name: "refs/heads/main".into(),
5487            expected: None,
5488            new: RefTarget::Direct(main_oid),
5489            reflog: None,
5490        });
5491        tx.update(RefUpdate {
5492            name: "refs/tags/v1.0".into(),
5493            expected: None,
5494            new: RefTarget::Direct(tag_oid),
5495            reflog: None,
5496        });
5497        tx.commit().expect("test operation should succeed");
5498
5499        let packed = store
5500            .pack_refs(true)
5501            .expect("test operation should succeed");
5502        assert_eq!(packed.len(), 2);
5503        assert_eq!(
5504            store
5505                .read_ref("refs/heads/main")
5506                .expect("test operation should succeed"),
5507            Some(RefTarget::Direct(main_oid))
5508        );
5509        assert_eq!(
5510            store
5511                .read_ref("refs/tags/v1.0")
5512                .expect("test operation should succeed"),
5513            Some(RefTarget::Direct(tag_oid))
5514        );
5515        assert!(!git_dir.join("refs").join("heads").join("main").exists());
5516        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
5517        assert!(git_dir.join("packed-refs").exists());
5518        assert!(!git_dir.join("packed-refs.lock").exists());
5519        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5520    }
5521
5522    #[test]
5523    fn file_ref_store_packs_loose_refs_without_pruning() {
5524        let git_dir = temp_git_dir();
5525        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5526        let oid = ObjectId::from_hex(
5527            ObjectFormat::Sha1,
5528            "ce013625030ba8dba906f756967f9e9ca394464a",
5529        )
5530        .expect("test operation should succeed");
5531        let mut tx = store.transaction();
5532        tx.update(RefUpdate {
5533            name: "refs/heads/main".into(),
5534            expected: None,
5535            new: RefTarget::Direct(oid),
5536            reflog: None,
5537        });
5538        tx.commit().expect("test operation should succeed");
5539
5540        let packed = store
5541            .pack_refs(false)
5542            .expect("test operation should succeed");
5543        assert_eq!(packed.len(), 1);
5544        assert!(git_dir.join("refs").join("heads").join("main").exists());
5545        assert_eq!(
5546            store
5547                .read_ref("refs/heads/main")
5548                .expect("test operation should succeed"),
5549            Some(RefTarget::Direct(oid))
5550        );
5551        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5552    }
5553
5554    #[test]
5555    fn file_ref_store_packs_loose_refs_with_peeled_ids() {
5556        let git_dir = temp_git_dir();
5557        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5558        let tag_oid = ObjectId::from_hex(
5559            ObjectFormat::Sha1,
5560            "ce013625030ba8dba906f756967f9e9ca394464a",
5561        )
5562        .expect("test operation should succeed");
5563        let peeled_oid = ObjectId::from_hex(
5564            ObjectFormat::Sha1,
5565            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5566        )
5567        .expect("test operation should succeed");
5568        let mut tx = store.transaction();
5569        tx.update(RefUpdate {
5570            name: "refs/tags/v1.0".into(),
5571            expected: None,
5572            new: RefTarget::Direct(tag_oid),
5573            reflog: None,
5574        });
5575        tx.commit().expect("test operation should succeed");
5576
5577        let packed = store
5578            .pack_refs_with_peeler(true, |name, oid| {
5579                if name == "refs/tags/v1.0" && oid == &tag_oid {
5580                    Ok(Some(peeled_oid))
5581                } else {
5582                    Ok(None)
5583                }
5584            })
5585            .expect("test operation should succeed");
5586        assert_eq!(packed.len(), 1);
5587        assert_eq!(packed[0].peeled, Some(peeled_oid));
5588        let bytes =
5589            fs::read_to_string(git_dir.join("packed-refs")).expect("test operation should succeed");
5590        assert!(bytes.contains(&format!("^{peeled_oid}\n")));
5591        assert!(!git_dir.join("refs").join("tags").join("v1.0").exists());
5592        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5593    }
5594
5595    fn reflog_entry(new_oid: &ObjectId, timestamp: i64, message: &str) -> ReflogEntry {
5596        ReflogEntry {
5597            old_oid: zero_oid(new_oid.format()).expect("test operation should succeed"),
5598            new_oid: *new_oid,
5599            committer: format!("Git Rs <sley@example.invalid> {timestamp} +0000").into_bytes(),
5600            message: message.as_bytes().to_vec(),
5601        }
5602    }
5603
5604    #[test]
5605    fn expire_reflog_drops_old_entries_and_keeps_latest() {
5606        let oid_a = ObjectId::from_hex(
5607            ObjectFormat::Sha1,
5608            "ce013625030ba8dba906f756967f9e9ca394464a",
5609        )
5610        .expect("test operation should succeed");
5611        let oid_b = ObjectId::from_hex(
5612            ObjectFormat::Sha1,
5613            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5614        )
5615        .expect("test operation should succeed");
5616        let oid_c = ObjectId::from_hex(
5617            ObjectFormat::Sha1,
5618            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5619        )
5620        .expect("test operation should succeed");
5621        let entries = vec![
5622            reflog_entry(&oid_a, 10, "oldest"),
5623            reflog_entry(&oid_b, 100, "middle"),
5624            reflog_entry(&oid_c, 20, "latest"),
5625        ];
5626
5627        // Cutoff drops the oldest entry; the most recent entry survives even
5628        // though its timestamp (20) is below the cutoff (50).
5629        let retained =
5630            expire_reflog(&entries, 50, None, |_| true).expect("test operation should succeed");
5631        assert_eq!(retained.len(), 2);
5632        assert_eq!(retained[0].message, b"middle");
5633        assert_eq!(retained[1].message, b"latest");
5634    }
5635
5636    #[test]
5637    fn expire_reflog_applies_stricter_unreachable_cutoff() {
5638        let reachable = ObjectId::from_hex(
5639            ObjectFormat::Sha1,
5640            "ce013625030ba8dba906f756967f9e9ca394464a",
5641        )
5642        .expect("test operation should succeed");
5643        let unreachable = ObjectId::from_hex(
5644            ObjectFormat::Sha1,
5645            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5646        )
5647        .expect("test operation should succeed");
5648        let tip = ObjectId::from_hex(
5649            ObjectFormat::Sha1,
5650            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5651        )
5652        .expect("test operation should succeed");
5653        // Both candidate entries sit above the lenient cutoff (50) but below the
5654        // stricter unreachable cutoff (150). Only the unreachable one is dropped.
5655        let entries = vec![
5656            reflog_entry(&reachable, 100, "reachable"),
5657            reflog_entry(&unreachable, 100, "unreachable"),
5658            reflog_entry(&tip, 200, "tip"),
5659        ];
5660        let retained = expire_reflog(&entries, 50, Some(150), |oid| {
5661            oid == &reachable || oid == &tip
5662        })
5663        .expect("test operation should succeed");
5664        assert_eq!(retained.len(), 2);
5665        assert_eq!(retained[0].message, b"reachable");
5666        assert_eq!(retained[1].message, b"tip");
5667    }
5668
5669    #[test]
5670    fn expire_reflog_keeps_single_entry_below_cutoff() {
5671        let oid = ObjectId::from_hex(
5672            ObjectFormat::Sha1,
5673            "ce013625030ba8dba906f756967f9e9ca394464a",
5674        )
5675        .expect("test operation should succeed");
5676        let entries = vec![reflog_entry(&oid, 1, "only")];
5677        let retained = expire_reflog(&entries, i64::MAX, Some(i64::MAX), |_| false)
5678            .expect("test operation should succeed");
5679        assert_eq!(retained.len(), 1);
5680        assert_eq!(retained[0].message, b"only");
5681    }
5682
5683    #[test]
5684    fn file_ref_store_expire_reflog_file_rewrites_and_dry_runs() {
5685        let git_dir = temp_git_dir();
5686        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5687        let first = ObjectId::from_hex(
5688            ObjectFormat::Sha1,
5689            "ce013625030ba8dba906f756967f9e9ca394464a",
5690        )
5691        .expect("test operation should succeed");
5692        let second = ObjectId::from_hex(
5693            ObjectFormat::Sha1,
5694            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5695        )
5696        .expect("test operation should succeed");
5697        store
5698            .write_reflog(
5699                "refs/heads/main",
5700                &[
5701                    reflog_entry(&first, 10, "old"),
5702                    reflog_entry(&second, 100, "new"),
5703                ],
5704            )
5705            .expect("test operation should succeed");
5706
5707        // Dry run reports the removal count without touching the file.
5708        let would_remove = store
5709            .expire_reflog_file("refs/heads/main", 50, None, false, |_| true)
5710            .expect("test operation should succeed");
5711        assert_eq!(would_remove, 1);
5712        assert_eq!(
5713            store
5714                .read_reflog("refs/heads/main")
5715                .expect("test operation should succeed")
5716                .len(),
5717            2
5718        );
5719
5720        // Opt-in rewrite drops the stale entry and leaves the latest.
5721        let removed = store
5722            .expire_reflog_file("refs/heads/main", 50, None, true, |_| true)
5723            .expect("test operation should succeed");
5724        assert_eq!(removed, 1);
5725        let log = store
5726            .read_reflog("refs/heads/main")
5727            .expect("test operation should succeed");
5728        assert_eq!(log.len(), 1);
5729        assert_eq!(log[0].new_oid, second);
5730        assert!(
5731            !git_dir
5732                .join("logs")
5733                .join("refs")
5734                .join("heads")
5735                .join("main.lock")
5736                .exists()
5737        );
5738        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5739    }
5740
5741    #[test]
5742    fn file_ref_transaction_commits_all_refs_atomically() {
5743        let git_dir = temp_git_dir();
5744        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5745        let main_oid = ObjectId::from_hex(
5746            ObjectFormat::Sha1,
5747            "ce013625030ba8dba906f756967f9e9ca394464a",
5748        )
5749        .expect("test operation should succeed");
5750        let topic_oid = ObjectId::from_hex(
5751            ObjectFormat::Sha1,
5752            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5753        )
5754        .expect("test operation should succeed");
5755        let tag_oid = ObjectId::from_hex(
5756            ObjectFormat::Sha1,
5757            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5758        )
5759        .expect("test operation should succeed");
5760        let mut tx = store.transaction();
5761        tx.update(RefUpdate {
5762            name: "refs/heads/main".into(),
5763            expected: None,
5764            new: RefTarget::Direct(main_oid),
5765            reflog: Some(reflog_entry(&main_oid, 0, "create main")),
5766        });
5767        tx.update(RefUpdate {
5768            name: "refs/heads/topic".into(),
5769            expected: None,
5770            new: RefTarget::Direct(topic_oid),
5771            reflog: None,
5772        });
5773        tx.update(RefUpdate {
5774            name: "refs/tags/v1.0".into(),
5775            expected: None,
5776            new: RefTarget::Direct(tag_oid),
5777            reflog: None,
5778        });
5779        tx.commit().expect("test operation should succeed");
5780
5781        assert_eq!(
5782            store
5783                .read_ref("refs/heads/main")
5784                .expect("test operation should succeed"),
5785            Some(RefTarget::Direct(main_oid))
5786        );
5787        assert_eq!(
5788            store
5789                .read_ref("refs/heads/topic")
5790                .expect("test operation should succeed"),
5791            Some(RefTarget::Direct(topic_oid))
5792        );
5793        assert_eq!(
5794            store
5795                .read_ref("refs/tags/v1.0")
5796                .expect("test operation should succeed"),
5797            Some(RefTarget::Direct(tag_oid))
5798        );
5799        let main_log = store
5800            .read_reflog("refs/heads/main")
5801            .expect("test operation should succeed");
5802        assert_eq!(main_log.len(), 1);
5803        assert_eq!(main_log[0].new_oid, main_oid);
5804        // No lock files survive a successful commit.
5805        assert!(
5806            !git_dir
5807                .join("refs")
5808                .join("heads")
5809                .join("main.lock")
5810                .exists()
5811        );
5812        assert!(
5813            !git_dir
5814                .join("refs")
5815                .join("heads")
5816                .join("topic.lock")
5817                .exists()
5818        );
5819        assert!(!git_dir.join("refs").join("tags").join("v1.0.lock").exists());
5820        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5821    }
5822
5823    #[test]
5824    fn file_ref_transaction_rolls_back_all_refs_on_expected_mismatch() {
5825        let git_dir = temp_git_dir();
5826        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5827        let old_topic = ObjectId::from_hex(
5828            ObjectFormat::Sha1,
5829            "ce013625030ba8dba906f756967f9e9ca394464a",
5830        )
5831        .expect("test operation should succeed");
5832        let new_main = ObjectId::from_hex(
5833            ObjectFormat::Sha1,
5834            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5835        )
5836        .expect("test operation should succeed");
5837        let new_tag = ObjectId::from_hex(
5838            ObjectFormat::Sha1,
5839            "18f002b4484b838b205a48b1e9e6763ba5e3a607",
5840        )
5841        .expect("test operation should succeed");
5842        let wrong_expected = ObjectId::from_hex(
5843            ObjectFormat::Sha1,
5844            "0000000000000000000000000000000000000001",
5845        )
5846        .expect("test operation should succeed");
5847
5848        // Seed an existing topic ref so the failing update has a real prior value
5849        // to be compared against (and left untouched).
5850        let mut seed = store.transaction();
5851        seed.update(RefUpdate {
5852            name: "refs/heads/topic".into(),
5853            expected: None,
5854            new: RefTarget::Direct(old_topic.clone()),
5855            reflog: None,
5856        });
5857        seed.commit().expect("test operation should succeed");
5858
5859        let mut tx = store.transaction();
5860        // 1st ref: brand new, would succeed in isolation.
5861        tx.update(RefUpdate {
5862            name: "refs/heads/main".into(),
5863            expected: None,
5864            new: RefTarget::Direct(new_main.clone()),
5865            reflog: Some(reflog_entry(&new_main, 0, "create main")),
5866        });
5867        // 2nd ref: expected value does not match on disk -> whole tx must abort.
5868        tx.update(RefUpdate {
5869            name: "refs/heads/topic".into(),
5870            expected: Some(RefTarget::Direct(wrong_expected)),
5871            new: RefTarget::Direct(new_main.clone()),
5872            reflog: None,
5873        });
5874        // 3rd ref: brand new, must not be written because the tx aborts.
5875        tx.update(RefUpdate {
5876            name: "refs/tags/v1.0".into(),
5877            expected: None,
5878            new: RefTarget::Direct(new_tag),
5879            reflog: None,
5880        });
5881        let result = tx.commit();
5882        assert!(result.is_err());
5883
5884        // Nothing changed: the new refs were never created and the existing one
5885        // keeps its original value.
5886        assert_eq!(
5887            store
5888                .read_ref("refs/heads/main")
5889                .expect("test operation should succeed"),
5890            None
5891        );
5892        assert_eq!(
5893            store
5894                .read_ref("refs/heads/topic")
5895                .expect("test operation should succeed"),
5896            Some(RefTarget::Direct(old_topic))
5897        );
5898        assert_eq!(
5899            store
5900                .read_ref("refs/tags/v1.0")
5901                .expect("test operation should succeed"),
5902            None
5903        );
5904        assert!(
5905            store
5906                .read_reflog("refs/heads/main")
5907                .expect("test operation should succeed")
5908                .is_empty()
5909        );
5910
5911        // All lock files were released.
5912        assert!(
5913            !git_dir
5914                .join("refs")
5915                .join("heads")
5916                .join("main.lock")
5917                .exists()
5918        );
5919        assert!(
5920            !git_dir
5921                .join("refs")
5922                .join("heads")
5923                .join("topic.lock")
5924                .exists()
5925        );
5926        assert!(!git_dir.join("refs").join("tags").join("v1.0.lock").exists());
5927        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5928    }
5929
5930    #[test]
5931    fn file_ref_transaction_mixes_update_and_delete() {
5932        let git_dir = temp_git_dir();
5933        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5934        let old_main = ObjectId::from_hex(
5935            ObjectFormat::Sha1,
5936            "ce013625030ba8dba906f756967f9e9ca394464a",
5937        )
5938        .expect("test operation should succeed");
5939        let new_topic = ObjectId::from_hex(
5940            ObjectFormat::Sha1,
5941            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5942        )
5943        .expect("test operation should succeed");
5944        let mut seed = store.transaction();
5945        seed.update(RefUpdate {
5946            name: "refs/heads/main".into(),
5947            expected: None,
5948            new: RefTarget::Direct(old_main),
5949            reflog: None,
5950        });
5951        seed.commit().expect("test operation should succeed");
5952
5953        let mut tx = store.transaction();
5954        tx.update(RefUpdate {
5955            name: "refs/heads/topic".into(),
5956            expected: None,
5957            new: RefTarget::Direct(new_topic),
5958            reflog: None,
5959        });
5960        tx.delete_with_precondition(
5961            "refs/heads/main",
5962            RefDeletePrecondition::Direct(Some(old_main)),
5963            None,
5964        );
5965        tx.commit().expect("test operation should succeed");
5966
5967        assert_eq!(
5968            store
5969                .read_ref("refs/heads/main")
5970                .expect("test operation should succeed"),
5971            None
5972        );
5973        assert_eq!(
5974            store
5975                .read_ref("refs/heads/topic")
5976                .expect("test operation should succeed"),
5977            Some(RefTarget::Direct(new_topic))
5978        );
5979        fs::remove_dir_all(git_dir).expect("test operation should succeed");
5980    }
5981
5982    #[test]
5983    fn file_ref_transaction_stale_delete_rolls_back_update() {
5984        let git_dir = temp_git_dir();
5985        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
5986        let old_oid = ObjectId::from_hex(
5987            ObjectFormat::Sha1,
5988            "ce013625030ba8dba906f756967f9e9ca394464a",
5989        )
5990        .expect("test operation should succeed");
5991        let new_oid = ObjectId::from_hex(
5992            ObjectFormat::Sha1,
5993            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
5994        )
5995        .expect("test operation should succeed");
5996        let mut seed = store.transaction();
5997        for name in ["refs/heads/main", "refs/heads/topic"] {
5998            seed.update(RefUpdate {
5999                name: name.into(),
6000                expected: None,
6001                new: RefTarget::Direct(old_oid),
6002                reflog: None,
6003            });
6004        }
6005        seed.commit().expect("test operation should succeed");
6006
6007        let mut tx = store.transaction();
6008        tx.update(RefUpdate {
6009            name: "refs/heads/topic".into(),
6010            expected: None,
6011            new: RefTarget::Direct(new_oid),
6012            reflog: None,
6013        });
6014        tx.delete_with_precondition(
6015            "refs/heads/main",
6016            RefDeletePrecondition::Direct(Some(new_oid)),
6017            None,
6018        );
6019        let err = tx.commit().expect_err("stale delete must abort");
6020        assert!(err.to_string().contains("expected ref refs/heads/main"));
6021
6022        assert_eq!(
6023            store
6024                .read_ref("refs/heads/main")
6025                .expect("test operation should succeed"),
6026            Some(RefTarget::Direct(old_oid))
6027        );
6028        assert_eq!(
6029            store
6030                .read_ref("refs/heads/topic")
6031                .expect("test operation should succeed"),
6032            Some(RefTarget::Direct(old_oid))
6033        );
6034        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6035    }
6036
6037    #[test]
6038    fn file_ref_transaction_rejects_duplicate_mixed_ref() {
6039        let git_dir = temp_git_dir();
6040        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6041        let oid = ObjectId::from_hex(
6042            ObjectFormat::Sha1,
6043            "ce013625030ba8dba906f756967f9e9ca394464a",
6044        )
6045        .expect("test operation should succeed");
6046        let mut tx = store.transaction();
6047        tx.update(RefUpdate {
6048            name: "refs/heads/main".into(),
6049            expected: None,
6050            new: RefTarget::Direct(oid),
6051            reflog: None,
6052        });
6053        tx.delete_with_precondition("refs/heads/main", RefDeletePrecondition::Any, None);
6054
6055        let err = tx.commit().expect_err("duplicate ref must fail");
6056        assert!(err.to_string().contains("refs/heads/main"));
6057        assert_eq!(
6058            store
6059                .read_ref("refs/heads/main")
6060                .expect("test operation should succeed"),
6061            None
6062        );
6063        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6064    }
6065
6066    #[test]
6067    fn file_ref_transaction_deletes_symbolic_ref_with_immediate_expectation() {
6068        let git_dir = temp_git_dir();
6069        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6070        let oid = ObjectId::from_hex(
6071            ObjectFormat::Sha1,
6072            "ce013625030ba8dba906f756967f9e9ca394464a",
6073        )
6074        .expect("test operation should succeed");
6075        let mut seed = store.transaction();
6076        seed.update(RefUpdate {
6077            name: "refs/heads/main".into(),
6078            expected: None,
6079            new: RefTarget::Direct(oid),
6080            reflog: None,
6081        });
6082        seed.update(RefUpdate {
6083            name: "refs/aliases/main".into(),
6084            expected: None,
6085            new: RefTarget::Symbolic("refs/heads/main".into()),
6086            reflog: None,
6087        });
6088        seed.commit().expect("test operation should succeed");
6089
6090        let mut tx = store.transaction();
6091        tx.delete_with_precondition(
6092            "refs/aliases/main",
6093            RefDeletePrecondition::Immediate(RefTarget::Symbolic("refs/heads/main".into())),
6094            None,
6095        );
6096        tx.commit().expect("test operation should succeed");
6097
6098        assert_eq!(
6099            store
6100                .read_ref("refs/aliases/main")
6101                .expect("test operation should succeed"),
6102            None
6103        );
6104        assert_eq!(
6105            store
6106                .read_ref("refs/heads/main")
6107                .expect("test operation should succeed"),
6108            Some(RefTarget::Direct(oid))
6109        );
6110        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6111    }
6112
6113    #[test]
6114    fn file_ref_transaction_rolls_back_delete_after_late_write_failure() {
6115        let git_dir = temp_git_dir();
6116        let store = FileRefStore::new(&git_dir, ObjectFormat::Sha1);
6117        let old_oid = ObjectId::from_hex(
6118            ObjectFormat::Sha1,
6119            "ce013625030ba8dba906f756967f9e9ca394464a",
6120        )
6121        .expect("test operation should succeed");
6122        let new_oid = ObjectId::from_hex(
6123            ObjectFormat::Sha1,
6124            "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391",
6125        )
6126        .expect("test operation should succeed");
6127        let mut seed = store.transaction();
6128        for name in ["refs/heads/main", "refs/heads/topic"] {
6129            seed.update(RefUpdate {
6130                name: name.into(),
6131                expected: None,
6132                new: RefTarget::Direct(old_oid),
6133                reflog: None,
6134            });
6135        }
6136        seed.commit().expect("test operation should succeed");
6137
6138        set_fail_loose_commit_action_for_test(Some(1));
6139        let mut tx = store.transaction();
6140        tx.delete_with_precondition(
6141            "refs/heads/main",
6142            RefDeletePrecondition::Direct(Some(old_oid)),
6143            None,
6144        );
6145        tx.update(RefUpdate {
6146            name: "refs/heads/topic".into(),
6147            expected: None,
6148            new: RefTarget::Direct(new_oid),
6149            reflog: None,
6150        });
6151        let err = tx.commit().expect_err("injected failure must abort");
6152        assert!(
6153            err.to_string()
6154                .contains("injected loose ref transaction failure")
6155        );
6156
6157        assert_eq!(
6158            store
6159                .read_ref("refs/heads/main")
6160                .expect("test operation should succeed"),
6161            Some(RefTarget::Direct(old_oid))
6162        );
6163        assert_eq!(
6164            store
6165                .read_ref("refs/heads/topic")
6166                .expect("test operation should succeed"),
6167            Some(RefTarget::Direct(old_oid))
6168        );
6169        assert!(
6170            !git_dir
6171                .join("refs")
6172                .join("heads")
6173                .join("main.lock")
6174                .exists()
6175        );
6176        assert!(
6177            !git_dir
6178                .join("refs")
6179                .join("heads")
6180                .join("topic.lock")
6181                .exists()
6182        );
6183        fs::remove_dir_all(git_dir).expect("test operation should succeed");
6184    }
6185
6186    fn temp_git_dir() -> PathBuf {
6187        let path = std::env::temp_dir().join(format!(
6188            "sley-refs-{}-{}",
6189            std::process::id(),
6190            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
6191        ));
6192        fs::create_dir_all(&path).expect("test operation should succeed");
6193        path
6194    }
6195
6196    fn zero_oid(format: ObjectFormat) -> Result<ObjectId> {
6197        Ok(ObjectId::null(format))
6198    }
6199
6200    fn write_reftable_config(git_dir: &Path) {
6201        fs::write(
6202            git_dir.join("config"),
6203            b"[core]\n\trepositoryformatversion = 1\n[extensions]\n\trefStorage = reftable\n",
6204        )
6205        .expect("test operation should succeed");
6206    }
6207
6208    fn write_reftable_stack(
6209        git_dir: &Path,
6210        tables: &[(&str, Vec<sley_formats::ReftableRefRecord>)],
6211    ) {
6212        let reftable_dir = git_dir.join("reftable");
6213        fs::create_dir_all(&reftable_dir).expect("test operation should succeed");
6214        let mut list = String::new();
6215        for (idx, (name, refs)) in tables.iter().enumerate() {
6216            let update_index = (idx + 1) as u64;
6217            let bytes = sley_formats::Reftable::write_ref_only(
6218                ObjectFormat::Sha1,
6219                update_index,
6220                update_index,
6221                refs,
6222            )
6223            .expect("test operation should succeed");
6224            fs::write(reftable_dir.join(name), bytes).expect("test operation should succeed");
6225            list.push_str(name);
6226            list.push('\n');
6227        }
6228        fs::write(reftable_dir.join("tables.list"), list).expect("test operation should succeed");
6229    }
6230}