Skip to main content

grit_lib/refs/
mod.rs

1//! Reference storage — files backend + reftable backend.
2//!
3//! Git stores references as text files under `<git-dir>/refs/` (and
4//! `<git-dir>/packed-refs` for the packed backend).  Each loose ref file
5//! contains either:
6//!
7//! - A 40-character hex SHA-1 followed by a newline, **or**
8//! - The string `"ref: <target>\n"` for symbolic refs.
9//!
10//! `HEAD` is a special case: it is normally a symbolic ref but may also be
11//! detached (pointing directly at a commit hash).
12//!
13//! When `extensions.refStorage = reftable`, the reftable backend is used
14//! instead.  The public API is the same; dispatch is handled internally.
15//!
16//! Library guide: [mod](https://grit-scm.com/docs/library/refs)
17//!
18//! ```rust
19//! use grit_lib::repo::{InitOptions, Repository};
20//! # fn main() -> grit_lib::error::Result<()> {
21//! let dir = tempfile::tempdir()?;
22//! let _repo = Repository::init(dir.path(), &InitOptions::default())?;
23//! assert!(_repo.refs().read_raw("HEAD")?.is_some());
24//!
25//! # Ok(())
26//! # }
27//! ```
28
29pub mod handle;
30pub mod listing;
31pub mod query_reachability;
32pub mod shorten;
33pub mod store;
34pub mod version_sort;
35
36pub use shorten::{
37    expand_ref_dwim_full_name, peel_symref_to_terminal_ref, shorten_unambiguous,
38    warn_ambiguous_refs_from_config,
39};
40
41mod domain_reexports;
42mod on_store;
43pub use domain_reexports::*;
44pub use handle::References;
45pub use on_store::update_branch_for_commit_on_store;
46pub(crate) use on_store::{
47    append_reflog_on_store, delete_ref_on_store, update_detached_head_for_commit_on_store,
48    write_symbolic_ref_on_store,
49};
50
51/// In-process ref-transaction and loose-object prune helpers.
52pub mod gc {
53    pub use crate::gc::*;
54}
55
56use std::collections::{BTreeSet, HashMap, HashSet};
57use std::fs;
58use std::io;
59use std::ops::ControlFlow;
60use std::path::{Path, PathBuf};
61use store::{
62    open_ref_store, Expected, RawRef as StoreRawRef, RefCaseOnlyRename, RefEntry, RefReflogRename,
63    RefStorageFormat, RefStore, RefTransaction, RefUpdate, RefUpdateFlags, ReflogUpdate,
64};
65
66use crate::check_ref_format::{check_refname_format, RefNameOptions};
67use crate::config::ConfigSet;
68use crate::error::{Error, RefLockError, Result};
69use crate::ident::Signature;
70use crate::objects::{HashAlgo, ObjectId};
71use crate::pack;
72use store::format_reflog_identity;
73
74/// Maximum symbolic ref hops when resolving a ref (Git `SYMREF_MAXDEPTH`).
75pub const SYMREF_MAXDEPTH: usize = 5;
76
77fn map_public_ref_store_error(err: Error, refname: &str) -> Error {
78    match err {
79        Error::RefStore(store::RefStoreError::SymrefLoop) => {
80            Error::InvalidRef(format!("ref symlink too deep: {refname}"))
81        }
82        other => other,
83    }
84}
85
86/// Loose-files checks shared by legacy [`write_ref_at_storage`] and RefStore-backed writes.
87///
88/// Only meaningful for the files backend; callers check the store format first.
89fn files_legacy_write_precheck(git_dir: &Path, refname: &str) -> Result<()> {
90    let storage_dir = ref_storage_dir(git_dir, refname);
91    let stor = crate::ref_namespace::storage_ref_name(refname);
92    let path = storage_dir.join(stor);
93    remove_empty_ref_directory(&path);
94    if fs::symlink_metadata(&path)
95        .map(|m| m.file_type().is_dir())
96        .unwrap_or(false)
97    {
98        let display = ref_path_for_display(&path);
99        return Err(crate::error::RefLockError::DirectoryInTheWay {
100            refname: refname.to_owned(),
101            path: display,
102        }
103        .into());
104    }
105    if path.is_file() && matches!(read_ref_file(&path), Err(Error::InvalidRef(_))) {
106        return Err(Error::InvalidRef(format!(
107            "cannot update ref '{refname}': reference broken"
108        )));
109    }
110    Ok(())
111}
112
113fn legacy_ref_update_flags(refname: &str) -> RefUpdateFlags {
114    let mut flags = RefUpdateFlags::default();
115    // Direct updates to `HEAD` detach or reattach the worktree HEAD file; do not peel symrefs.
116    if refname == "HEAD" {
117        flags.no_deref = true;
118    }
119    flags
120}
121
122pub(crate) fn commit_store_update(git_dir: &Path, update: RefUpdate) -> Result<()> {
123    let store = open_ref_store(git_dir)?;
124    commit_ref_update_on_store(store.as_ref(), update)
125}
126
127/// Apply one ref update through an already-selected [`RefStore`] (for example the store on
128/// [`crate::repo::Repository`] after [`crate::repo::Repository::with_ref_store`]).
129///
130/// # Errors
131///
132/// Propagates validation, compare-and-swap, and backend errors.
133pub fn commit_ref_update_on_store(store: &dyn RefStore, update: RefUpdate) -> Result<()> {
134    if let Some(ref log) = update.reflog {
135        validate_reflog_update(log)?;
136    }
137    let refname = update.name.clone();
138    let txn = RefTransaction::new()
139        .update(update)
140        .map_err(crate::error::Error::from)?;
141    let prepared = store
142        .prepare(txn)
143        .map_err(|e| map_public_ref_store_error(Error::from(e), &refname))?;
144    prepared
145        .commit()
146        .map_err(|e| map_public_ref_store_error(Error::from(e), &refname))
147}
148
149/// Point `refname` at `oid` using `store`, optionally running loose-files prechecks when
150/// `store` uses the files backend under `git_dir`.
151///
152/// # Errors
153///
154/// Same as [`write_ref`], except updates go through `store` instead of reopening storage from
155/// `git_dir`.
156pub fn update_direct_ref_on_store(
157    store: &dyn RefStore,
158    git_dir: &Path,
159    refname: &str,
160    oid: &ObjectId,
161) -> Result<()> {
162    store_write_value_on_store(
163        store,
164        git_dir,
165        refname,
166        Some(StoreRawRef::Direct(*oid)),
167        Expected::Any,
168        None,
169    )
170}
171
172pub(crate) fn store_write_value_on_store(
173    store: &dyn RefStore,
174    git_dir: &Path,
175    refname: &str,
176    new_value: Option<StoreRawRef>,
177    expected: Expected,
178    reflog: Option<ReflogUpdate>,
179) -> Result<()> {
180    ensure_refname_safe_for_storage(refname)?;
181    if new_value.is_some() && store.format() == RefStorageFormat::Files {
182        files_legacy_write_precheck(git_dir, refname)?;
183    }
184    commit_ref_update_on_store(
185        store,
186        RefUpdate {
187            name: refname.to_owned(),
188            new_value,
189            expected,
190            reflog,
191            flags: legacy_ref_update_flags(refname),
192        },
193    )
194}
195
196/// Apply several ref updates in one [`RefStore::prepare`] / commit (all-or-nothing).
197///
198/// Used by batch ref paths (`fetch`, `push`, `receive-pack`, [`crate::gc::update_refs`]).
199///
200/// # Errors
201///
202/// Returns [`crate::refs::store::RefStoreError`] for duplicate names, CAS failures,
203/// lock conflicts, and namespace conflicts. Propagates I/O and corrupt-store failures.
204pub fn commit_ref_store_batch(git_dir: &Path, updates: &[RefUpdate]) -> Result<()> {
205    if updates.is_empty() {
206        return Ok(());
207    }
208    let store = open_ref_store(git_dir)?;
209    commit_ref_updates_on_store(store.as_ref(), updates, &[])
210}
211
212/// Apply several ref updates atomically through an existing [`RefStore`].
213///
214/// # Errors
215///
216/// Propagates validation, compare-and-swap, and backend errors from [`RefStore::prepare`].
217pub fn commit_ref_updates_on_store(
218    store: &dyn RefStore,
219    updates: &[RefUpdate],
220    reflog_renames: &[RefReflogRename],
221) -> Result<()> {
222    commit_ref_batch_on_store(store, updates, reflog_renames, &[])
223}
224
225/// Apply ref updates, reflog renames, and optional case-only ref renames atomically.
226///
227/// # Errors
228///
229/// Propagates validation, compare-and-swap, and backend errors from [`RefStore::prepare`].
230pub fn commit_ref_batch_on_store(
231    store: &dyn RefStore,
232    updates: &[RefUpdate],
233    reflog_renames: &[RefReflogRename],
234    case_only_renames: &[RefCaseOnlyRename],
235) -> Result<()> {
236    if updates.is_empty() && reflog_renames.is_empty() && case_only_renames.is_empty() {
237        return Ok(());
238    }
239    commit_owned_ref_batch_on_store(
240        store,
241        updates.to_vec(),
242        reflog_renames.to_vec(),
243        case_only_renames.to_vec(),
244    )
245}
246
247/// [`commit_ref_updates_on_store`] taking ownership of `updates`, so a large batch is not copied.
248///
249/// # Errors
250///
251/// Same as [`commit_ref_updates_on_store`].
252pub(crate) fn commit_owned_ref_updates_on_store(
253    store: &dyn RefStore,
254    updates: Vec<RefUpdate>,
255) -> Result<()> {
256    if updates.is_empty() {
257        return Ok(());
258    }
259    commit_owned_ref_batch_on_store(store, updates, Vec::new(), Vec::new())
260}
261
262fn commit_owned_ref_batch_on_store(
263    store: &dyn RefStore,
264    updates: Vec<RefUpdate>,
265    reflog_renames: Vec<RefReflogRename>,
266    case_only_renames: Vec<RefCaseOnlyRename>,
267) -> Result<()> {
268    for update in &updates {
269        if let Some(ref log) = update.reflog {
270            validate_reflog_update(log)?;
271        }
272    }
273    for rename in &reflog_renames {
274        validate_reflog_update(&rename.append)?;
275    }
276    let label = updates
277        .first()
278        .map(|u| u.name.clone())
279        .or_else(|| reflog_renames.first().map(|r| r.from.clone()))
280        .or_else(|| case_only_renames.first().map(|r| r.from.clone()))
281        .unwrap_or_else(|| "refs".to_owned());
282    let mut txn = RefTransaction::from_updates(updates).map_err(|e| {
283        let name = match &e {
284            store::RefStoreError::DuplicateUpdate { name } => name.clone(),
285            _ => label.clone(),
286        };
287        map_public_ref_store_error(Error::from(e), &name)
288    })?;
289    for rename in reflog_renames {
290        txn = txn.with_reflog_rename(rename);
291    }
292    for rename in case_only_renames {
293        txn = txn.with_case_only_rename(rename);
294    }
295    let prepared = store
296        .prepare(txn)
297        .map_err(|e| map_public_ref_store_error(Error::from(e), &label))?;
298    prepared
299        .commit()
300        .map_err(|e| map_public_ref_store_error(Error::from(e), &label))
301}
302
303fn store_write_value(
304    git_dir: &Path,
305    refname: &str,
306    new_value: Option<StoreRawRef>,
307    expected: Expected,
308    reflog: Option<ReflogUpdate>,
309) -> Result<()> {
310    let store = open_ref_store(git_dir)?;
311    store_write_value_on_store(
312        store.as_ref(),
313        git_dir,
314        refname,
315        new_value,
316        expected,
317        reflog,
318    )
319}
320
321fn collect_resolved_refs(store: &dyn RefStore, prefix: &str) -> Result<Vec<(String, ObjectId)>> {
322    collect_resolved_ref_entries(store, |f| store.for_each_ref(prefix, f))
323}
324
325/// Resolve each entry `for_each` yields to an object id through `store`.
326fn collect_resolved_ref_entries(
327    store: &dyn RefStore,
328    for_each: impl FnOnce(&mut dyn FnMut(&RefEntry) -> ControlFlow<()>) -> Result<()>,
329) -> Result<Vec<(String, ObjectId)>> {
330    let mut results = Vec::new();
331    for_each(&mut |entry: &RefEntry| {
332        let oid = entry
333            .peeled
334            .or_else(|| match &entry.value {
335                StoreRawRef::Direct(oid) => Some(*oid),
336                StoreRawRef::Symbolic(target) => store.resolve(target).ok(),
337            })
338            .or_else(|| store.resolve(&entry.name).ok());
339        if let Some(oid) = oid {
340            results.push((entry.name.clone(), oid));
341        }
342        ControlFlow::Continue(())
343    })?;
344    Ok(results)
345}
346
347/// A symbolic or direct reference.
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub enum Ref {
350    /// Direct reference: stores an [`ObjectId`].
351    Direct(ObjectId),
352    /// Symbolic reference: stores the name of the target ref.
353    Symbolic(String),
354}
355
356/// Read a single reference file from `path`.
357///
358/// # Errors
359///
360/// - [`Error::InvalidRef`] if the file content is not a valid ref.
361/// - [`Error::Io`] on filesystem errors.
362pub fn read_ref_file(path: &Path) -> Result<Ref> {
363    if let Ok(target) = fs::read_link(path) {
364        return Ok(Ref::Symbolic(target.to_string_lossy().into_owned()));
365    }
366
367    let content = match fs::read_to_string(path) {
368        Ok(c) => c,
369        // `refs/heads/master` can be a directory when a branch named `master/...` exists, and a
370        // probe like `refs/remotes/v1` can hit ENOTDIR when `refs/remotes` is a file. Treat both
371        // like a missing loose ref so optional DWIM candidates fall through cleanly.
372        Err(e)
373            if e.kind() == io::ErrorKind::IsADirectory
374                || e.kind() == io::ErrorKind::NotADirectory
375                || e.raw_os_error() == Some(libc::EISDIR)
376                || e.raw_os_error() == Some(libc::ENOTDIR) =>
377        {
378            return Err(Error::Io(io::Error::new(io::ErrorKind::NotFound, e)));
379        }
380        Err(e) => return Err(Error::Io(e)),
381    };
382    let content = content.trim_end_matches('\n');
383    parse_ref_content(content)
384}
385
386/// Parse the content of a ref file (without trailing newline).
387pub(crate) fn parse_ref_content(content: &str) -> Result<Ref> {
388    if let Some(target) = content.strip_prefix("ref: ") {
389        Ok(Ref::Symbolic(target.trim().to_owned()))
390    } else if let Some(token) = content.split_whitespace().next() {
391        if ObjectId::is_full_hex(token) {
392            let oid: ObjectId = token.parse()?;
393            Ok(Ref::Direct(oid))
394        } else if content == "unknown-oid" {
395            const PLACEHOLDER: &[u8; 20] = b"GritUnknownOidPlc!X!";
396            let oid = ObjectId::from_bytes(PLACEHOLDER)?;
397            Ok(Ref::Direct(oid))
398        } else {
399            Err(Error::InvalidRef(content.to_owned()))
400        }
401    } else {
402        Err(Error::InvalidRef(content.to_owned()))
403    }
404}
405
406/// Resolve a reference to its target [`ObjectId`], following symbolic refs.
407///
408/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
409///
410/// # Parameters
411///
412/// - `git_dir` — path to the git directory.
413/// - `refname` — reference name (e.g. `"HEAD"`, `"refs/heads/main"`).
414///
415/// # Errors
416///
417/// - [`Error::RefStore`] with [`RefStoreError::NotFound`](store::RefStoreError::NotFound) if
418///   `refname`, or the target at the end of its symbolic chain, does not exist.
419/// - [`Error::InvalidRef`] if the ref is malformed or forms a cycle.
420pub fn resolve_ref(git_dir: &Path, refname: &str) -> Result<ObjectId> {
421    open_ref_store(git_dir)?
422        .resolve(refname)
423        .map_err(|err| map_public_ref_store_error(err, refname))
424}
425
426/// Determine the common git directory for worktree-aware ref resolution.
427///
428/// If `<git_dir>/commondir` exists, its contents point to the shared
429/// git directory. Returns `None` when git_dir is already the common dir.
430pub fn common_dir(git_dir: &Path) -> Option<PathBuf> {
431    let commondir_file = git_dir.join("commondir");
432    if !commondir_file.is_file() {
433        return None;
434    }
435    let raw = fs::read_to_string(commondir_file).ok()?;
436    let rel = raw.trim();
437    // Match Git: `commondir` may be relative to this gitdir or an absolute path (see
438    // `git worktree add` and `refs/files-backend.c`).
439    let path = if Path::new(rel).is_absolute() {
440        PathBuf::from(rel)
441    } else {
442        git_dir.join(rel)
443    };
444    path.canonicalize().ok()
445}
446
447/// Outcome of a single storage-level ref lookup (Git `refs_read_raw_ref` style).
448///
449/// This checks whether a ref **name** exists in the ref store without applying
450/// DWIM rules. A symbolic ref is considered to exist if its ref file (or
451/// reftable record) is present, even when the target is missing.
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum RawRefLookup {
454    /// A loose ref file, packed ref line, or reftable record exists for this name.
455    Exists,
456    /// No ref is recorded under this exact name.
457    NotFound,
458    /// A path component exists as a directory where a ref file was expected (e.g. `refs/heads`).
459    IsDirectory,
460}
461
462/// Return whether `refname` exists as a ref in the repository's ref storage.
463///
464/// This matches `git refs exists` / `git show-ref --exists`: no DWIM, no
465/// resolution of symbolic targets. Dispatches to the reftable backend when
466/// configured.
467///
468/// # Parameters
469///
470/// - `git_dir` — path to the git directory (worktree gitdir or bare `.git`).
471/// - `refname` — full ref name (e.g. `HEAD`, `refs/heads/main`, `CHERRY_PICK_HEAD`).
472///
473/// # Errors
474///
475/// Propagates I/O and reftable errors other than "not found".
476pub fn read_raw_ref(git_dir: &Path, refname: &str) -> Result<RawRefLookup> {
477    let store = open_ref_store(git_dir)?;
478    match store.read_raw(refname)? {
479        Some(_) => Ok(RawRefLookup::Exists),
480        None => {
481            let (storage_dir, stor_name) =
482                crate::worktree_ref::resolve_ref_storage(git_dir, refname);
483            let path = storage_dir.join(&stor_name);
484            if fs::symlink_metadata(&path)
485                .map(|m| m.is_dir())
486                .unwrap_or(false)
487            {
488                Ok(RawRefLookup::IsDirectory)
489            } else {
490                Ok(RawRefLookup::NotFound)
491            }
492        }
493    }
494}
495
496/// Lock file path for a loose ref file (`<refpath>.lock`), matching Git's naming for nested refs.
497#[must_use]
498pub fn lock_path_for_ref(path: &Path) -> PathBuf {
499    let mut s = path.as_os_str().to_owned();
500    s.push(".lock");
501    PathBuf::from(s)
502}
503
504/// First packed ref name strictly under `refname/` (sorted `packed-refs` prefix scan).
505fn first_packed_descendant_ref(store: &Path, refname: &str) -> Result<Option<String>> {
506    let leaf_dir = format!("{refname}/");
507    let map = read_packed_refs_map_for_prefix(store, refname)?;
508    let sorted: BTreeSet<String> = map.keys().cloned().collect();
509    Ok(sorted
510        .range(leaf_dir.clone()..)
511        .next()
512        .filter(|name| name.starts_with(&leaf_dir))
513        .cloned())
514}
515
516fn packed_ref_name_exists(git_dir: &Path, refname: &str) -> Result<bool> {
517    Ok(lookup_packed_ref(git_dir, refname)?.is_some())
518}
519
520/// Whether any name in `sorted_packed` namespace-conflicts with `candidate`.
521///
522/// Uses prefix walks and one sorted range probe — `O(log n)` in the number of
523/// packed refs — instead of scanning every packed name.
524pub(crate) fn packed_refname_namespace_conflict(
525    sorted_packed: &BTreeSet<String>,
526    candidate: &str,
527) -> bool {
528    let segments: Vec<&str> = candidate.split('/').filter(|s| !s.is_empty()).collect();
529    if segments.len() > 1 {
530        let mut dirname = String::new();
531        for part in &segments[..segments.len() - 1] {
532            if !dirname.is_empty() {
533                dirname.push('/');
534            }
535            dirname.push_str(part);
536            if sorted_packed.contains(&dirname) {
537                return true;
538            }
539        }
540    }
541
542    let leaf_dir = format!("{candidate}/");
543    if let Some(existing) = sorted_packed.range(leaf_dir.clone()..).next() {
544        if existing.starts_with(&leaf_dir) {
545            return true;
546        }
547    }
548    false
549}
550
551fn packed_ref_namespace_conflict(git_dir: &Path, refname: &str) -> Result<bool> {
552    Ok(PackedRefs::load(git_dir)?.has_namespace_conflict(refname))
553}
554
555/// Returns true if `packed-refs` in the ref storage directory for `refname` contains that name.
556///
557/// Used to mirror Git's `is_packed_transaction_needed` behaviour: deleting a ref may open a nested
558/// packed-refs transaction whose abort runs the `reference-transaction` hook in the `aborted`
559/// state between `preparing` and `prepared` on the main transaction.
560///
561/// Returns `false` for reftable repositories and for `HEAD` (packed-refs does not store `HEAD`).
562///
563/// # Errors
564///
565/// Propagates I/O errors reading `packed-refs`.
566pub fn packed_refs_entry_exists(git_dir: &Path, refname: &str) -> Result<bool> {
567    if refname == "HEAD" {
568        return Ok(false);
569    }
570    if open_ref_store(git_dir)?.format() != RefStorageFormat::Files {
571        return Ok(false);
572    }
573    let storage_dir = ref_storage_dir(git_dir, refname);
574    packed_ref_name_exists(&storage_dir, refname)
575}
576
577/// Why a reference name cannot be created (Git `refs_verify_refname_available` style).
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub enum RefnameUnavailable {
580    /// An ancestor ref already exists in the store (e.g. `refs/foo` blocks `refs/foo/bar`).
581    AncestorExists {
582        /// Existing ref that blocks creation.
583        blocking: String,
584        /// Ref the caller tried to create.
585        new_ref: String,
586    },
587    /// A descendant ref already exists (e.g. `refs/foo/bar` blocks `refs/foo`).
588    DescendantExists {
589        /// Existing ref under `new_ref/`.
590        blocking: String,
591        /// Ref the caller tried to create.
592        new_ref: String,
593    },
594    /// Two refnames in the same batch are mutually incompatible (parent vs child).
595    SameBatch {
596        /// Ref being validated (Git prints this first).
597        refname: String,
598        /// Other ref in the batch (parent dirname or descendant name).
599        other: String,
600    },
601}
602
603impl RefnameUnavailable {
604    /// Suffix after `cannot lock ref '<display_ref>': ` for stderr (no trailing newline).
605    #[must_use]
606    pub fn lock_message_suffix(&self) -> String {
607        match self {
608            RefnameUnavailable::AncestorExists { blocking, new_ref } => {
609                format!("'{blocking}' exists; cannot create '{new_ref}'")
610            }
611            RefnameUnavailable::DescendantExists { blocking, new_ref } => {
612                format!("'{blocking}' exists; cannot create '{new_ref}'")
613            }
614            RefnameUnavailable::SameBatch { refname, other } => {
615                format!("cannot process '{refname}' and '{other}' at the same time")
616            }
617        }
618    }
619}
620
621fn find_descendant_in_sorted_extras(
622    dirname_with_slash: &str,
623    extras: &BTreeSet<String>,
624) -> Option<String> {
625    let start = extras
626        .range(dirname_with_slash.to_string()..)
627        .next()
628        .cloned()?;
629    if start.starts_with(dirname_with_slash) {
630        Some(start)
631    } else {
632        None
633    }
634}
635
636/// Whether a network-advertised ref name is safe to accept during fetch.
637///
638/// Delegates to [`crate::check_ref_format::is_valid_fetch_advertised_ref`].
639#[must_use]
640pub fn is_valid_fetch_advertised_ref(name: &str) -> bool {
641    crate::check_ref_format::is_valid_fetch_advertised_ref(name)
642}
643
644/// Whether a name from a protocol-v2 `ls-refs` response should be kept.
645///
646/// `HEAD` is valid without a `refs/` prefix; every other name must pass
647/// [`is_valid_fetch_advertised_ref`].
648#[must_use]
649pub fn is_valid_ls_refs_advertised_name(name: &str) -> bool {
650    name == "HEAD" || is_valid_fetch_advertised_ref(name)
651}
652
653/// Whether a ref name is safe to store under the ref backend (post-refspec mapping).
654#[must_use]
655pub fn is_valid_storable_ref_name(name: &str) -> bool {
656    is_valid_fetch_advertised_ref(name)
657}
658
659/// Whether a symref target from a ref advertisement is safe to record.
660#[must_use]
661pub fn is_valid_advertised_symref_target(name: &str) -> bool {
662    crate::check_ref_format::is_valid_advertised_symref_target(name)
663}
664
665/// Verify that `refname` can be created without directory/file conflicts with the ref store
666/// and with other refnames queued in the same transaction (`extras`).
667///
668/// `skip` names are ignored when checking the filesystem (updates that delete or replace
669/// those refs in the same batch). Matches Git's `refs_verify_refname_available`.
670///
671/// # Parameters
672///
673/// - `git_dir` — repository git directory.
674/// - `refname` — full ref name to create.
675/// - `extras` — other refnames touched in the same stdin batch / transaction (sorted set).
676/// - `skip` — refnames that may be deleted or updated away in the same batch.
677pub fn verify_refname_available_for_create(
678    git_dir: &Path,
679    refname: &str,
680    extras: &BTreeSet<String>,
681    skip: &HashSet<String>,
682) -> std::result::Result<(), RefnameUnavailable> {
683    // `Repository::git_dir` may be a relative path (e.g. `.git`); resolve so lookups match the
684    // on-disk ref store regardless of process cwd (test harness runs from `trash/.git/...`).
685    let git_dir = fs::canonicalize(git_dir).unwrap_or_else(|_| git_dir.to_path_buf());
686    let mut seen_dirnames: HashSet<String> = HashSet::new();
687    let segments: Vec<&str> = refname.split('/').filter(|s| !s.is_empty()).collect();
688    if segments.len() <= 1 {
689        // No slash-separated parent prefixes (e.g. `HEAD`).
690    } else {
691        let mut dirname = String::new();
692        for part in &segments[..segments.len() - 1] {
693            if !dirname.is_empty() {
694                dirname.push('/');
695            }
696            dirname.push_str(part);
697
698            if !seen_dirnames.insert(dirname.clone()) {
699                continue;
700            }
701
702            if skip.contains(&dirname) {
703                continue;
704            }
705
706            match read_raw_ref(&git_dir, &dirname) {
707                Ok(RawRefLookup::Exists) => {
708                    return Err(RefnameUnavailable::AncestorExists {
709                        blocking: dirname.clone(),
710                        new_ref: refname.to_owned(),
711                    });
712                }
713                // A directory at `refs/prefix` is normal when storing `refs/prefix/child`; only a
714                // real ref (loose file or packed line) blocks creating `refs/prefix/...`.
715                Ok(RawRefLookup::NotFound | RawRefLookup::IsDirectory) => {}
716                Err(_) => {}
717            }
718
719            if extras.contains(&dirname) {
720                return Err(RefnameUnavailable::SameBatch {
721                    refname: refname.to_owned(),
722                    other: dirname.clone(),
723                });
724            }
725        }
726    }
727
728    let mut leaf_dir = String::with_capacity(refname.len() + 1);
729    leaf_dir.push_str(refname);
730    leaf_dir.push('/');
731
732    // Loose descendants can only exist inside a `<refname>/` directory; packed
733    // and stray-file descendants are checked separately below. Skipping the
734    // listing when no such directory exists keeps batch creates linear: listing
735    // a missing directory walks its parent, so a clone writing N tags would
736    // otherwise read every sibling ref N times.
737    let packed_dir = common_dir(&git_dir).unwrap_or_else(|| git_dir.clone());
738    let may_have_loose_descendants = packed_dir.join("reftable").is_dir()
739        || git_dir.join(refname).is_dir()
740        || packed_dir.join(refname).is_dir();
741    let under = if may_have_loose_descendants {
742        list_refs(&git_dir, &leaf_dir).unwrap_or_default()
743    } else {
744        Vec::new()
745    };
746    if under.is_empty() {
747        if let Ok(Some(name)) = first_packed_descendant_ref(&packed_dir, refname) {
748            if !skip.contains(&name) {
749                return Err(RefnameUnavailable::DescendantExists {
750                    blocking: name,
751                    new_ref: refname.to_owned(),
752                });
753            }
754        }
755        if packed_dir != git_dir {
756            if let Ok(Some(name)) = first_packed_descendant_ref(&git_dir, refname) {
757                if !skip.contains(&name) {
758                    return Err(RefnameUnavailable::DescendantExists {
759                        blocking: name,
760                        new_ref: refname.to_owned(),
761                    });
762                }
763            }
764        }
765    }
766    if under.is_empty()
767        && fs::symlink_metadata(git_dir.join(refname))
768            .map(|m| m.is_dir())
769            .unwrap_or(false)
770    {
771        let mut blocking: Option<String> = None;
772        let dir_path = git_dir.join(refname);
773        if let Ok(read) = fs::read_dir(&dir_path) {
774            for entry in read.flatten() {
775                let path = entry.path();
776                let Ok(meta) = fs::metadata(&path) else {
777                    continue;
778                };
779                if !meta.is_file() {
780                    continue;
781                }
782                let name = entry.file_name().to_string_lossy().into_owned();
783                let full = format!("{refname}/{name}");
784                blocking = Some(full);
785                break;
786            }
787        }
788        if let Some(b) = blocking {
789            if !skip.contains(&b) {
790                return Err(RefnameUnavailable::DescendantExists {
791                    blocking: b,
792                    new_ref: refname.to_owned(),
793                });
794            }
795        }
796    }
797
798    for (existing, _) in under {
799        if skip.contains(&existing) {
800            continue;
801        }
802        return Err(RefnameUnavailable::DescendantExists {
803            blocking: existing,
804            new_ref: refname.to_owned(),
805        });
806    }
807
808    if let Some(extra) = find_descendant_in_sorted_extras(&leaf_dir, extras) {
809        if !skip.contains(&extra) {
810            return Err(RefnameUnavailable::SameBatch {
811                refname: refname.to_owned(),
812                other: extra,
813            });
814        }
815    }
816
817    Ok(())
818}
819
820/// One ref create/update/delete in a batch transaction (see [`crate::gc::update_refs`]).
821#[derive(Clone, Debug)]
822pub struct RefBatchItem {
823    /// Full ref name (e.g. `refs/heads/main`).
824    pub name: String,
825    /// New oid to write, or `None` to delete the ref.
826    pub new_oid: Option<ObjectId>,
827}
828
829/// Verify that every *new* ref in a batch can be created without D/F or namespace
830/// conflicts with the existing store or other items in the batch.
831///
832/// Matches Git's pre-lock `refs_verify_refnames_available` checks with an empty
833/// `skip` list (refs scheduled for deletion in the same batch do not suppress
834/// ancestor/descendant conflicts — see t1404 D/F batch tests).
835///
836/// # Errors
837///
838/// Returns [`RefnameUnavailable`] describing the first conflict.
839pub fn verify_ref_transaction_batch(
840    git_dir: &Path,
841    items: &[RefBatchItem],
842) -> std::result::Result<(), RefnameUnavailable> {
843    if let Some((parent, child)) =
844        batch_refname_prefix_conflict(items.iter().map(|i| i.name.as_str()))
845    {
846        return Err(RefnameUnavailable::SameBatch {
847            refname: child,
848            other: parent,
849        });
850    }
851
852    if crate::ref_storage::detect_format(git_dir).is_ok_and(|f| f.is_reftable()) {
853        return crate::reftable::verify_ref_transaction_batch_reftable(git_dir, items);
854    }
855
856    let extras: BTreeSet<String> = items.iter().map(|i| i.name.clone()).collect();
857    let empty_skip = HashSet::new();
858    for item in items {
859        let Some(_oid) = item.new_oid else {
860            continue;
861        };
862        if resolve_ref(git_dir, &item.name).is_ok() {
863            continue;
864        }
865        verify_refname_available_for_create(git_dir, &item.name, &extras, &empty_skip)?;
866    }
867    Ok(())
868}
869
870/// First strict prefix conflict in a batch, if any (sorted adjacent scan).
871pub(crate) fn batch_refname_prefix_conflict<'a>(
872    names: impl Iterator<Item = &'a str>,
873) -> Option<(String, String)> {
874    let mut sorted: Vec<&str> = names.collect();
875    sorted.sort_unstable();
876    for pair in sorted.windows(2) {
877        let (a, b) = (pair[0], pair[1]);
878        if refname_is_strict_prefix(a, b) {
879            return Some((a.to_owned(), b.to_owned()));
880        }
881        if refname_is_strict_prefix(b, a) {
882            return Some((b.to_owned(), a.to_owned()));
883        }
884    }
885    None
886}
887
888fn refname_is_strict_prefix(parent: &str, child: &str) -> bool {
889    child.len() > parent.len()
890        && child.as_bytes().get(parent.len()) == Some(&b'/')
891        && child.starts_with(parent)
892}
893
894fn packed_refs_unexpected_line(path: &Path, line: impl Into<String>) -> Error {
895    Error::PackedRefsUnexpectedLine {
896        path: path.display().to_string(),
897        line: line.into(),
898    }
899}
900
901fn packed_refs_unterminated_line(path: &Path, line: impl Into<String>) -> Error {
902    Error::PackedRefsUnexpectedLine {
903        path: path.display().to_string(),
904        line: line.into(),
905    }
906}
907
908/// Validate a `^<peeled-oid>` line and return the peeled id.
909fn parse_packed_ref_peel_line(path: &Path, line: &str, expect_peel: bool) -> Result<ObjectId> {
910    if !expect_peel {
911        return Err(packed_refs_unexpected_line(path, line));
912    }
913    line.strip_prefix('^')
914        .and_then(|inner| ObjectId::from_hex(inner).ok())
915        .ok_or_else(|| packed_refs_unexpected_line(path, line))
916}
917
918/// Id-prefix length and id of a record line: 40 or 64 hex digits followed by a space.
919fn packed_line_oid(line: &str) -> Option<(usize, ObjectId)> {
920    let bytes = line.as_bytes();
921    let hex_len = [HashAlgo::Sha1.hex_len(), HashAlgo::Sha256.hex_len()]
922        .into_iter()
923        .find(|&len| bytes.get(len) == Some(&b' '))?;
924    // Byte `hex_len` is an ASCII space, so the slice ends on a char boundary.
925    let oid = ObjectId::from_hex(&line[..hex_len]).ok()?;
926    Some((hex_len, oid))
927}
928
929fn packed_ref_data_line_refname(line: &str) -> Option<&str> {
930    let bytes = line.as_bytes();
931    let mut j = 0usize;
932    while j < bytes.len() && bytes[j].is_ascii_hexdigit() {
933        j += 1;
934    }
935    if !ObjectId::is_hex_len(j) || j + 1 >= bytes.len() || bytes[j] != b' ' {
936        return None;
937    }
938    let refname = &line[j + 1..];
939    if refname.is_empty() || refname.as_bytes().iter().any(|b| b.is_ascii_whitespace()) {
940        return None;
941    }
942    Some(refname)
943}
944
945fn parse_packed_ref_data_line(
946    path: &Path,
947    line: &str,
948    ref_opts: &RefNameOptions,
949) -> Result<(String, ObjectId)> {
950    let (refname, oid) = parse_packed_ref_data_line_borrowed(path, line, ref_opts)?;
951    Ok((refname.to_owned(), oid))
952}
953
954fn parse_packed_ref_data_line_borrowed<'a>(
955    path: &Path,
956    line: &'a str,
957    ref_opts: &RefNameOptions,
958) -> Result<(&'a str, ObjectId)> {
959    let Some((hex_len, oid)) = packed_line_oid(line) else {
960        return Err(packed_refs_unexpected_line(path, line));
961    };
962    let refname = &line[hex_len + 1..];
963    if refname.is_empty() || refname.as_bytes().iter().any(|b| b.is_ascii_whitespace()) {
964        return Err(packed_refs_unexpected_line(path, line));
965    }
966    if check_refname_format(refname, ref_opts).is_err() {
967        return Err(packed_refs_unexpected_line(path, line));
968    }
969    Ok((refname, oid))
970}
971
972/// Parse `packed-refs` bytes the way Git reads them for ref listing (strict).
973/// In-memory view of a `packed-refs` file for indexed lookup without a full parse.
974#[derive(Debug, Clone)]
975pub(crate) struct PackedRefsFileView {
976    data: std::sync::Arc<crate::pack_map::PackData>,
977    /// The header declares the `sorted` trait, so lookups and prefix scans binary-search the
978    /// bytes in place. Like Git, the claim is trusted; `fsck` reports a file that breaks it.
979    sorted: bool,
980    /// Hex length of the repository's object ids; the refname starts one byte after it.
981    hex_len: usize,
982    /// Offset just past the first line when it is a comment (the header), else 0.
983    body_start: usize,
984    /// Which records the header promises a peel line for.
985    peel_trait: PackedPeelTrait,
986}
987
988impl PackedRefsFileView {
989    /// [`Self::from_data`] over an owned buffer.
990    #[cfg(test)]
991    fn from_bytes(path: &Path, data: Vec<u8>, hash_algo: crate::objects::HashAlgo) -> Result<Self> {
992        Self::from_data(path, crate::pack_map::PackData::from_owned(data), hash_algo)
993    }
994
995    /// View `data`, the (possibly memory-mapped) bytes of `path`, for a repository using
996    /// `hash_algo`.
997    ///
998    /// Checks only what Git checks before any lookup: a final newline, a last line (after
999    /// the header) long enough to hold an object id, and here also a UTF-8 header. Opening
1000    /// reads the header and the last line, nothing in between; each scan and lookup parses
1001    /// the lines it reaches. Records are the lines with an id-sized prefix, a space and a
1002    /// name.
1003    fn from_data(
1004        path: &Path,
1005        data: std::sync::Arc<crate::pack_map::PackData>,
1006        hash_algo: crate::objects::HashAlgo,
1007    ) -> Result<Self> {
1008        let hex_len = hash_algo.hex_len();
1009        if data.is_empty() {
1010            return Ok(Self {
1011                data,
1012                sorted: false,
1013                hex_len,
1014                body_start: 0,
1015                peel_trait: PackedPeelTrait::None,
1016            });
1017        }
1018        if !data.ends_with(b"\n") {
1019            let tail = std::str::from_utf8(&data)
1020                .map(|s| s.rsplit('\n').next().unwrap_or(s))
1021                .unwrap_or("<invalid utf-8>");
1022            return Err(packed_refs_unterminated_line(path, tail));
1023        }
1024        // Only the header (the leading comment lines) is decoded up front; scans and lookups
1025        // check the encoding of the lines they parse.
1026        let text = std::str::from_utf8(&data[..packed_refs_leading_comments_end(&data)])
1027            .map_err(|_| packed_refs_unexpected_line(path, "<invalid utf-8>"))?;
1028        // Ranged scans never reach the end of a sorted file, so check the last line up front, as
1029        // Git does: past the header, it must be long enough to hold an object id.
1030        let body_start = if data.starts_with(b"#") {
1031            memchr::memchr(b'\n', &data).map_or(data.len(), |nl| nl + 1)
1032        } else {
1033            0
1034        };
1035        let body = &data[body_start..];
1036        if let Some(rest) = body.strip_suffix(b"\n") {
1037            let last_line = memchr::memrchr(b'\n', rest).map_or(rest, |nl| &rest[nl + 1..]);
1038            if last_line.len() < hex_len + 1 {
1039                return Err(packed_refs_unexpected_line(
1040                    path,
1041                    String::from_utf8_lossy(last_line),
1042                ));
1043            }
1044        }
1045        Ok(Self {
1046            sorted: packed_refs_file_declares_sorted(text),
1047            hex_len,
1048            body_start,
1049            peel_trait: packed_refs_peel_trait(text),
1050            data,
1051        })
1052    }
1053
1054    /// The name of the record at `line_start`: everything after `<hex id> `.
1055    fn record_name(&self, line_start: usize) -> &[u8] {
1056        let end = packed_line_end(&self.data, line_start);
1057        &self.data[line_start + self.hex_len + 1..end]
1058    }
1059
1060    fn record_cmp(&self, line_start: usize, name: &str) -> std::cmp::Ordering {
1061        self.record_name(line_start).cmp(name.as_bytes())
1062    }
1063
1064    /// Start of the first record beginning at or after the line start `from` and before
1065    /// `limit`.
1066    fn next_record(&self, from: usize, limit: usize) -> Option<usize> {
1067        let hex_len = self.hex_len;
1068        let mut start = from;
1069        while start < limit {
1070            let rest = &self.data[start..];
1071            // A peel line (`^`, an id, a newline) needs no search: any line starting inside
1072            // it ends at that newline, too short to be a record.
1073            if rest[0] == b'^' && rest.get(hex_len + 1) == Some(&b'\n') {
1074                start += hex_len + 2;
1075                continue;
1076            }
1077            let end = memchr::memchr(b'\n', rest).unwrap_or(rest.len());
1078            if packed_line_is_record(&rest[..end], hex_len) {
1079                return Some(start);
1080            }
1081            start += end + 1;
1082        }
1083        None
1084    }
1085
1086    /// Start of the last record beginning before the line start `before`.
1087    fn prev_record(&self, before: usize) -> Option<usize> {
1088        let mut end = before;
1089        while end > 0 {
1090            let line_end = end - 1;
1091            let start = memchr::memrchr(b'\n', &self.data[..line_end]).map_or(0, |nl| nl + 1);
1092            if packed_line_is_record(&self.data[start..line_end], self.hex_len) {
1093                return Some(start);
1094            }
1095            end = start;
1096        }
1097        None
1098    }
1099
1100    /// Starts of every record, in file order.
1101    fn records(&self) -> impl Iterator<Item = usize> + '_ {
1102        let len = self.data.len();
1103        std::iter::successors(self.next_record(0, len), move |&start| {
1104            self.next_record(packed_line_end(&self.data, start) + 1, len)
1105        })
1106    }
1107
1108    /// Binary search of a sorted file for `name`: `Ok` with the start of a record named
1109    /// `name`, else `Err` with the start of the first record after every record found
1110    /// smaller (the file length when there is none).
1111    ///
1112    /// Each probe takes the first record starting on or after the line holding the midpoint,
1113    /// so the search only reads the lines it probes. In a file whose records really are in
1114    /// order the answer is exact; otherwise it is whatever the probes lead to, as in Git.
1115    fn search(&self, name: &str) -> std::result::Result<usize, usize> {
1116        let len = self.data.len();
1117        let mut lo = self.body_start;
1118        let mut hi = len;
1119        while lo < hi {
1120            let mid = lo + (hi - lo) / 2;
1121            let line = memchr::memrchr(b'\n', &self.data[lo..mid]).map_or(lo, |nl| lo + nl + 1);
1122            let Some(record) = self.next_record(line, hi) else {
1123                hi = line;
1124                continue;
1125            };
1126            match self.record_cmp(record, name) {
1127                std::cmp::Ordering::Less => lo = packed_line_end(&self.data, record) + 1,
1128                std::cmp::Ordering::Greater => hi = record,
1129                std::cmp::Ordering::Equal => return Ok(record),
1130            }
1131        }
1132        Err(self.next_record(lo, len).unwrap_or(len))
1133    }
1134
1135    /// Start of the first record of a sorted file not less than `name` (or the file length).
1136    fn lower_bound(&self, name: &str) -> usize {
1137        let (Ok(start) | Err(start)) = self.search(name);
1138        start
1139    }
1140
1141    /// Object id stored for `refname`, and optional peeled tag target from the following `^` line.
1142    fn lookup_entry(
1143        &self,
1144        path: &Path,
1145        refname: &str,
1146    ) -> Result<Option<(ObjectId, Option<ObjectId>)>> {
1147        let ref_opts = RefNameOptions {
1148            allow_onelevel: false,
1149            refspec_pattern: false,
1150            normalize: false,
1151        };
1152        let line_start = if self.sorted {
1153            self.search(refname).ok()
1154        } else {
1155            self.records()
1156                .find(|&start| self.record_cmp(start, refname).is_eq())
1157        };
1158        let Some(start) = line_start else {
1159            return Ok(None);
1160        };
1161        let (name, oid) = parse_packed_line_at(path, &self.data, start, &ref_opts)?;
1162        debug_assert_eq!(name, refname);
1163        let line_end = packed_line_end(&self.data, start);
1164        if line_end + 1 < self.data.len() && self.data[line_end + 1] == b'^' {
1165            let peel_line = packed_line_as_str(&self.data, line_end + 1);
1166            parse_packed_ref_peel_line(path, peel_line, true)?;
1167        }
1168        let peel = peel_oid_following_line(&self.data, start);
1169        Ok(Some((oid, peel)))
1170    }
1171
1172    fn lookup_oid(&self, path: &Path, refname: &str) -> Result<Option<ObjectId>> {
1173        Ok(self.lookup_entry(path, refname)?.map(|(oid, _)| oid))
1174    }
1175
1176    /// Smallest packed ref strictly under `refname/`, with its object id.
1177    ///
1178    /// A sorted file only parses the records sharing `refname` as a prefix.
1179    pub(crate) fn first_descendant(
1180        &self,
1181        path: &Path,
1182        refname: &str,
1183    ) -> Result<Option<(String, ObjectId)>> {
1184        let leaf_dir = format!("{refname}/");
1185        Ok(self
1186            .map_for_prefix(path, Some(refname))?
1187            .into_iter()
1188            .filter(|(name, _)| name.starts_with(&leaf_dir))
1189            .min_by(|a, b| a.0.cmp(&b.0)))
1190    }
1191
1192    /// Refs whose names start with `prefix` (all refs for `None`).
1193    ///
1194    /// Every line the scan covers is parsed strictly, peel and comment lines included. In a
1195    /// sorted file a prefix scan covers the binary-searched range of matching records plus
1196    /// whatever lies between them and the records on either side, so a corrupt line
1197    /// in or next to the range is reported rather than skipped. Like Git, it does not read
1198    /// records further away. Scans without a prefix, of all of `refs/`, or of an unsorted
1199    /// file cover the whole file.
1200    fn map_for_prefix(
1201        &self,
1202        path: &Path,
1203        prefix: Option<&str>,
1204    ) -> Result<HashMap<String, ObjectId>> {
1205        self.map_for_prefix_filtered(path, prefix, None)
1206    }
1207
1208    /// [`Self::map_for_prefix`], leaving out the records `keep` rejects. Rejected records are
1209    /// still parsed strictly; they are only spared the allocation.
1210    fn map_for_prefix_filtered(
1211        &self,
1212        path: &Path,
1213        prefix: Option<&str>,
1214        keep: Option<PackedRecordFilter<'_>>,
1215    ) -> Result<HashMap<String, ObjectId>> {
1216        let prefix_trim = prefix.map(|p| p.trim_end_matches('/'));
1217        let range_prefix = prefix_trim.filter(|p| self.sorted && !p.is_empty() && *p != "refs");
1218        let (begin, end) = match range_prefix {
1219            Some(prefix) => {
1220                let first = self.lower_bound(prefix);
1221                // In a file whose records are out of order the bounds can cross.
1222                let end = list_prefix_upper_bound(prefix)
1223                    .map_or(self.data.len(), |upper| self.lower_bound(&upper))
1224                    .max(first);
1225                // Start after the record before the range and its peel line (which belongs to
1226                // that record), so anything else up to the first record in range is checked.
1227                let begin = self.prev_record(first).map_or(0, |prev| {
1228                    let after = packed_line_end(&self.data, prev) + 1;
1229                    if self.data.get(after) == Some(&b'^') {
1230                        packed_line_end(&self.data, after) + 1
1231                    } else {
1232                        after
1233                    }
1234                });
1235                (begin, end)
1236            }
1237            None => (0, self.data.len()),
1238        };
1239        // Both bounds are line starts or the end of the file, so the region is whole lines.
1240        let region = std::str::from_utf8(&self.data[begin..end])
1241            .map_err(|_| packed_refs_unexpected_line(path, "<invalid utf-8>"))?;
1242        parse_packed_refs_region(path, region, prefix_trim, keep, self.peel_trait)
1243    }
1244}
1245
1246/// What a `packed-refs` record tells about the object its ref names, given the file's
1247/// `peeled` / `fully-peeled` header traits.
1248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1249pub(crate) enum PackedPeel {
1250    /// The ref names an annotated tag that peels to this object.
1251    Peeled(ObjectId),
1252    /// The ref is known not to name an annotated tag.
1253    NotTag,
1254    /// The file does not say; the object must be read to peel it.
1255    Unknown,
1256}
1257
1258/// Decides, from its storage name, object id and [`PackedPeel`], whether a `packed-refs`
1259/// record is listed.
1260pub(crate) type PackedRecordFilter<'a> = &'a dyn Fn(&str, &ObjectId, PackedPeel) -> bool;
1261
1262/// Strictly parse the `packed-refs` lines of `region`, keeping refs that match `prefix`.
1263///
1264/// `region` starts at the beginning of the file or of a line that is not a peel line, and ends
1265/// at the end of the file or just before a ref line, so peel lines stay attached to their ref.
1266fn parse_packed_refs_region(
1267    path: &Path,
1268    region: &str,
1269    prefix: Option<&str>,
1270    keep: Option<PackedRecordFilter<'_>>,
1271    peel_trait: PackedPeelTrait,
1272) -> Result<HashMap<String, ObjectId>> {
1273    let ref_opts = RefNameOptions {
1274        allow_onelevel: false,
1275        refspec_pattern: false,
1276        normalize: false,
1277    };
1278    let mut map = HashMap::new();
1279    // With a filter, a record is decided once its peel line (if any) has been seen.
1280    let mut pending: Option<(&str, ObjectId, Option<ObjectId>)> = None;
1281    let flush = |map: &mut HashMap<String, ObjectId>,
1282                 pending: &mut Option<(&str, ObjectId, Option<ObjectId>)>| {
1283        let (Some(keep), Some((refname, oid, peeled))) = (keep, pending.take()) else {
1284            return;
1285        };
1286        let peel = if peel_trait.covers(refname) {
1287            peeled.map_or(PackedPeel::NotTag, PackedPeel::Peeled)
1288        } else {
1289            PackedPeel::Unknown
1290        };
1291        if keep(refname, &oid, peel) {
1292            map.insert(refname.to_owned(), oid);
1293        }
1294    };
1295    let mut expect_peel = false;
1296    let mut lines = region.split('\n').peekable();
1297    while let Some(line) = lines.next() {
1298        if line.is_empty() {
1299            if lines.peek().is_some() {
1300                return Err(packed_refs_unexpected_line(path, line));
1301            }
1302            break;
1303        }
1304        if line.starts_with('#') {
1305            flush(&mut map, &mut pending);
1306            expect_peel = false;
1307            continue;
1308        }
1309        if line.starts_with('^') {
1310            let peel = parse_packed_ref_peel_line(path, line, expect_peel)?;
1311            if let Some((_, _, peeled)) = pending.as_mut() {
1312                *peeled = Some(peel);
1313            }
1314            expect_peel = false;
1315            continue;
1316        }
1317        flush(&mut map, &mut pending);
1318        let (refname, oid) = parse_packed_ref_data_line_borrowed(path, line, &ref_opts)?;
1319        if prefix.is_none_or(|p| ref_name_matches_list_prefix(refname, p)) {
1320            if keep.is_some() {
1321                pending = Some((refname, oid, None));
1322            } else {
1323                map.insert(refname.to_owned(), oid);
1324            }
1325        }
1326        expect_peel = true;
1327    }
1328    flush(&mut map, &mut pending);
1329    Ok(map)
1330}
1331
1332fn read_packed_refs_file_view(store: &Path) -> Result<Option<PackedRefsFileView>> {
1333    let packed_path = store::paths::packed_refs_path(store);
1334    // Writers publish `packed-refs` by renaming a new file over it, never by rewriting it in
1335    // place, so a large file can be mapped like a pack instead of copied.
1336    let data = match crate::pack_map::PackData::open_with_min_len(&packed_path, 0) {
1337        Ok(d) => d,
1338        Err(Error::Io(e)) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
1339        Err(e) => return Err(e),
1340    };
1341    let hash_algo = crate::repo::read_repository_format_from_git_dir(store)
1342        .map(|format| format.hash_algo())
1343        .unwrap_or(crate::objects::HashAlgo::Sha1);
1344    Ok(Some(PackedRefsFileView::from_data(
1345        &packed_path,
1346        data,
1347        hash_algo,
1348    )?))
1349}
1350
1351/// Whether `line` (without its newline) is shaped like a record: `hex_len` bytes, a space and
1352/// a non-empty name. Other lines are left for the strict parse.
1353fn packed_line_is_record(line: &[u8], hex_len: usize) -> bool {
1354    line.len() > hex_len + 1 && line[hex_len] == b' ' && !matches!(line[0], b'#' | b'^')
1355}
1356
1357fn packed_line_end(data: &[u8], line_start: usize) -> usize {
1358    memchr::memchr(b'\n', &data[line_start..]).map_or(data.len(), |p| line_start + p)
1359}
1360
1361fn packed_line_as_str(data: &[u8], line_start: usize) -> &str {
1362    std::str::from_utf8(&data[line_start..packed_line_end(data, line_start)]).unwrap_or("")
1363}
1364
1365fn peel_oid_following_line(data: &[u8], line_start: usize) -> Option<ObjectId> {
1366    let line_end = packed_line_end(data, line_start);
1367    if line_end >= data.len() || data[line_end] != b'\n' {
1368        return None;
1369    }
1370    let peel_start = line_end + 1;
1371    if peel_start >= data.len() || data[peel_start] != b'^' {
1372        return None;
1373    }
1374    let peel_end = packed_line_end(data, peel_start);
1375    let inner = std::str::from_utf8(&data[peel_start + 1..peel_end]).ok()?;
1376    ObjectId::from_hex(inner.trim()).ok()
1377}
1378
1379fn parse_packed_line_at(
1380    path: &Path,
1381    data: &[u8],
1382    line_start: usize,
1383    ref_opts: &RefNameOptions,
1384) -> Result<(String, ObjectId)> {
1385    let line = packed_line_as_str(data, line_start);
1386    parse_packed_ref_data_line(path, line, ref_opts)
1387}
1388
1389pub(crate) fn parse_packed_refs_to_map(
1390    path: &Path,
1391    data: &[u8],
1392) -> Result<HashMap<String, ObjectId>> {
1393    parse_packed_refs_to_map_with_prefix(path, data, None)
1394}
1395
1396/// Offset just past the comment lines (`#...\n`) at the start of `data`.
1397fn packed_refs_leading_comments_end(data: &[u8]) -> usize {
1398    let mut end = 0;
1399    while data.get(end) == Some(&b'#') {
1400        match memchr::memchr(b'\n', &data[end..]) {
1401            Some(nl) => end += nl + 1,
1402            None => return data.len(),
1403        }
1404    }
1405    end
1406}
1407
1408/// Whether the `packed-refs` header comment lists Git's `sorted` trait (`pack-refs --all`).
1409fn packed_refs_file_declares_sorted(text: &str) -> bool {
1410    for line in text.split('\n') {
1411        if line.is_empty() {
1412            break;
1413        }
1414        if !line.starts_with('#') {
1415            break;
1416        }
1417        let body = line[1..].trim();
1418        let Some(traits) = body.strip_prefix("pack-refs with:") else {
1419            continue;
1420        };
1421        return traits.split_whitespace().any(|t| t == "sorted");
1422    }
1423    false
1424}
1425
1426fn parse_packed_refs_to_map_with_prefix(
1427    path: &Path,
1428    data: &[u8],
1429    prefix_filter: Option<&str>,
1430) -> Result<HashMap<String, ObjectId>> {
1431    if data.is_empty() {
1432        return Ok(HashMap::new());
1433    }
1434    if data.contains(&b'\r') {
1435        return Err(packed_refs_unexpected_line(
1436            path,
1437            "<contains carriage return>",
1438        ));
1439    }
1440    if !data.ends_with(b"\n") {
1441        let tail = std::str::from_utf8(data)
1442            .map(|s| s.rsplit('\n').next().unwrap_or(s))
1443            .unwrap_or("<invalid utf-8>");
1444        return Err(packed_refs_unterminated_line(path, tail));
1445    }
1446    let text = std::str::from_utf8(data)
1447        .map_err(|_| packed_refs_unexpected_line(path, "<invalid utf-8>"))?;
1448
1449    let ref_opts = RefNameOptions {
1450        allow_onelevel: false,
1451        refspec_pattern: false,
1452        normalize: false,
1453    };
1454
1455    let mut map = HashMap::new();
1456    let mut expect_peel = false;
1457    let prefix_trim = prefix_filter.map(|p| p.trim_end_matches('/'));
1458    let packed_sorted = packed_refs_file_declares_sorted(text);
1459    let prefix_upper = if packed_sorted {
1460        prefix_trim.and_then(list_prefix_upper_bound)
1461    } else {
1462        None
1463    };
1464    let mut lines = text.split('\n').peekable();
1465    while let Some(line) = lines.next() {
1466        if line.is_empty() {
1467            if lines.peek().is_some() {
1468                return Err(packed_refs_unexpected_line(path, line));
1469            }
1470            break;
1471        }
1472        if line.starts_with('#') {
1473            expect_peel = false;
1474            continue;
1475        }
1476        if line.starts_with('^') {
1477            parse_packed_ref_peel_line(path, line, expect_peel)?;
1478            expect_peel = false;
1479            continue;
1480        }
1481
1482        if let Some(prefix) = prefix_trim {
1483            let Some(refname) = packed_ref_data_line_refname(line) else {
1484                return Err(packed_refs_unexpected_line(path, line));
1485            };
1486            if packed_sorted {
1487                if refname < prefix {
1488                    expect_peel = true;
1489                    continue;
1490                }
1491                if let Some(upper) = &prefix_upper {
1492                    if refname >= upper.as_str() {
1493                        break;
1494                    }
1495                }
1496            }
1497            if !ref_name_matches_list_prefix(refname, prefix) {
1498                expect_peel = true;
1499                continue;
1500            }
1501        }
1502        let (refname, oid) = parse_packed_ref_data_line(path, line, &ref_opts)?;
1503        map.insert(refname, oid);
1504        expect_peel = true;
1505    }
1506    Ok(map)
1507}
1508
1509pub(crate) fn read_packed_refs_map(store: &Path) -> Result<HashMap<String, ObjectId>> {
1510    read_packed_refs_map_for_prefix(store, "")
1511}
1512
1513pub(crate) fn read_packed_refs_map_for_prefix(
1514    store: &Path,
1515    prefix: &str,
1516) -> Result<HashMap<String, ObjectId>> {
1517    read_packed_refs_map_for_prefix_filtered(store, prefix, None)
1518}
1519
1520/// [`read_packed_refs_map_for_prefix`], leaving out the records `keep` rejects.
1521pub(crate) fn read_packed_refs_map_for_prefix_filtered(
1522    store: &Path,
1523    prefix: &str,
1524    keep: Option<PackedRecordFilter<'_>>,
1525) -> Result<HashMap<String, ObjectId>> {
1526    let Some(view) = read_packed_refs_file_view(store)? else {
1527        return Ok(HashMap::new());
1528    };
1529    let packed_path = store::paths::packed_refs_path(store);
1530    let filter = if prefix.is_empty() {
1531        None
1532    } else {
1533        Some(prefix)
1534    };
1535    view.map_for_prefix_filtered(&packed_path, filter, keep)
1536}
1537
1538/// What a `packed-refs` header promises about its `^` peel lines.
1539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1540enum PackedPeelTrait {
1541    /// No `peeled` trait: peel lines may be missing for any ref.
1542    None,
1543    /// `peeled`: every annotated tag under `refs/tags/` has a peel line.
1544    Tags,
1545    /// `fully-peeled`: every ref that names an annotated tag has a peel line.
1546    All,
1547}
1548
1549impl PackedPeelTrait {
1550    /// Whether a ref named `refname` without a peel line is known not to name an annotated tag.
1551    fn covers(self, refname: &str) -> bool {
1552        match self {
1553            Self::None => false,
1554            Self::Tags => refname.starts_with("refs/tags/"),
1555            Self::All => true,
1556        }
1557    }
1558}
1559
1560/// Read-only snapshot of the peel lines recorded in `packed-refs`.
1561///
1562/// Lets callers that peel many refs (log decorations, for example) skip reading tag
1563/// objects when `packed-refs` already records the peeled value, the same shortcut
1564/// Git takes when iterating packed refs.
1565///
1566/// Lookups trust the record shape found by [`PackedRefsFileView`] and skip the strict
1567/// per-line validation: callers pair this with a ref listing that already parsed the
1568/// file strictly.
1569pub(crate) struct PackedRefPeels {
1570    view: Option<PackedRefsFileView>,
1571    /// Record start by name for unsorted files, where binary search is not exact.
1572    unsorted: Option<HashMap<Box<[u8]>, usize>>,
1573    peel_trait: PackedPeelTrait,
1574    /// Start of the record after the last hit in a sorted file (or the file length), so
1575    /// lookups made in name order (a ref listing) are answered without a binary search.
1576    next: std::cell::Cell<usize>,
1577}
1578
1579impl PackedRefPeels {
1580    /// Load the peel snapshot for the files ref store backing `git_dir`.
1581    ///
1582    /// A missing `packed-refs`, or a ref store that is not the files backend, yields a
1583    /// snapshot that knows nothing (every [`Self::peeled`] call returns `None`).
1584    ///
1585    /// # Errors
1586    ///
1587    /// Returns [`Error::Io`] when `packed-refs` exists but cannot be read, or
1588    /// [`Error::PackedRefsUnexpectedLine`] when it is malformed.
1589    pub(crate) fn load(git_dir: &Path, format: RefStorageFormat) -> Result<Self> {
1590        if format != RefStorageFormat::Files {
1591            return Ok(Self::from_view(None));
1592        }
1593        let store = common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
1594        Ok(Self::from_view(read_packed_refs_file_view(&store)?))
1595    }
1596
1597    /// [`Self::load`] for the refs of `store`, the ref store opened for `git_dir`.
1598    ///
1599    /// A files backend hands over the `packed-refs` view it already holds (a listing just
1600    /// read it), so the file is neither mapped nor indexed a second time.
1601    ///
1602    /// # Errors
1603    ///
1604    /// Same as [`Self::load`].
1605    pub(crate) fn for_store(store: &dyn store::RefStore, git_dir: &Path) -> Result<Self> {
1606        match store.as_files() {
1607            Some(files) => Ok(Self::from_view(files.packed_refs_view()?)),
1608            None => Self::load(git_dir, store.format()),
1609        }
1610    }
1611
1612    fn from_view(view: Option<PackedRefsFileView>) -> Self {
1613        let empty = Self {
1614            view: None,
1615            unsorted: None,
1616            peel_trait: PackedPeelTrait::None,
1617            next: std::cell::Cell::new(0),
1618        };
1619        let Some(view) = view else {
1620            return empty;
1621        };
1622        let peel_trait = view.peel_trait;
1623        if peel_trait == PackedPeelTrait::None {
1624            return empty;
1625        }
1626        let unsorted = (!view.sorted).then(|| {
1627            view.records()
1628                .map(|start| (Box::<[u8]>::from(view.record_name(start)), start))
1629                .collect()
1630        });
1631        let first = view
1632            .next_record(view.body_start, view.data.len())
1633            .unwrap_or(view.data.len());
1634        Self {
1635            view: Some(view),
1636            unsorted,
1637            peel_trait,
1638            next: std::cell::Cell::new(first),
1639        }
1640    }
1641
1642    /// Peeled value of `refname` when `packed-refs` vouches for it.
1643    ///
1644    /// `oid` is the ref's current value; packed data is only trusted when it records the
1645    /// same value (a loose ref overriding the packed one is not covered).
1646    ///
1647    /// # Returns
1648    ///
1649    /// - `Some(Some(peeled))`: the ref names an annotated tag that peels to `peeled`
1650    ///   (the innermost non-tag object).
1651    /// - `Some(None)`: the ref is known not to name an annotated tag.
1652    /// - `None`: `packed-refs` does not say; the caller must peel the object itself.
1653    pub(crate) fn peeled(&self, refname: &str, oid: &ObjectId) -> Option<Option<ObjectId>> {
1654        if !self.peel_trait.covers(refname) {
1655            return None;
1656        }
1657        let view = self.view.as_ref()?;
1658        let start = match &self.unsorted {
1659            Some(map) => *map.get(refname.as_bytes())?,
1660            None => {
1661                let len = view.data.len();
1662                let hint = self.next.get();
1663                let start = if hint < len && view.record_cmp(hint, refname).is_eq() {
1664                    hint
1665                } else {
1666                    view.search(refname).ok()?
1667                };
1668                let after = packed_line_end(&view.data, start) + 1;
1669                self.next.set(view.next_record(after, len).unwrap_or(len));
1670                start
1671            }
1672        };
1673        let hex = std::str::from_utf8(&view.data[start..start + view.hex_len]).ok()?;
1674        let packed_oid = ObjectId::from_hex(hex).ok()?;
1675        (packed_oid == *oid).then(|| peel_oid_following_line(&view.data, start))
1676    }
1677}
1678
1679/// The peel guarantee declared by the `# pack-refs with:` header of `text`.
1680fn packed_refs_peel_trait(text: &str) -> PackedPeelTrait {
1681    let Some(header) = text.strip_prefix("# pack-refs with: ") else {
1682        return PackedPeelTrait::None;
1683    };
1684    let traits = header.split('\n').next().unwrap_or_default();
1685    let mut peel_trait = PackedPeelTrait::None;
1686    for t in traits.split_whitespace() {
1687        match t {
1688            "fully-peeled" => return PackedPeelTrait::All,
1689            "peeled" => peel_trait = PackedPeelTrait::Tags,
1690            _ => {}
1691        }
1692    }
1693    peel_trait
1694}
1695
1696fn acquire_packed_refs_lock(
1697    git_dir: &Path,
1698    lock: &Path,
1699    lock_display: &Path,
1700) -> Result<crate::lockfile::LockFile> {
1701    use crate::lockfile::{LockError, LockFile, LockWait};
1702    let wait = LockWait::packed_refs_for_git_dir(git_dir);
1703    LockFile::acquire(lock, wait).map_err(|err| match err {
1704        LockError::Held { .. } => Error::from(RefLockError::PackedRefsLockHeld {
1705            lock_path: lock_display.display().to_string(),
1706        }),
1707        LockError::Io { source, .. } => Error::Io(source),
1708    })
1709}
1710
1711/// Look up a refname in `packed-refs`.
1712pub(crate) fn lookup_packed_ref(git_dir: &Path, refname: &str) -> Result<Option<ObjectId>> {
1713    let Some(view) = read_packed_refs_file_view(git_dir)? else {
1714        return Ok(None);
1715    };
1716    let packed_path = store::paths::packed_refs_path(git_dir);
1717    view.lookup_oid(&packed_path, refname)
1718}
1719
1720/// Look up a refname and optional peeled tag oid in `packed-refs`.
1721#[cfg(test)]
1722pub(crate) fn lookup_packed_ref_with_peel(
1723    git_dir: &Path,
1724    refname: &str,
1725) -> Result<Option<(ObjectId, Option<ObjectId>)>> {
1726    let Some(view) = read_packed_refs_file_view(git_dir)? else {
1727        return Ok(None);
1728    };
1729    let packed_path = store::paths::packed_refs_path(git_dir);
1730    view.lookup_entry(&packed_path, refname)
1731}
1732
1733/// Write a symbolic ref (e.g. `NOTES_MERGE_REF` → `refs/notes/m`).
1734///
1735/// For reftable-backed repositories this dispatches to the reftable writer.
1736///
1737/// # Errors
1738///
1739/// Returns [`Error::InvalidRef`] when `refname` is not safe for ref storage (e.g. path
1740/// traversal under `refs/`), on a namespace conflict, or [`Error::Io`] on filesystem errors.
1741pub fn write_symbolic_ref(git_dir: &Path, refname: &str, target: &str) -> Result<()> {
1742    store_write_value(
1743        git_dir,
1744        refname,
1745        Some(StoreRawRef::Symbolic(target.to_owned())),
1746        Expected::Any,
1747        None,
1748    )
1749}
1750
1751pub(crate) fn ensure_refname_safe_for_storage(refname: &str) -> Result<()> {
1752    if let Some(rest) = refname.strip_prefix("refs/") {
1753        if rest.is_empty() || !crate::check_ref_format::is_valid_fetch_advertised_ref(refname) {
1754            return Err(Error::InvalidRef(format!(
1755                "refusing to update ref with bad name '{refname}'"
1756            )));
1757        }
1758        return Ok(());
1759    }
1760    if crate::check_ref_format::refname_is_safe(refname) {
1761        Ok(())
1762    } else {
1763        Err(Error::InvalidRef(format!(
1764            "refusing to update ref with bad name '{refname}'"
1765        )))
1766    }
1767}
1768
1769/// Write a ref, creating parent directories as needed.
1770///
1771/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
1772///
1773/// # Parameters
1774///
1775/// - `git_dir` — path to the git directory.
1776/// - `refname` — reference name (e.g. `"refs/heads/main"`).
1777/// - `oid` — the new target object ID.
1778///
1779/// # Errors
1780///
1781/// Returns [`Error::InvalidRef`] when `refname` is not safe for ref storage (e.g. path
1782/// traversal under `refs/`), on a namespace conflict, or [`Error::Io`] on filesystem errors.
1783pub fn write_ref(git_dir: &Path, refname: &str, oid: &ObjectId) -> Result<()> {
1784    store_write_value(
1785        git_dir,
1786        refname,
1787        Some(StoreRawRef::Direct(*oid)),
1788        Expected::Any,
1789        None,
1790    )
1791}
1792
1793/// Per-ref update flags for [`RefUpdate`] (e.g. `REF_NO_DEREF` on `HEAD`).
1794#[must_use]
1795pub fn ref_update_flags(refname: &str) -> RefUpdateFlags {
1796    legacy_ref_update_flags(refname)
1797}
1798
1799/// Create a new direct ref and its initial reflog line in one store transaction.
1800///
1801/// # Errors
1802///
1803/// Returns an error when the ref already exists, the name is invalid, or ref/reflog I/O fails.
1804pub fn create_ref_with_reflog(
1805    git_dir: &Path,
1806    refname: &str,
1807    oid: &ObjectId,
1808    identity: &str,
1809    message: &str,
1810) -> Result<()> {
1811    let config = ConfigSet::load(
1812        &crate::environment::Environment::empty(),
1813        Some(git_dir),
1814        true,
1815    )
1816    .unwrap_or_default();
1817    let store = open_ref_store(git_dir)?;
1818    create_ref_with_reflog_on_store(
1819        store.as_ref(),
1820        git_dir,
1821        &config,
1822        refname,
1823        oid,
1824        identity,
1825        message,
1826    )
1827}
1828
1829/// [`create_ref_with_reflog`] through an already-open store with already-loaded `config`, so
1830/// a caller holding a [`crate::repo::Repository`] reuses its packed-refs and config caches.
1831pub(crate) fn create_ref_with_reflog_on_store(
1832    store: &dyn RefStore,
1833    git_dir: &Path,
1834    config: &ConfigSet,
1835    refname: &str,
1836    oid: &ObjectId,
1837    identity: &str,
1838    message: &str,
1839) -> Result<()> {
1840    let reflog =
1841        should_autocreate_reflog_with_config(config, git_dir, refname).then(|| ReflogUpdate {
1842            identity: identity.to_owned(),
1843            message: message.to_owned(),
1844            time: time::OffsetDateTime::now_utc(),
1845            old_oid: None,
1846            new_oid: None,
1847        });
1848    store_write_value_on_store(
1849        store,
1850        git_dir,
1851        refname,
1852        Some(StoreRawRef::Direct(*oid)),
1853        Expected::Missing,
1854        reflog,
1855    )
1856}
1857
1858/// Write a loose ref file under `storage_dir`: the body shared by [`write_ref`]
1859/// and [`write_ref_cached`], run after the packed-refs namespace-conflict check.
1860fn write_ref_at_storage(
1861    git_dir: &Path,
1862    storage_dir: &Path,
1863    refname: &str,
1864    oid: &ObjectId,
1865) -> Result<()> {
1866    let stor = crate::ref_namespace::storage_ref_name(refname);
1867    let path = storage_dir.join(stor);
1868    // An empty directory left over from a previously deleted nested ref can sit exactly where
1869    // this ref file must go (e.g. `refs/e-create/foo` after `refs/e-create/foo/bar` was pruned).
1870    // Git removes such empty directory trees before locking the ref (see t0600 "empty directory
1871    // should not fool create/update"); mirror that so the rename below does not hit "Is a
1872    // directory". Non-empty directories (containing files or `*.lock`) are left in place so the
1873    // subsequent write surfaces the conflict, matching Git's behaviour.
1874    remove_empty_ref_directory(&path);
1875    // If a *non-empty* directory still sits where the ref file must go, locking cannot
1876    // proceed. Git reports this as a "non-empty directory ... blocking reference" rather
1877    // than letting the rename fail with a raw "Is a directory" I/O error (see t0600
1878    // "non-empty directory blocks create"). Mirror that message so callers display it
1879    // verbatim. This only applies to direct (non-deref) writes where `refname` is also the
1880    // user-visible ref; indirect symref writes are handled by the caller.
1881    if fs::symlink_metadata(&path)
1882        .map(|m| m.file_type().is_dir())
1883        .unwrap_or(false)
1884    {
1885        let display = ref_path_for_display(&path);
1886        return Err(crate::error::RefLockError::DirectoryInTheWay {
1887            refname: refname.to_owned(),
1888            path: display,
1889        }
1890        .into());
1891    }
1892    if path.is_file() && matches!(read_ref_file(&path), Err(Error::InvalidRef(_))) {
1893        return Err(Error::InvalidRef(format!(
1894            "cannot update ref '{refname}': reference broken"
1895        )));
1896    }
1897    if let Some(parent) = path.parent() {
1898        crate::git_dir::io_error::io_with_git_path(
1899            git_dir,
1900            parent,
1901            crate::git_dir::GitDirPathOperation::CreateDir,
1902            fs::create_dir_all(parent),
1903        )?;
1904    }
1905    let content = format!("{oid}\n");
1906    // Write via lock file for atomicity (`O_CREAT|O_EXCL`), with optional backoff
1907    // per `core.filesRefLockTimeout`. A held lock surfaces as `Error::LockHeld`.
1908    let lock_path = lock_path_for_ref(&path);
1909    use crate::lockfile::{LockError, LockFile, LockWait};
1910    use std::io::Write as _;
1911    let wait = LockWait::files_ref_lock_for_git_dir(git_dir);
1912    let mut lock = LockFile::acquire(&lock_path, wait).map_err(|err| match err {
1913        held @ LockError::Held { .. } => Error::from(held),
1914        LockError::Io { source, .. } => crate::git_dir::io_error::map_git_dir_io(
1915            git_dir,
1916            &lock_path,
1917            crate::git_dir::GitDirPathOperation::UpdateRef,
1918            source,
1919        ),
1920    })?;
1921    crate::git_dir::io_error::io_with_git_path(
1922        git_dir,
1923        &path,
1924        crate::git_dir::GitDirPathOperation::UpdateRef,
1925        lock.write_all(content.as_bytes()),
1926    )?;
1927    lock.commit_to(&path).map_err(|err| match err {
1928        LockError::Io { source, .. } => crate::git_dir::io_error::map_git_dir_io(
1929            git_dir,
1930            &path,
1931            crate::git_dir::GitDirPathOperation::UpdateRef,
1932            source,
1933        ),
1934        held @ LockError::Held { .. } => Error::from(held),
1935    })?;
1936    Ok(())
1937}
1938
1939/// Like [`write_ref`], but requires `expected_old` to match the ref's current value
1940/// while the loose-ref lock is held (atomic CAS for concurrent updaters).
1941///
1942/// # Errors
1943///
1944/// Same as [`write_ref`], plus [`store::RefStoreError::ExpectedMismatch`] when the CAS check
1945/// fails under lock.
1946pub fn write_ref_cas(
1947    git_dir: &Path,
1948    refname: &str,
1949    oid: &ObjectId,
1950    expected_old: ObjectId,
1951) -> Result<()> {
1952    store_write_value(
1953        git_dir,
1954        refname,
1955        Some(StoreRawRef::Direct(*oid)),
1956        Expected::Oid(expected_old),
1957        None,
1958    )
1959}
1960
1961/// Like [`delete_ref`], but requires `expected_old` to match under the ref lock.
1962///
1963/// # Errors
1964///
1965/// Same as [`delete_ref`], plus [`store::RefStoreError::ExpectedMismatch`] when the CAS check
1966/// fails.
1967pub fn delete_ref_cas(git_dir: &Path, refname: &str, expected_old: ObjectId) -> Result<()> {
1968    store_write_value(git_dir, refname, None, Expected::Oid(expected_old), None)
1969}
1970
1971/// Unlink a loose ref file after `packed-refs` was updated, when the file still contains
1972/// `expected`. Does not modify `packed-refs` (unlike [`delete_ref`] / [`delete_ref_cas`]).
1973///
1974/// Returns `Ok(true)` when the loose file was removed, `Ok(false)` when the OID differed or the
1975/// file was already absent.
1976///
1977/// # Errors
1978///
1979/// Propagates I/O failures and when the ref lock is already held.
1980pub(crate) fn prune_loose_ref_cas(
1981    git_dir: &Path,
1982    refname: &str,
1983    expected: ObjectId,
1984) -> Result<bool> {
1985    ensure_refname_safe_for_storage(refname)?;
1986    let storage_dir = ref_storage_dir(git_dir, refname);
1987    let stor = crate::ref_namespace::storage_ref_name(refname);
1988    let path = storage_dir.join(&stor);
1989    let lock_path = lock_path_for_ref(&path);
1990    use crate::lockfile::{LockFile, LockWait};
1991    let wait = LockWait::files_ref_lock_for_git_dir(git_dir);
1992    let _lock = LockFile::acquire(&lock_path, wait)?;
1993    let outcome = (|| -> Result<bool> {
1994        if !path.is_file() {
1995            return Ok(true);
1996        }
1997        let Ok(Ref::Direct(oid)) = read_ref_file(&path) else {
1998            return Ok(false);
1999        };
2000        if oid != expected {
2001            return Ok(false);
2002        }
2003        remove_empty_ref_directory(&path);
2004        fs::remove_file(&path).map_err(Error::Io)?;
2005        if let Some(parent) = path.parent() {
2006            remove_empty_ref_directory(parent);
2007        }
2008        Ok(true)
2009    })();
2010    outcome
2011}
2012
2013/// A one-time, in-memory snapshot of a ref store's `packed-refs` file.
2014///
2015/// Bulk ref operations — notably `fetch`'s apply phase — resolve and write
2016/// thousands of refs back-to-back. Going through [`resolve_ref`] / [`write_ref`]
2017/// for each one re-reads and re-scans the *entire* `packed-refs` file on every
2018/// call, making the apply O(refs × packed-refs size). On a repository with tens
2019/// of thousands of packed refs that dominates the whole fetch even when no
2020/// objects are transferred. Loading the file once into this snapshot and using
2021/// [`resolve_ref_cached`] / [`write_ref_cached`] makes the apply linear.
2022///
2023/// The snapshot reflects `packed-refs` at load time. [`write_ref_cached`] may
2024/// update the snapshot in place so later namespace checks in the same apply batch
2025/// see loose refs without reloading the file. Otherwise
2026/// treat it as read-only: stale packed data only omits refs that were loose on
2027/// disk (resolved outside the snapshot) to begin with.
2028pub struct PackedRefs {
2029    map: HashMap<String, ObjectId>,
2030    sorted_names: BTreeSet<String>,
2031}
2032
2033fn packed_refs_from_map(map: HashMap<String, ObjectId>) -> PackedRefs {
2034    let sorted_names = map.keys().cloned().collect();
2035    PackedRefs { map, sorted_names }
2036}
2037
2038impl PackedRefs {
2039    /// Load the `packed-refs` snapshot for the ref store backing `git_dir` (the
2040    /// shared common dir for linked worktrees). A missing file yields an empty
2041    /// snapshot; reftable repositories also yield an empty snapshot.
2042    ///
2043    /// # Errors
2044    ///
2045    /// Returns [`Error::Io`] if `packed-refs` exists but cannot be read, or
2046    /// [`Error::PackedRefsUnexpectedLine`] when the file contains lines Git would reject.
2047    pub fn load(git_dir: &Path) -> Result<Self> {
2048        let store = common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
2049        let map = read_packed_refs_map(&store)?;
2050        Ok(packed_refs_from_map(map))
2051    }
2052
2053    /// The packed value recorded for `refname`, if any.
2054    #[must_use]
2055    pub fn get(&self, refname: &str) -> Option<ObjectId> {
2056        self.map.get(refname).copied()
2057    }
2058
2059    /// Whether any packed ref name conflicts with `refname` as a path prefix
2060    /// (e.g. an existing `refs/heads/x` blocks creating `refs/heads/x/y`).
2061    /// Mirrors the internal `packed_ref_namespace_conflict` helper against the snapshot.
2062    #[must_use]
2063    pub fn has_namespace_conflict(&self, refname: &str) -> bool {
2064        packed_refname_namespace_conflict(&self.sorted_names, refname)
2065    }
2066
2067    /// Record a ref written as loose during a cached apply batch so later
2068    /// namespace checks in the same batch see it without reloading `packed-refs`.
2069    pub(crate) fn record_loose_write(&mut self, refname: &str, oid: ObjectId) {
2070        self.map.insert(refname.to_owned(), oid);
2071        self.sorted_names.insert(refname.to_owned());
2072    }
2073}
2074
2075/// Resolve `refname` to its current object id using a preloaded [`PackedRefs`]
2076/// snapshot instead of re-reading `packed-refs`. Loose refs win over packed
2077/// (matching [`resolve_ref`]); a symbolic loose ref falls back to the full
2078/// [`resolve_ref`] resolver (rare for the tracking/tag refs this is used for).
2079/// Returns `Ok(None)` when the ref does not exist.
2080///
2081/// # Errors
2082///
2083/// Returns an error on ref-file I/O failures other than "not found".
2084pub fn resolve_ref_cached(
2085    git_dir: &Path,
2086    refname: &str,
2087    packed: &PackedRefs,
2088) -> Result<Option<ObjectId>> {
2089    if open_ref_store(git_dir)?.format() != RefStorageFormat::Files {
2090        return Ok(resolve_ref(git_dir, refname).ok());
2091    }
2092    resolve_files_ref_cached(git_dir, refname, packed)
2093}
2094
2095fn resolve_files_ref_cached(
2096    git_dir: &Path,
2097    refname: &str,
2098    packed: &PackedRefs,
2099) -> Result<Option<ObjectId>> {
2100    let (store, stor_name) = crate::worktree_ref::resolve_ref_storage(git_dir, refname);
2101    let storage_owned = crate::ref_namespace::storage_ref_name(&stor_name);
2102    let try_names: Vec<&str> = if storage_owned != stor_name {
2103        vec![storage_owned.as_str(), stor_name.as_str()]
2104    } else {
2105        vec![stor_name.as_str()]
2106    };
2107
2108    for name in &try_names {
2109        let path = store.join(name);
2110        match read_ref_file(&path) {
2111            Ok(Ref::Direct(oid)) => return Ok(Some(oid)),
2112            Ok(Ref::Symbolic(target)) => return Ok(resolve_ref(git_dir, &target).ok()),
2113            Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
2114            Err(e) => return Err(e),
2115        }
2116    }
2117
2118    for name in &try_names {
2119        if let Some(oid) = packed.get(name) {
2120            return Ok(Some(oid));
2121        }
2122    }
2123    Ok(None)
2124}
2125
2126/// Like [`write_ref`], but checks the packed-refs namespace conflict against a
2127/// preloaded [`PackedRefs`] snapshot instead of re-reading `packed-refs` on
2128/// every call. Behaves identically to [`write_ref`] otherwise.
2129///
2130/// # Errors
2131///
2132/// Returns [`Error::InvalidRef`] when `refname` is not safe for ref storage, on a namespace
2133/// conflict, or [`Error::Io`] / [`Error::Message`] on filesystem errors.
2134pub fn write_ref_cached(
2135    git_dir: &Path,
2136    refname: &str,
2137    oid: &ObjectId,
2138    packed: &mut PackedRefs,
2139) -> Result<()> {
2140    if open_ref_store(git_dir)?.format() != RefStorageFormat::Files {
2141        return write_ref(git_dir, refname, oid);
2142    }
2143    ensure_refname_safe_for_storage(refname)?;
2144    if packed.has_namespace_conflict(refname) {
2145        return Err(Error::InvalidRef(format!(
2146            "cannot update ref '{refname}': reference namespace conflict"
2147        )));
2148    }
2149    let storage_dir = ref_storage_dir(git_dir, refname);
2150    write_ref_at_storage(git_dir, &storage_dir, refname, oid)?;
2151    packed.record_loose_write(refname, *oid);
2152    Ok(())
2153}
2154
2155/// Render a ref-store path for user-facing diagnostics the way Git does: relative to the
2156/// current working directory when possible (e.g. `.git/refs/foo/bar`), otherwise the full
2157/// path. Git builds these paths relative to the worktree root, so under the normal in-tree
2158/// case this yields the `.git/...` form that the upstream tests expect.
2159fn ref_path_for_display(path: &Path) -> String {
2160    let cwd = crate::environment::Environment::empty().cwd.clone();
2161    if let Ok(rel) = path.strip_prefix(&cwd) {
2162        return rel.to_string_lossy().into_owned();
2163    }
2164    // On platforms where the cwd and the (canonicalized) ref path disagree only by a
2165    // symlinked prefix (e.g. macOS `/tmp` -> `/private/tmp`), retry with both sides
2166    // canonicalized so the relative form is still recovered.
2167    if let (Ok(cwd_c), Ok(path_c)) = (cwd.canonicalize(), path.canonicalize()) {
2168        if let Ok(rel) = path_c.strip_prefix(&cwd_c) {
2169            return rel.to_string_lossy().into_owned();
2170        }
2171    }
2172    path.to_string_lossy().into_owned()
2173}
2174
2175/// Remove the directory at `path` if it is an empty directory tree (contains only empty
2176/// directories, no regular files or lock files). Best-effort: returns silently on any error or
2177/// if `path` is not a directory. Mirrors Git's `remove_empty_directories`
2178/// (`remove_dir_recursively(REMOVE_DIR_EMPTY_ONLY)`), used to clear stale directories that sit
2179/// where a ref file must be created or deleted.
2180pub(crate) fn remove_empty_ref_directory(path: &Path) {
2181    match fs::symlink_metadata(path) {
2182        Ok(meta) if meta.file_type().is_dir() => {}
2183        _ => return,
2184    }
2185    // `remove_dir_all` would also delete files; we only want to remove trees that are entirely
2186    // empty of files. `fs::remove_dir` fails (non-empty) unless we recurse, so walk first.
2187    if dir_tree_has_files(path) {
2188        return;
2189    }
2190    let _ = remove_dir_tree(path);
2191}
2192
2193/// Return true if the directory tree rooted at `dir` contains any non-directory entry.
2194fn dir_tree_has_files(dir: &Path) -> bool {
2195    let Ok(entries) = fs::read_dir(dir) else {
2196        // Unreadable: treat conservatively as "has files" so we do not delete it.
2197        return true;
2198    };
2199    for entry in entries.flatten() {
2200        match entry.file_type() {
2201            Ok(ft) if ft.is_dir() => {
2202                if dir_tree_has_files(&entry.path()) {
2203                    return true;
2204                }
2205            }
2206            Ok(_) => return true, // a file, symlink, or `*.lock`
2207            Err(_) => return true,
2208        }
2209    }
2210    false
2211}
2212
2213/// Walk upward from `ref_path`'s parent, removing empty directories until `refs/`.
2214pub(crate) fn prune_empty_loose_ref_parents(storage_dir: &Path, ref_path: &Path) {
2215    if let Some(dir) = ref_path.parent() {
2216        prune_empty_ref_dirs(storage_dir, dir);
2217    }
2218}
2219
2220/// Remove `dir` and then its parents while they hold no files, stopping at `refs/`.
2221pub(crate) fn prune_empty_ref_dirs(storage_dir: &Path, dir: &Path) {
2222    let refs_root = storage_dir.join("refs");
2223    let mut current = Some(dir);
2224    while let Some(dir) = current {
2225        if dir == refs_root.as_path() || !dir.starts_with(&refs_root) {
2226            break;
2227        }
2228        if dir_tree_has_files(dir) {
2229            break;
2230        }
2231        if fs::remove_dir(dir).is_err() {
2232            break;
2233        }
2234        current = dir.parent();
2235    }
2236}
2237
2238/// Recursively remove an empty directory tree (assumes [`dir_tree_has_files`] returned false).
2239fn remove_dir_tree(dir: &Path) -> io::Result<()> {
2240    for entry in fs::read_dir(dir)? {
2241        let entry = entry?;
2242        if entry.file_type()?.is_dir() {
2243            remove_dir_tree(&entry.path())?;
2244        }
2245    }
2246    fs::remove_dir(dir)
2247}
2248
2249/// Delete a ref.
2250///
2251/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
2252///
2253/// # Errors
2254///
2255/// Returns [`RefStoreError::NotFound`](store::RefStoreError::NotFound) (inside
2256/// [`Error::RefStore`]) when `refname` does not exist, [`Error::InvalidRef`] when it is not
2257/// safe for ref storage, or [`Error::Io`] for filesystem failures.
2258pub fn delete_ref(git_dir: &Path, refname: &str) -> Result<()> {
2259    if matches!(read_raw_ref(git_dir, refname)?, RawRefLookup::NotFound) {
2260        return Err(store::RefStoreError::not_found(refname).into());
2261    }
2262    if open_ref_store(git_dir)?.format() != RefStorageFormat::Files {
2263        let store = open_ref_store(git_dir)?;
2264        let mut flags = legacy_ref_update_flags(refname);
2265        if refname != "HEAD" {
2266            if let Ok(Some(StoreRawRef::Symbolic(_))) = store.read_raw(refname) {
2267                flags.no_deref = true;
2268            }
2269        }
2270        return commit_store_update(
2271            git_dir,
2272            RefUpdate {
2273                name: refname.to_owned(),
2274                new_value: None,
2275                expected: Expected::Exists,
2276                reflog: None,
2277                flags,
2278            },
2279        )
2280        .map_err(|e| map_public_ref_store_error(e, refname));
2281    }
2282    ensure_refname_safe_for_storage(refname)?;
2283    let storage_dir = ref_storage_dir(git_dir, refname);
2284    let stor = crate::ref_namespace::storage_ref_name(refname);
2285    let path = storage_dir.join(&stor);
2286    let lock = lock_path_for_ref(&path);
2287    if fs::symlink_metadata(&lock).is_ok() {
2288        return Err(Error::from(crate::lockfile::LockError::Held { path: lock }));
2289    }
2290
2291    // Remove the packed-refs entry *first* (acquiring the packed-refs lock). Git deletes the
2292    // packed version while holding the lock before unlinking the loose ref, so that a failure to
2293    // rewrite packed-refs (lock held, stale `packed-refs.new`, ...) leaves the reference fully
2294    // intact rather than dropping the loose file and exposing a stale packed value. See t0600
2295    // "delete fails cleanly if packed-refs file is locked / .new write fails".
2296    remove_packed_ref(&storage_dir, &stor)?;
2297
2298    // Remove the loose ref file. An empty directory tree may occupy the ref path (a leftover
2299    // from a previously deleted nested ref); clear it so the delete succeeds rather than failing
2300    // with "Is a directory" (see t0600 "empty directory should not fool 0/1-arg delete").
2301    remove_empty_ref_directory(&path);
2302    match fs::remove_file(&path) {
2303        Ok(()) => {}
2304        Err(e) if e.kind() == io::ErrorKind::NotFound => {}
2305        Err(e)
2306            if e.kind() == io::ErrorKind::NotADirectory
2307                || e.raw_os_error() == Some(libc::ENOTDIR) => {}
2308        // The path is (still) a directory — e.g. a non-empty tree we declined to remove.
2309        // Treat as "loose ref not present"; the packed-refs entry was already removed above.
2310        Err(e)
2311            if e.raw_os_error() == Some(libc::EISDIR) || e.raw_os_error() == Some(libc::EPERM) => {}
2312        Err(e) => return Err(Error::Io(e)),
2313    }
2314
2315    prune_empty_loose_ref_parents(&storage_dir, &path);
2316
2317    let log_path = storage_dir.join("logs").join(&stor);
2318
2319    // Remove the ref's reflog and clean up empty parent directories, matching Git's
2320    // `files_transaction_finish` (which deletes the reflog of any deleted ref and then calls
2321    // `try_remove_empty_parents`). Leaving the reflog behind would block a later nested ref
2322    // (e.g. deleting `k/l` then creating `k/l/m`, which needs `logs/refs/heads/k/l` to be a
2323    // directory) — see t0601 and t1410 "stale dirs do not cause d/f conflicts".
2324    let _ = fs::remove_file(&log_path);
2325
2326    let logs_root = storage_dir.join("logs");
2327    let mut parent = log_path.parent();
2328    while let Some(p) = parent {
2329        if p == logs_root.as_path() || !p.starts_with(&logs_root) {
2330            break;
2331        }
2332        if fs::remove_dir(p).is_err() {
2333            break; // not empty or other error
2334        }
2335        parent = p.parent();
2336    }
2337
2338    Ok(())
2339}
2340
2341/// Remove a single entry from the packed-refs file, rewriting it.
2342pub(crate) fn remove_packed_ref(git_dir: &Path, refname: &str) -> Result<()> {
2343    remove_packed_refs_inner(git_dir, &|name| name == refname, false, false).map(drop)
2344}
2345
2346pub(crate) fn remove_packed_ref_under_lock(git_dir: &Path, refname: &str) -> Result<()> {
2347    remove_packed_refs_inner(git_dir, &|name| name == refname, false, true).map(drop)
2348}
2349
2350/// Remove every record named in `refnames` from `git_dir`'s packed-refs in a single rewrite,
2351/// with the caller already holding `packed-refs.lock`.
2352///
2353/// Returns the removed records verbatim (the ref line plus any `^` peel line, each ending in a
2354/// newline), so [`restore_packed_records_under_lock`] can put them back.
2355///
2356/// # Errors
2357///
2358/// Fails without touching the file when it does not parse, or when writing the replacement fails.
2359pub(crate) fn remove_packed_refs_under_lock(
2360    git_dir: &Path,
2361    refnames: &HashSet<String>,
2362) -> Result<Vec<String>> {
2363    if refnames.is_empty() {
2364        return Ok(Vec::new());
2365    }
2366    remove_packed_refs_inner(git_dir, &|name| refnames.contains(name), true, true)
2367}
2368
2369/// Storage name of a packed record returned by [`remove_packed_refs_under_lock`].
2370pub(crate) fn packed_record_name(record: &str) -> &str {
2371    let line = record.lines().next().unwrap_or("");
2372    line.split_once(' ').map_or("", |(_, name)| name.trim())
2373}
2374
2375/// Object id of a packed record returned by [`remove_packed_refs_under_lock`].
2376pub(crate) fn packed_record_oid(record: &str) -> Option<ObjectId> {
2377    let line = record.lines().next()?;
2378    ObjectId::from_hex(line.split_once(' ')?.0).ok()
2379}
2380
2381/// Put `records` (as returned by [`remove_packed_refs_under_lock`]) back into `git_dir`'s
2382/// packed-refs, keeping the file sorted, with the caller holding `packed-refs.lock`.
2383///
2384/// # Errors
2385///
2386/// Fails without touching the file when it does not parse, or when writing the replacement fails.
2387pub(crate) fn restore_packed_records_under_lock(git_dir: &Path, records: &[&str]) -> Result<()> {
2388    if records.is_empty() {
2389        return Ok(());
2390    }
2391    let packed_path = store::paths::packed_refs_path(git_dir);
2392    let content = match fs::read(&packed_path) {
2393        Ok(c) => c,
2394        Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
2395        Err(e) => return Err(Error::Io(e)),
2396    };
2397    parse_packed_refs_to_map(&packed_path, &content)?;
2398    let content = String::from_utf8(content)
2399        .map_err(|_| packed_refs_unexpected_line(&packed_path, "<invalid utf-8>"))?;
2400
2401    let mut blocks: Vec<String> = Vec::new();
2402    for line in content.lines() {
2403        if line.starts_with('#') {
2404            continue;
2405        }
2406        match blocks.last_mut() {
2407            Some(block) if line.starts_with('^') => block.push_str(line),
2408            _ => blocks.push(line.to_owned()),
2409        }
2410        if let Some(block) = blocks.last_mut() {
2411            block.push('\n');
2412        }
2413    }
2414    blocks.extend(records.iter().map(|r| (*r).to_owned()));
2415    blocks.sort_by(|a, b| packed_record_name(a).cmp(packed_record_name(b)));
2416
2417    let mut out =
2418        String::with_capacity(content.len() + records.iter().map(|r| r.len()).sum::<usize>());
2419    out.push_str(PACKED_REFS_HEADER);
2420    for block in &blocks {
2421        out.push_str(block);
2422    }
2423    replace_packed_refs_file(git_dir, &packed_path, &out, true)
2424}
2425
2426const PACKED_REFS_HEADER: &str = "# pack-refs with: peeled fully-peeled sorted\n";
2427
2428fn remove_packed_refs_inner(
2429    git_dir: &Path,
2430    remove: &dyn Fn(&str) -> bool,
2431    keep_removed: bool,
2432    lock_already_held: bool,
2433) -> Result<Vec<String>> {
2434    let mut removed: Vec<String> = Vec::new();
2435    let packed_path = store::paths::packed_refs_path(git_dir);
2436    let content = match fs::read(&packed_path) {
2437        Ok(c) => c,
2438        Err(e)
2439            if e.kind() == io::ErrorKind::NotFound
2440                || e.kind() == io::ErrorKind::NotADirectory
2441                || e.raw_os_error() == Some(libc::ENOTDIR) =>
2442        {
2443            return Ok(removed);
2444        }
2445        Err(e) => return Err(Error::Io(e)),
2446    };
2447    // Fail before mutating anything if the on-disk file is not readable like Git's.
2448    parse_packed_refs_to_map(&packed_path, &content)?;
2449
2450    let content = String::from_utf8(content)
2451        .map_err(|_| packed_refs_unexpected_line(&packed_path, "<invalid utf-8>"))?;
2452
2453    let mut out = String::with_capacity(content.len());
2454    let mut skip_peeled = false;
2455    let mut changed = false;
2456    // Write a fresh header (don't preserve old comment lines — real git
2457    // regenerates the header on every rewrite).
2458    let mut header_written = false;
2459
2460    for line in content.lines() {
2461        if skip_peeled {
2462            if line.starts_with('^') {
2463                if let Some(record) = removed.last_mut().filter(|_| keep_removed) {
2464                    record.push_str(line);
2465                    record.push('\n');
2466                }
2467                changed = true;
2468                continue;
2469            }
2470            skip_peeled = false;
2471        }
2472
2473        if line.starts_with('#') {
2474            // Skip old header lines — we'll write a fresh one
2475            continue;
2476        }
2477        if line.starts_with('^') {
2478            out.push_str(line);
2479            out.push('\n');
2480            continue;
2481        }
2482
2483        // Write fresh header before the first data line
2484        if !header_written {
2485            out.insert_str(0, PACKED_REFS_HEADER);
2486            header_written = true;
2487        }
2488
2489        let name = line.split_once(' ').map_or("", |(_, name)| name.trim());
2490        if remove(name) {
2491            if keep_removed {
2492                removed.push(format!("{line}\n"));
2493            }
2494            changed = true;
2495            skip_peeled = true;
2496            continue;
2497        }
2498
2499        out.push_str(line);
2500        out.push('\n');
2501    }
2502
2503    if changed {
2504        replace_packed_refs_file(git_dir, &packed_path, &out, lock_already_held)?;
2505    }
2506
2507    Ok(removed)
2508}
2509
2510/// Replace `packed_path` with `body`, taking `packed-refs.lock` unless the caller holds it.
2511fn replace_packed_refs_file(
2512    git_dir: &Path,
2513    packed_path: &Path,
2514    body: &str,
2515    lock_already_held: bool,
2516) -> Result<()> {
2517    // Git rewrites packed-refs under two files: it first takes the `packed-refs.lock`
2518    // lockfile (failing if another process holds it), then writes the new content to the
2519    // `packed-refs.new` tempfile and renames it over `packed-refs`. Mirror both so that
2520    // `update-ref -d` fails cleanly — leaving the reference intact — when either file is
2521    // already present (t0600 "delete fails cleanly if packed-refs file is locked / .new
2522    // write fails").
2523    let lock = lock_path_for_ref(packed_path); // packed-refs.lock
2524    let _packed_lock = if !lock_already_held {
2525        let abs_lock = fs::canonicalize(git_dir)
2526            .map(|d| d.join("packed-refs.lock"))
2527            .unwrap_or_else(|_| lock.clone());
2528        Some(acquire_packed_refs_lock(git_dir, &lock, &abs_lock)?)
2529    } else {
2530        None
2531    };
2532
2533    let tmp = packed_path.with_extension("new");
2534    let mut created_tmp = false;
2535    let write_result = (|| -> Result<()> {
2536        // Staged packed-refs body (not a lock file); exclusive create avoids clobber.
2537        let mut file = std::fs::OpenOptions::new()
2538            .write(true)
2539            .create_new(true)
2540            .open(&tmp)
2541            .map_err(Error::Io)?;
2542        created_tmp = true;
2543        use std::io::Write as _;
2544        file.write_all(body.as_bytes()).map_err(Error::Io)?;
2545        drop(file);
2546        fs::rename(&tmp, packed_path).map_err(Error::Io)?;
2547        created_tmp = false; // consumed by rename
2548        Ok(())
2549    })();
2550
2551    if write_result.is_err() && created_tmp {
2552        let _ = fs::remove_file(&tmp);
2553    }
2554    write_result
2555}
2556
2557/// Read the symbolic ref target of `HEAD`.
2558///
2559/// Returns `None` if HEAD is detached (points directly to a commit hash).
2560///
2561/// # Errors
2562///
2563/// Returns [`Error::Io`] or [`Error::InvalidRef`] on failures.
2564pub fn read_head(git_dir: &Path) -> Result<Option<String>> {
2565    read_symbolic_ref(git_dir, "HEAD")
2566}
2567
2568/// Read symbolic target of any ref.
2569///
2570/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
2571///
2572/// Returns `Ok(Some(target))` when `refname` exists and is symbolic,
2573/// `Ok(None)` when it is direct or missing.
2574pub fn read_symbolic_ref(git_dir: &Path, refname: &str) -> Result<Option<String>> {
2575    match open_ref_store(git_dir)?.read_raw(refname)? {
2576        Some(StoreRawRef::Symbolic(target)) => Ok(Some(target)),
2577        Some(StoreRawRef::Direct(_)) | None => Ok(None),
2578    }
2579}
2580
2581/// Like [`read_symbolic_ref`] but uses an open [`crate::repo::Repository`] ref store (no format re-detection).
2582pub fn read_symbolic_ref_for_repository(
2583    repo: &crate::repo::Repository,
2584    refname: &str,
2585) -> Result<Option<String>> {
2586    match repo.refs().read_raw(refname)? {
2587        Some(StoreRawRef::Symbolic(target)) => Ok(Some(target)),
2588        Some(StoreRawRef::Direct(_)) | None => Ok(None),
2589    }
2590}
2591
2592/// Core `logAllRefUpdates` modes (after config lookup), matching Git's `log_refs_config`.
2593#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2594pub enum LogRefsConfig {
2595    /// `core.logAllRefUpdates` not set; resolved per-repo (bare vs non-bare).
2596    Unset,
2597    /// Explicitly disabled.
2598    None,
2599    /// `true` — log branch-like refs only (see [`should_autocreate_reflog`]).
2600    Normal,
2601    /// `always` — log updates to any ref.
2602    Always,
2603}
2604
2605impl LogRefsConfig {
2606    /// Parse a `core.logAllRefUpdates` value; `None` when it is not a recognised mode.
2607    pub(crate) fn parse(raw: &str) -> Option<Self> {
2608        match raw.trim().to_ascii_lowercase().as_str() {
2609            "always" => Some(Self::Always),
2610            "1" | "true" | "yes" | "on" => Some(Self::Normal),
2611            "0" | "false" | "no" | "off" | "never" => Some(Self::None),
2612            _ => None,
2613        }
2614    }
2615}
2616
2617/// Read `[core] logAllRefUpdates` from the repository config.
2618///
2619/// Returns [`LogRefsConfig::Unset`] when the key is absent.
2620pub fn read_log_refs_config(git_dir: &Path) -> LogRefsConfig {
2621    ConfigSet::load(
2622        &crate::environment::Environment::empty(),
2623        Some(git_dir),
2624        true,
2625    )
2626    .map(|cfg| cfg.log_refs_config())
2627    .unwrap_or(LogRefsConfig::Unset)
2628}
2629
2630/// Effective `logAllRefUpdates` after applying Git's `LOG_REFS_UNSET` rule.
2631///
2632/// Falls back to [`LogRefsConfig::Normal`] when the config cannot be loaded.
2633pub fn effective_log_refs_config(git_dir: &Path) -> LogRefsConfig {
2634    ConfigSet::load(
2635        &crate::environment::Environment::empty(),
2636        Some(git_dir),
2637        true,
2638    )
2639    .map(|cfg| cfg.effective_log_refs_config())
2640    .unwrap_or(LogRefsConfig::Normal)
2641}
2642
2643/// Whether a new reflog file may be auto-created for `refname` given an already-resolved
2644/// `core.logAllRefUpdates` mode (including command-line config).
2645#[must_use]
2646pub fn should_autocreate_reflog_for_mode(refname: &str, mode: LogRefsConfig) -> bool {
2647    match mode {
2648        LogRefsConfig::Always => true,
2649        LogRefsConfig::Normal => {
2650            refname == "HEAD"
2651                || refname.starts_with("refs/heads/")
2652                || refname.starts_with("refs/remotes/")
2653                || refname.starts_with("refs/notes/")
2654        }
2655        LogRefsConfig::None | LogRefsConfig::Unset => false,
2656    }
2657}
2658
2659/// Whether a new reflog file may be auto-created for `refname` (Git `should_autocreate_reflog`).
2660#[must_use]
2661pub fn should_autocreate_reflog(git_dir: &Path, refname: &str) -> bool {
2662    should_autocreate_reflog_for_mode(refname, effective_log_refs_config(git_dir))
2663}
2664
2665/// Like [`should_autocreate_reflog`], using an already-loaded repository config snapshot.
2666#[must_use]
2667pub fn should_autocreate_reflog_with_config(
2668    config: &ConfigSet,
2669    _git_dir: &Path,
2670    refname: &str,
2671) -> bool {
2672    should_autocreate_reflog_for_mode(refname, config.effective_log_refs_config())
2673}
2674
2675/// Write a reflog entry.
2676///
2677/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
2678///
2679/// # Parameters
2680///
2681/// - `git_dir` — path to the git directory.
2682/// - `refname` — reference name (e.g. `"refs/heads/main"`).
2683/// - `old_oid` — previous OID (use `ObjectId::from_bytes(&[0;20])` for a new ref).
2684/// - `new_oid` — new OID.
2685/// - `identity` — `"Name <email> <timestamp> <tz>"` formatted string.
2686/// - `message` — short log message.
2687/// - `force_create` — if true, create the log file even when [`should_autocreate_reflog`] would not.
2688///
2689/// # Errors
2690///
2691/// Returns [`Error::Io`] on filesystem errors.
2692/// Remove stale reflog *files* that occupy path components which must become directories.
2693///
2694/// When a branch is deleted we keep its reflog file (so `branch -D` + later recreate can
2695/// retain history). If a later nested branch (e.g. `k/l/m`) needs `logs/refs/heads/k/l` to be
2696/// a directory, the leftover file at that path blocks `create_dir_all`. Walk from `logs_root`
2697/// down toward `target` and remove any regular file sitting where a directory is required, so
2698/// the directory can be created. Best-effort: filesystem errors are ignored (the subsequent
2699/// `create_dir_all` surfaces any real problem).
2700fn clear_conflicting_reflog_files(logs_root: &Path, target: &Path) {
2701    let Ok(rel) = target.strip_prefix(logs_root) else {
2702        return;
2703    };
2704    let mut cur = logs_root.to_path_buf();
2705    for component in rel.components() {
2706        cur.push(component);
2707        match fs::symlink_metadata(&cur) {
2708            Ok(meta) if meta.file_type().is_dir() => {}
2709            Ok(_) => {
2710                // A non-directory (stale reflog file or symlink) is in the way of a needed
2711                // directory. Remove it so the directory hierarchy can be created.
2712                let _ = fs::remove_file(&cur);
2713            }
2714            Err(_) => break, // does not exist yet (or unreadable); nothing more to clear
2715        }
2716    }
2717}
2718
2719/// Render a reflog entry's `(old, new)` OIDs as hex, widening a null OID to the
2720/// width of the non-null side so the line matches the repository hash algorithm.
2721pub(crate) fn reflog_oid_hex_pair(old: &ObjectId, new: &ObjectId) -> (String, String) {
2722    let width = if !old.is_zero() {
2723        old.algo().hex_len()
2724    } else if !new.is_zero() {
2725        new.algo().hex_len()
2726    } else {
2727        old.algo().hex_len()
2728    };
2729    let render = |oid: &ObjectId| {
2730        if oid.is_zero() {
2731            "0".repeat(width)
2732        } else {
2733            oid.to_hex()
2734        }
2735    };
2736    (render(old), render(new))
2737}
2738
2739fn map_reflog_store_validation(err: store::RefStoreError) -> Error {
2740    match err {
2741        store::RefStoreError::InvalidReflog(sig) => Error::InvalidSignature(sig),
2742        other => Error::from(other),
2743    }
2744}
2745
2746fn validate_reflog_message(message: &str) -> Result<()> {
2747    store::validate_reflog_message(message).map_err(map_reflog_store_validation)
2748}
2749
2750fn validate_reflog_identity_string(identity: &str) -> Result<()> {
2751    store::validate_reflog_identity_string(identity).map_err(map_reflog_store_validation)
2752}
2753
2754/// Reject reflog metadata that would forge or corrupt a log line on either backend.
2755fn validate_reflog_update(log: &ReflogUpdate) -> Result<()> {
2756    store::validate_reflog_update(log).map_err(map_reflog_store_validation)
2757}
2758
2759/// Append one line to the reflog for `refname` using a validated [`Signature`].
2760///
2761/// # Errors
2762///
2763/// Propagates I/O failures, invalid refname errors, and [`Error::InvalidSignature`].
2764pub fn append_reflog_with_signature(
2765    git_dir: &Path,
2766    refname: &str,
2767    old_oid: &ObjectId,
2768    new_oid: &ObjectId,
2769    identity: &Signature,
2770    message: &str,
2771    force_create: bool,
2772) -> Result<()> {
2773    identity.validate().map_err(Error::InvalidSignature)?;
2774    let formatted = format_reflog_identity(&identity.reflog_name_email(), identity.time);
2775    append_reflog_with_config(
2776        git_dir,
2777        refname,
2778        old_oid,
2779        new_oid,
2780        &formatted,
2781        message,
2782        force_create,
2783        None,
2784    )
2785}
2786
2787/// Append one line to the reflog for `refname`.
2788///
2789/// Creates the reflog file when `force_create` is true or when the ref already
2790/// has a reflog. Identity and message are written in Git's standard reflog format.
2791///
2792/// # Errors
2793///
2794/// Propagates I/O failures and invalid refname errors.
2795pub fn append_reflog(
2796    git_dir: &Path,
2797    refname: &str,
2798    old_oid: &ObjectId,
2799    new_oid: &ObjectId,
2800    identity: &str,
2801    message: &str,
2802    force_create: bool,
2803) -> Result<()> {
2804    append_reflog_with_config(
2805        git_dir,
2806        refname,
2807        old_oid,
2808        new_oid,
2809        identity,
2810        message,
2811        force_create,
2812        None,
2813    )
2814}
2815
2816/// Like [`append_reflog`], reusing `config` for reflog auto-create policy when provided.
2817#[allow(clippy::too_many_arguments)]
2818pub fn append_reflog_with_config(
2819    git_dir: &Path,
2820    refname: &str,
2821    old_oid: &ObjectId,
2822    new_oid: &ObjectId,
2823    identity: &str,
2824    message: &str,
2825    force_create: bool,
2826    config: Option<&ConfigSet>,
2827) -> Result<()> {
2828    validate_reflog_identity_string(identity)?;
2829    validate_reflog_message(message)?;
2830    test_inject_reflog_fail(refname)?;
2831    if open_ref_store(git_dir)?.format() != RefStorageFormat::Files {
2832        return crate::reftable::reftable_append_reflog(
2833            git_dir,
2834            refname,
2835            old_oid,
2836            new_oid,
2837            identity,
2838            message,
2839            force_create,
2840        );
2841    }
2842    let storage_dir = ref_storage_dir(git_dir, refname);
2843    let stor = crate::ref_namespace::storage_ref_name(refname);
2844    let log_path = storage_dir.join("logs").join(&stor);
2845    let may_create = force_create
2846        || match config {
2847            Some(cfg) => should_autocreate_reflog_with_config(cfg, git_dir, refname),
2848            None => should_autocreate_reflog(git_dir, refname),
2849        };
2850    if !may_create && !log_path.exists() {
2851        return Ok(());
2852    }
2853    if let Some(parent) = log_path.parent() {
2854        // A stale reflog *file* left behind by a deleted branch (e.g. `logs/refs/heads/k/l`)
2855        // can occupy a path component that must now become a directory (for `k/l/m`). Git
2856        // removes such leftovers while creating the reflog; mirror that so `create_dir_all`
2857        // does not fail with "File exists" / "Not a directory".
2858        let logs_root = storage_dir.join("logs");
2859        clear_conflicting_reflog_files(&logs_root, parent);
2860        crate::git_dir::io_error::io_with_git_path(
2861            git_dir,
2862            parent,
2863            crate::git_dir::GitDirPathOperation::CreateDir,
2864            fs::create_dir_all(parent),
2865        )?;
2866    }
2867    // A reflog records `<old> <new>` at the repository's hash width. The null
2868    // ("0000…") side of a ref creation/deletion is stored at SHA-1 width by
2869    // callers; widen it to match the non-null side so sha256 reflogs are
2870    // internally consistent (and re-parseable).
2871    let (old_hex, new_hex) = reflog_oid_hex_pair(old_oid, new_oid);
2872    let line = if message.is_empty() {
2873        format!("{old_hex} {new_hex} {identity}\n")
2874    } else {
2875        format!("{old_hex} {new_hex} {identity}\t{message}\n")
2876    };
2877    let mut file = fs::OpenOptions::new()
2878        .create(true)
2879        .append(true)
2880        .open(&log_path)
2881        .map_err(|source| {
2882            crate::git_dir::io_error::map_git_dir_io(
2883                git_dir,
2884                &log_path,
2885                crate::git_dir::GitDirPathOperation::AppendReflog,
2886                source,
2887            )
2888        })?;
2889    use io::Write;
2890    crate::git_dir::io_error::io_with_git_path(
2891        git_dir,
2892        &log_path,
2893        crate::git_dir::GitDirPathOperation::AppendReflog,
2894        file.write_all(line.as_bytes()),
2895    )?;
2896    Ok(())
2897}
2898
2899fn pack_ref_include_pattern(all: bool) -> &'static [u8] {
2900    if all {
2901        b"*"
2902    } else {
2903        b"refs/tags/*"
2904    }
2905}
2906
2907fn ref_matches_pack_include(refname: &str, all: bool) -> bool {
2908    crate::wildmatch::wildmatch(pack_ref_include_pattern(all), refname.as_bytes(), 0)
2909}
2910
2911fn collect_loose_ref_files(
2912    dir: &Path,
2913    prefix: &str,
2914    out: &mut Vec<(String, PathBuf)>,
2915) -> Result<()> {
2916    let read = match fs::read_dir(dir) {
2917        Ok(r) => r,
2918        Err(e)
2919            if e.kind() == io::ErrorKind::NotFound
2920                || e.kind() == io::ErrorKind::NotADirectory
2921                || e.raw_os_error() == Some(libc::ENOTDIR) =>
2922        {
2923            return Ok(());
2924        }
2925        Err(e) => return Err(Error::Io(e)),
2926    };
2927    for entry in read {
2928        let entry = entry?;
2929        let name = entry.file_name().to_string_lossy().to_string();
2930        let refname = format!("{prefix}{name}");
2931        let path = entry.path();
2932        if path.is_dir() {
2933            collect_loose_ref_files(&path, &format!("{refname}/"), out)?;
2934        } else if path.is_file() {
2935            out.push((refname, path));
2936        }
2937    }
2938    Ok(())
2939}
2940
2941fn load_packed_refs_with_peel(
2942    packed_path: &Path,
2943    merged: &mut HashMap<String, (ObjectId, Option<ObjectId>)>,
2944) -> Result<()> {
2945    if !packed_path.is_file() {
2946        return Ok(());
2947    }
2948    let data = fs::read(packed_path)?;
2949    parse_packed_refs_to_map(packed_path, &data)?;
2950    let content = String::from_utf8(data)
2951        .map_err(|_| packed_refs_unexpected_line(packed_path, "<invalid utf-8>"))?;
2952    let mut last_name: Option<String> = None;
2953    for line in content.lines() {
2954        if line.starts_with('#') {
2955            continue;
2956        }
2957        if let Some(hex) = line.strip_prefix('^') {
2958            let Ok(peel_oid) = ObjectId::from_hex(hex.trim()) else {
2959                continue;
2960            };
2961            if let Some(name) = &last_name {
2962                if let Some(entry) = merged.get_mut(name) {
2963                    entry.1 = Some(peel_oid);
2964                }
2965            }
2966            continue;
2967        }
2968        let mut parts = line.splitn(2, ' ');
2969        let Some(hex) = parts.next() else {
2970            continue;
2971        };
2972        let Some(name) = parts.next() else {
2973            continue;
2974        };
2975        let name = name.trim();
2976        let Ok(oid) = ObjectId::from_hex(hex) else {
2977            continue;
2978        };
2979        merged.insert(name.to_owned(), (oid, None));
2980        last_name = Some(name.to_owned());
2981    }
2982    Ok(())
2983}
2984
2985/// Pack loose refs for `repo` (files `packed-refs` or reftable compaction).
2986pub(crate) fn pack_refs_for_repository(
2987    repo: &crate::repo::Repository,
2988    all: bool,
2989    dry_run: bool,
2990) -> Result<(PathBuf, usize, usize)> {
2991    let git_dir = repo.git_dir();
2992    if open_ref_store(git_dir)?.format() != RefStorageFormat::Files {
2993        return pack_refs_reftable(git_dir, dry_run);
2994    }
2995    pack_refs_files(git_dir, repo.odb(), all, dry_run)
2996}
2997
2998fn pack_refs_reftable(git_dir: &Path, dry_run: bool) -> Result<(PathBuf, usize, usize)> {
2999    use crate::reftable::ReftableStack;
3000    let store_git_dir = common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
3001    let rt_dir = store_git_dir.join("reftable");
3002    let tables_before = fs::read_to_string(rt_dir.join("tables.list"))
3003        .map(|s| s.lines().filter(|l| !l.is_empty()).count())
3004        .unwrap_or(0);
3005    if dry_run {
3006        let tables_pruned = tables_before.saturating_sub(1);
3007        return Ok((rt_dir, 0, tables_pruned));
3008    }
3009    let mut stack = ReftableStack::open(&store_git_dir)?;
3010    stack.compact()?;
3011    let tables_after = fs::read_to_string(rt_dir.join("tables.list"))
3012        .map(|s| s.lines().filter(|l| !l.is_empty()).count())
3013        .unwrap_or(0);
3014    Ok((rt_dir, 0, tables_before.saturating_sub(tables_after)))
3015}
3016
3017fn pack_refs_files(
3018    git_dir: &Path,
3019    odb: &crate::odb::Odb,
3020    all: bool,
3021    dry_run: bool,
3022) -> Result<(PathBuf, usize, usize)> {
3023    let storage_git_dir = common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
3024    let packed_path = store::paths::packed_refs_path(&storage_git_dir);
3025
3026    let mut merged: HashMap<String, (ObjectId, Option<ObjectId>)> = HashMap::new();
3027    load_packed_refs_with_peel(&packed_path, &mut merged)?;
3028
3029    let mut loose_files = Vec::new();
3030    collect_loose_ref_files(&storage_git_dir.join("refs"), "refs/", &mut loose_files)?;
3031
3032    let mut packed_loose = 0usize;
3033    let mut to_prune: Vec<(String, ObjectId)> = Vec::new();
3034
3035    for (refname, path) in loose_files {
3036        if crate::worktree_ref::is_per_worktree_ref(&refname) {
3037            continue;
3038        }
3039        if !ref_matches_pack_include(&refname, all) {
3040            continue;
3041        }
3042        let Ok(Ref::Direct(oid)) = read_ref_file(&path) else {
3043            continue;
3044        };
3045        if odb.read(&oid).is_err() {
3046            continue;
3047        }
3048        let peel = if refname.starts_with("refs/tags/") {
3049            packed_ref_peel(odb, oid)
3050        } else {
3051            None
3052        };
3053        merged.insert(refname.clone(), (oid, peel));
3054        to_prune.push((refname, oid));
3055        let _ = path;
3056        packed_loose += 1;
3057    }
3058
3059    if packed_loose == 0 && merged.is_empty() {
3060        return Ok((packed_path, 0, 0));
3061    }
3062
3063    if dry_run {
3064        return Ok((packed_path, packed_loose, to_prune.len()));
3065    }
3066
3067    let mut names: Vec<String> = merged.keys().cloned().collect();
3068    names.sort();
3069
3070    let mut body = String::from("# pack-refs with: peeled fully-peeled sorted\n");
3071    for name in &names {
3072        let Some((oid, peel)) = merged.get(name) else {
3073            continue;
3074        };
3075        body.push_str(&oid.to_hex());
3076        body.push(' ');
3077        body.push_str(name);
3078        body.push('\n');
3079        if let Some(p) = peel {
3080            body.push('^');
3081            body.push_str(&p.to_hex());
3082            body.push('\n');
3083        }
3084    }
3085
3086    atomic_rewrite_packed_refs(&storage_git_dir, &body)?;
3087
3088    #[cfg(test)]
3089    pack_refs_test_hooks::run_before_prune();
3090
3091    let mut pruned = 0usize;
3092    for (refname, expected) in &to_prune {
3093        match prune_loose_ref_cas(git_dir, refname, *expected) {
3094            Ok(true) => pruned += 1,
3095            Ok(false) => {
3096                remove_packed_ref(git_dir, refname)?;
3097            }
3098            Err(e) => return Err(e),
3099        }
3100    }
3101
3102    Ok((packed_path, packed_loose, pruned))
3103}
3104
3105/// Test hook: runs once after `packed-refs` is rewritten and before loose refs are pruned.
3106#[cfg(test)]
3107mod pack_refs_test_hooks {
3108    use std::cell::RefCell;
3109
3110    thread_local! {
3111        static BEFORE_PRUNE: RefCell<Option<Box<dyn Fn()>>> = const { RefCell::new(None) };
3112    }
3113
3114    pub fn set_before_prune(f: Option<Box<dyn Fn()>>) {
3115        BEFORE_PRUNE.with(|c| *c.borrow_mut() = f);
3116    }
3117
3118    pub fn run_before_prune() {
3119        BEFORE_PRUNE.with(|c| {
3120            if let Some(f) = c.borrow_mut().take() {
3121                f();
3122            }
3123        });
3124    }
3125}
3126
3127/// Test hook: when set to a ref name, the next [`append_reflog`] for that ref fails.
3128///
3129/// Only active in debug builds; used by regression tests for commit ref/reflog atomicity.
3130#[cfg(test)]
3131mod reflog_fail_inject {
3132    use std::cell::RefCell;
3133
3134    // hygiene: test-only reflog failure injection hook
3135    thread_local! {
3136        pub static INJECT: RefCell<Option<String>> = const { RefCell::new(None) };
3137    }
3138}
3139
3140/// Test hook: fail the next reflog append for `refname` (or any ref when `None`).
3141#[cfg(test)]
3142pub fn set_test_inject_reflog_fail(refname: Option<&str>) {
3143    reflog_fail_inject::INJECT.with(|c| {
3144        *c.borrow_mut() = refname.map(str::to_owned);
3145    });
3146}
3147
3148#[cfg(test)]
3149fn test_inject_reflog_fail(refname: &str) -> Result<()> {
3150    reflog_fail_inject::INJECT.with(|c| {
3151        if c.borrow().as_deref() == Some(refname) {
3152            return Err(Error::Io(std::io::Error::other("injected reflog failure")));
3153        }
3154        Ok(())
3155    })
3156}
3157
3158#[cfg(not(test))]
3159fn test_inject_reflog_fail(_refname: &str) -> Result<()> {
3160    Ok(())
3161}
3162
3163/// Move a checked-out branch to `new_oid` and append matching branch and `HEAD` reflogs.
3164///
3165/// Verifies `HEAD` is a symbolic ref to `branch_ref`, then runs a storage transaction:
3166/// on the files backend the branch ref lock is held from CAS through reflog writes and
3167/// ref commit; on reftable, branch and `HEAD` logs are written in one transaction.
3168/// Reflog failures before the ref commit do not advance the branch tip.
3169///
3170/// # Errors
3171///
3172/// Returns [`Error::Message`] when `HEAD` does not point at `branch_ref`,
3173/// [`store::RefStoreError::ExpectedMismatch`] (see [`verify_branch_commit_cas`]) when the
3174/// CAS check fails, or an I/O error when ref/reflog writes fail.
3175pub struct BranchCommitRefUpdate<'a> {
3176    /// Branch ref to update (e.g. `refs/heads/main`).
3177    pub branch_ref: &'a str,
3178    /// Expected current tip; `None` when the branch must not exist yet (unborn).
3179    pub expected_old: Option<ObjectId>,
3180    /// New commit OID for the branch tip.
3181    pub new_oid: ObjectId,
3182    /// Committer identity for reflog lines.
3183    pub identity: &'a str,
3184    /// Reflog message (without trailing newline).
3185    pub reflog_message: &'a str,
3186}
3187
3188/// Compare expected and current branch tips for compare-and-swap ref updates.
3189///
3190/// `None` means the branch is absent (unborn) on either side.
3191///
3192/// # Errors
3193///
3194/// Returns [`Error::RefStore`] with [`store::RefStoreError::ExpectedMismatch`] when
3195/// `current` does not match `expected`; `expected` is [`Expected::Missing`] when the branch
3196/// had to be absent, and `actual` is `None` when it was.
3197pub fn verify_branch_commit_cas(
3198    expected: Option<ObjectId>,
3199    current: Option<ObjectId>,
3200    refname: &str,
3201) -> Result<()> {
3202    verify_branch_cas(expected, current, refname)
3203}
3204
3205fn verify_branch_cas(
3206    expected: Option<ObjectId>,
3207    current: Option<ObjectId>,
3208    refname: &str,
3209) -> Result<()> {
3210    if expected == current {
3211        return Ok(());
3212    }
3213    Err(store::RefStoreError::ExpectedMismatch {
3214        name: refname.to_owned(),
3215        expected: expected.map_or(Expected::Missing, Expected::Oid),
3216        actual: current.map(StoreRawRef::Direct),
3217    }
3218    .into())
3219}
3220
3221fn read_loose_or_packed_oid(git_dir: &Path, refname: &str) -> Result<Option<ObjectId>> {
3222    let (store, stor_name) = crate::worktree_ref::resolve_ref_storage(git_dir, refname);
3223    let storage_owned = crate::ref_namespace::storage_ref_name(&stor_name);
3224    let try_names: Vec<&str> = if storage_owned != stor_name {
3225        vec![storage_owned.as_str(), stor_name.as_str()]
3226    } else {
3227        vec![stor_name.as_str()]
3228    };
3229    for name in try_names {
3230        let path = store.join(name);
3231        if let Ok(Ref::Direct(oid)) = read_ref_file(&path) {
3232            return Ok(Some(oid));
3233        }
3234        if let Some(oid) = lookup_packed_ref(&store, name)? {
3235            return Ok(Some(oid));
3236        }
3237    }
3238    Ok(None)
3239}
3240
3241#[cfg(test)]
3242mod test_pause_inject_serial {
3243    use std::sync::{Mutex, OnceLock};
3244
3245    // hygiene: test-only pause injection serialization
3246    static SERIAL: OnceLock<Mutex<()>> = OnceLock::new();
3247
3248    pub fn lock() -> std::sync::MutexGuard<'static, ()> {
3249        SERIAL
3250            .get_or_init(|| Mutex::new(()))
3251            .lock()
3252            .unwrap_or_else(|e| e.into_inner())
3253    }
3254}
3255
3256/// Serialize tests that install global ref pause injection hooks (parallel `cargo test` safe).
3257#[cfg(test)]
3258pub fn test_pause_injection_serial_guard() -> std::sync::MutexGuard<'static, ()> {
3259    test_pause_inject_serial::lock()
3260}
3261
3262#[cfg(test)]
3263mod branch_ref_pause_inject {
3264    use std::sync::{Condvar, Mutex, OnceLock};
3265
3266    pub struct PauseGate {
3267        pub wait: Mutex<bool>,
3268        pub cv: Condvar,
3269    }
3270
3271    // hygiene: test-only branch-ref commit pause injection
3272    static PAUSE_BEFORE_COMMIT: OnceLock<Mutex<Option<std::sync::Arc<PauseGate>>>> =
3273        OnceLock::new();
3274
3275    pub fn pause_slot() -> &'static Mutex<Option<std::sync::Arc<PauseGate>>> {
3276        PAUSE_BEFORE_COMMIT.get_or_init(|| Mutex::new(None))
3277    }
3278}
3279
3280/// Synchronization gate used with [`set_test_inject_branch_ref_pause_before_commit`].
3281#[cfg(test)]
3282pub type BranchRefPauseGate = std::sync::Arc<branch_ref_pause_inject::PauseGate>;
3283
3284/// Test-only: pause [`update_branch_for_commit`] on the files backend after reflogs, before committing the ref.
3285#[cfg(test)]
3286pub fn set_test_inject_branch_ref_pause_before_commit(gate: Option<BranchRefPauseGate>) {
3287    *branch_ref_pause_inject::pause_slot()
3288        .lock()
3289        .unwrap_or_else(|e| e.into_inner()) = gate;
3290}
3291
3292#[cfg(test)]
3293fn test_branch_ref_pause_before_commit() {
3294    let gate = branch_ref_pause_inject::pause_slot()
3295        .lock()
3296        .unwrap_or_else(|e| e.into_inner())
3297        .clone();
3298    if let Some(gate) = gate {
3299        let mut wait = gate.wait.lock().unwrap_or_else(|e| e.into_inner());
3300        *wait = true;
3301        gate.cv.notify_all();
3302        while *wait {
3303            wait = gate.cv.wait(wait).unwrap_or_else(|e| e.into_inner());
3304        }
3305    }
3306}
3307
3308#[cfg(not(test))]
3309fn test_branch_ref_pause_before_commit() {}
3310
3311#[cfg(test)]
3312mod ref_store_batch_pause_inject {
3313    use std::sync::{Condvar, Mutex, OnceLock};
3314
3315    pub struct PauseGate {
3316        pub wait: Mutex<bool>,
3317        pub cv: Condvar,
3318    }
3319
3320    // hygiene: test-only ref-store batch commit pause injection
3321    /// Armed pause: ref index, the repository it applies to, and the gate.
3322    pub type PauseSlot = Option<(usize, std::path::PathBuf, std::sync::Arc<PauseGate>)>;
3323
3324    static PAUSE_AFTER_REF_WRITE: OnceLock<Mutex<PauseSlot>> = OnceLock::new();
3325
3326    pub fn pause_slot() -> &'static Mutex<PauseSlot> {
3327        PAUSE_AFTER_REF_WRITE.get_or_init(|| Mutex::new(None))
3328    }
3329}
3330
3331/// Synchronization gate used with [`set_test_inject_ref_store_batch_pause_after_apply`].
3332#[cfg(test)]
3333pub type RefStoreBatchPauseGate = std::sync::Arc<ref_store_batch_pause_inject::PauseGate>;
3334
3335/// Test-only: build a pause gate for [`set_test_inject_ref_store_batch_pause_after_apply`].
3336#[cfg(test)]
3337pub fn new_ref_store_batch_pause_gate() -> RefStoreBatchPauseGate {
3338    std::sync::Arc::new(ref_store_batch_pause_inject::PauseGate {
3339        wait: std::sync::Mutex::new(false),
3340        cv: std::sync::Condvar::new(),
3341    })
3342}
3343
3344/// Test-only: pause files ref-store batch commit after persisting the ref at `apply_index` (0-based).
3345///
3346/// Only ref stores for `git_dir` pause, so tests running in parallel against
3347/// other repositories are unaffected.
3348#[cfg(test)]
3349pub fn set_test_inject_ref_store_batch_pause_after_apply(
3350    apply_index: usize,
3351    git_dir: &Path,
3352    gate: Option<RefStoreBatchPauseGate>,
3353) {
3354    *ref_store_batch_pause_inject::pause_slot()
3355        .lock()
3356        .unwrap_or_else(|e| e.into_inner()) = gate.map(|g| (apply_index, git_dir.to_path_buf(), g));
3357}
3358
3359#[cfg(test)]
3360struct RefStorePauseTestSerial {
3361    /// Held while one test owns ref-store batch pause injection.
3362    holder: Option<std::thread::ThreadId>,
3363}
3364
3365#[cfg(test)]
3366static REF_STORE_PAUSE_TEST_SERIAL: std::sync::Mutex<RefStorePauseTestSerial> =
3367    std::sync::Mutex::new(RefStorePauseTestSerial { holder: None });
3368
3369/// Serialize ref-store batch pause injection tests and clear injection on drop.
3370#[cfg(test)]
3371pub(crate) struct RefStoreBatchPauseTestScope {
3372    _serial: std::sync::MutexGuard<'static, RefStorePauseTestSerial>,
3373}
3374
3375#[cfg(test)]
3376pub(crate) fn ref_store_batch_pause_test_scope() -> RefStoreBatchPauseTestScope {
3377    let mut serial = REF_STORE_PAUSE_TEST_SERIAL
3378        .lock()
3379        .unwrap_or_else(|e| e.into_inner());
3380    assert!(
3381        serial.holder.is_none(),
3382        "ref_store_batch_pause_test_scope must not nest"
3383    );
3384    serial.holder = Some(std::thread::current().id());
3385    set_test_inject_ref_store_batch_pause_after_apply(0, Path::new(""), None);
3386    RefStoreBatchPauseTestScope { _serial: serial }
3387}
3388
3389#[cfg(test)]
3390impl Drop for RefStoreBatchPauseTestScope {
3391    fn drop(&mut self) {
3392        set_test_inject_ref_store_batch_pause_after_apply(0, Path::new(""), None);
3393        self._serial.holder = None;
3394    }
3395}
3396
3397#[cfg(test)]
3398pub(crate) fn test_ref_store_batch_pause_after_ref_write(idx: usize, git_dir: &Path) {
3399    let slot = ref_store_batch_pause_inject::pause_slot()
3400        .lock()
3401        .unwrap_or_else(|e| e.into_inner())
3402        .clone();
3403    if let Some((target, target_dir, gate)) = slot {
3404        let same_repo = |a: &Path, b: &Path| {
3405            a == b || matches!((a.canonicalize(), b.canonicalize()), (Ok(a), Ok(b)) if a == b)
3406        };
3407        if idx != target || !same_repo(git_dir, &target_dir) {
3408            return;
3409        }
3410        let mut wait = gate.wait.lock().unwrap_or_else(|e| e.into_inner());
3411        *wait = true;
3412        gate.cv.notify_all();
3413        while *wait {
3414            wait = gate.cv.wait(wait).unwrap_or_else(|e| e.into_inner());
3415        }
3416    }
3417}
3418
3419#[cfg(not(test))]
3420pub(crate) fn test_ref_store_batch_pause_after_ref_write(_idx: usize, _git_dir: &Path) {}
3421
3422/// Loose branch ref lock held between CAS validation and the ref file commit.
3423///
3424/// Dropping without [`commit_branch_commit_ref_lock`](Self::commit) aborts the lock file.
3425pub struct BranchCommitRefLock {
3426    lock: Option<crate::lockfile::LockFile>,
3427    disarmed: bool,
3428}
3429
3430impl BranchCommitRefLock {
3431    /// Acquire the branch ref lock and verify `expected_old` before worktree mutations.
3432    ///
3433    /// # Errors
3434    ///
3435    /// Same as [`update_branch_for_commit_with_config`], except no reflogs or ref write occur yet.
3436    pub fn acquire(
3437        git_dir: &Path,
3438        branch_ref: &str,
3439        expected_old: Option<ObjectId>,
3440    ) -> Result<Self> {
3441        ensure_refname_safe_for_storage(branch_ref)?;
3442        let storage_dir = ref_storage_dir(git_dir, branch_ref);
3443        if packed_ref_namespace_conflict(&storage_dir, branch_ref)? {
3444            return Err(Error::InvalidRef(format!(
3445                "cannot update ref '{}': reference namespace conflict",
3446                branch_ref
3447            )));
3448        }
3449        let stor = crate::ref_namespace::storage_ref_name(branch_ref);
3450        let path = storage_dir.join(&stor);
3451        remove_empty_ref_directory(&path);
3452        if fs::symlink_metadata(&path)
3453            .map(|m| m.file_type().is_dir())
3454            .unwrap_or(false)
3455        {
3456            let display = ref_path_for_display(&path);
3457            return Err(crate::error::RefLockError::DirectoryInTheWay {
3458                refname: branch_ref.to_owned(),
3459                path: display,
3460            }
3461            .into());
3462        }
3463        if let Some(parent) = path.parent() {
3464            crate::git_dir::io_error::io_with_git_path(
3465                git_dir,
3466                parent,
3467                crate::git_dir::GitDirPathOperation::CreateDir,
3468                fs::create_dir_all(parent),
3469            )?;
3470        }
3471
3472        let lock_path = lock_path_for_ref(&path);
3473        use crate::lockfile::{LockError, LockFile, LockWait};
3474        let wait = LockWait::files_ref_lock_for_git_dir(git_dir);
3475        let lock = LockFile::acquire(&lock_path, wait).map_err(|err| match err {
3476            held @ LockError::Held { .. } => Error::from(held),
3477            LockError::Io { source, path } => crate::git_dir::io_error::map_git_dir_io(
3478                git_dir,
3479                path.as_path(),
3480                crate::git_dir::GitDirPathOperation::UpdateRef,
3481                source,
3482            ),
3483        })?;
3484
3485        let current = read_loose_or_packed_oid(git_dir, branch_ref)?;
3486        verify_branch_cas(expected_old, current, branch_ref)?;
3487
3488        Ok(Self {
3489            lock: Some(lock),
3490            disarmed: false,
3491        })
3492    }
3493
3494    /// Append branch and `HEAD` reflog entries while holding the lock from [`Self::acquire`].
3495    ///
3496    /// Intended to run before worktree checkout so reflog I/O failures do not leave the
3497    /// index or working tree ahead of `HEAD`.
3498    ///
3499    /// # Errors
3500    ///
3501    /// Returns I/O or reflog failures; the ref lock is aborted and any branch reflog line
3502    /// written in this call is truncated.
3503    pub fn write_reflogs(
3504        &mut self,
3505        git_dir: &Path,
3506        update: &BranchCommitRefUpdate<'_>,
3507        reflog_old: &ObjectId,
3508        config: &ConfigSet,
3509    ) -> Result<()> {
3510        write_branch_commit_reflogs(git_dir, self, update, reflog_old, config)
3511    }
3512
3513    /// Write the new OID into the held lock file and rename it over the branch ref.
3514    ///
3515    /// Call after the worktree matches `update.new_oid` when splitting reflog work from checkout.
3516    ///
3517    /// # Errors
3518    ///
3519    /// Returns I/O failures; the ref lock is aborted unless the rename succeeds.
3520    pub fn finish_ref_commit(
3521        mut self,
3522        git_dir: &Path,
3523        update: &BranchCommitRefUpdate<'_>,
3524    ) -> Result<()> {
3525        finish_branch_commit_ref_lock_files(git_dir, &mut self, update)?;
3526        self.disarmed = true;
3527        Ok(())
3528    }
3529
3530    /// Write reflogs and commit the branch ref while holding the lock from [`Self::acquire`].
3531    ///
3532    /// # Errors
3533    ///
3534    /// Returns I/O or reflog failures; the lock is aborted unless the ref commit succeeds.
3535    pub fn commit(
3536        mut self,
3537        git_dir: &Path,
3538        update: &BranchCommitRefUpdate<'_>,
3539        reflog_old: &ObjectId,
3540        config: &ConfigSet,
3541    ) -> Result<()> {
3542        write_branch_commit_reflogs(git_dir, &mut self, update, reflog_old, config)?;
3543        finish_branch_commit_ref_lock_files(git_dir, &mut self, update)?;
3544        self.disarmed = true;
3545        Ok(())
3546    }
3547}
3548
3549impl Drop for BranchCommitRefLock {
3550    fn drop(&mut self) {
3551        drop(self.lock.take());
3552    }
3553}
3554
3555fn write_branch_commit_reflogs(
3556    git_dir: &Path,
3557    lock: &mut BranchCommitRefLock,
3558    update: &BranchCommitRefUpdate<'_>,
3559    reflog_old: &ObjectId,
3560    config: &ConfigSet,
3561) -> Result<()> {
3562    let mut branch_reflog_written = false;
3563    if should_autocreate_reflog_with_config(config, git_dir, update.branch_ref) {
3564        match append_reflog_with_config(
3565            git_dir,
3566            update.branch_ref,
3567            reflog_old,
3568            &update.new_oid,
3569            update.identity,
3570            update.reflog_message,
3571            false,
3572            Some(config),
3573        ) {
3574            Ok(()) => branch_reflog_written = true,
3575            Err(err) => {
3576                drop(lock.lock.take());
3577                return Err(err);
3578            }
3579        }
3580    }
3581
3582    if should_autocreate_reflog_with_config(config, git_dir, "HEAD") {
3583        if let Err(err) = append_reflog_with_config(
3584            git_dir,
3585            "HEAD",
3586            reflog_old,
3587            &update.new_oid,
3588            update.identity,
3589            update.reflog_message,
3590            false,
3591            Some(config),
3592        ) {
3593            if branch_reflog_written {
3594                let _ = crate::reflog::truncate_last_reflog_line(git_dir, update.branch_ref);
3595            }
3596            drop(lock.lock.take());
3597            return Err(err);
3598        }
3599    }
3600
3601    Ok(())
3602}
3603
3604fn finish_branch_commit_ref_lock_files(
3605    git_dir: &Path,
3606    lock: &mut BranchCommitRefLock,
3607    update: &BranchCommitRefUpdate<'_>,
3608) -> Result<()> {
3609    test_branch_ref_pause_before_commit();
3610
3611    let storage_dir = ref_storage_dir(git_dir, update.branch_ref);
3612    let stor = crate::ref_namespace::storage_ref_name(update.branch_ref);
3613    let path = storage_dir.join(&stor);
3614    let content = format!("{}\n", update.new_oid);
3615    use crate::lockfile::LockError;
3616    use std::io::Write as _;
3617    let mut file_lock = lock
3618        .lock
3619        .take()
3620        .ok_or_else(|| Error::Message("branch ref lock already released".into()))?;
3621    crate::git_dir::io_error::io_with_git_path(
3622        git_dir,
3623        &path,
3624        crate::git_dir::GitDirPathOperation::UpdateRef,
3625        file_lock.write_all(content.as_bytes()),
3626    )?;
3627    file_lock.commit_to(&path).map_err(|err| match err {
3628        LockError::Io {
3629            source,
3630            path: lock_path,
3631        } => crate::git_dir::io_error::map_git_dir_io(
3632            git_dir,
3633            lock_path.as_path(),
3634            crate::git_dir::GitDirPathOperation::UpdateRef,
3635            source,
3636        ),
3637        held @ LockError::Held { .. } => Error::from(held),
3638    })?;
3639    Ok(())
3640}
3641
3642pub(crate) fn update_branch_for_commit_files(
3643    git_dir: &Path,
3644    update: &BranchCommitRefUpdate<'_>,
3645    reflog_old: &ObjectId,
3646    config: &ConfigSet,
3647) -> Result<()> {
3648    let lock = BranchCommitRefLock::acquire(git_dir, update.branch_ref, update.expected_old)?;
3649    lock.commit(git_dir, update, reflog_old, config)
3650}
3651
3652/// Apply [`BranchCommitRefUpdate`]: branch CAS, ref write, branch + `HEAD` reflogs.
3653///
3654/// Opens the ref store for `git_dir`; prefer [`update_branch_for_commit_on_store`] with
3655/// [`crate::repo::Repository::refs`] when a repository handle is available.
3656pub fn update_branch_for_commit(git_dir: &Path, update: &BranchCommitRefUpdate<'_>) -> Result<()> {
3657    let config = ConfigSet::load(
3658        &crate::environment::Environment::empty(),
3659        Some(git_dir),
3660        true,
3661    )
3662    .unwrap_or_default();
3663    update_branch_for_commit_with_config(git_dir, update, &config)
3664}
3665
3666/// Like [`update_branch_for_commit`], using a repository config snapshot for reflog policy.
3667///
3668/// Opens the ref store for `git_dir`; see [`update_branch_for_commit_on_store`].
3669pub fn update_branch_for_commit_with_config(
3670    git_dir: &Path,
3671    update: &BranchCommitRefUpdate<'_>,
3672    config: &ConfigSet,
3673) -> Result<()> {
3674    update_branch_for_commit_on_store(open_ref_store(git_dir)?.as_ref(), update, config)
3675}
3676
3677/// Files-backend body of [`update_detached_head_for_commit_on_store`]; the caller has checked
3678/// that `HEAD` is detached.
3679pub(crate) fn update_detached_head_for_commit_files(
3680    git_dir: &Path,
3681    expected_old: Option<ObjectId>,
3682    new_oid: ObjectId,
3683    identity: &str,
3684    reflog_message: &str,
3685    config: &ConfigSet,
3686) -> Result<()> {
3687    let current = resolve_ref(git_dir, "HEAD").ok();
3688    verify_branch_cas(expected_old, current, "HEAD")?;
3689    let reflog_old = expected_old.unwrap_or_else(crate::diff::zero_oid);
3690
3691    write_ref_cas(git_dir, "HEAD", &new_oid, reflog_old)?;
3692    if should_autocreate_reflog_with_config(config, git_dir, "HEAD") {
3693        if let Err(err) = append_reflog_with_config(
3694            git_dir,
3695            "HEAD",
3696            &reflog_old,
3697            &new_oid,
3698            identity,
3699            reflog_message,
3700            false,
3701            Some(config),
3702        ) {
3703            let _ = write_ref_cas(git_dir, "HEAD", &reflog_old, new_oid);
3704            return Err(err);
3705        }
3706    }
3707    Ok(())
3708}
3709
3710/// Filesystem path to the reflog file for `refname` (same layout as [`append_reflog`]).
3711///
3712/// Branch and tag reflogs live under the shared [`common_dir`] when the repository uses a
3713/// `commondir` link (linked worktrees / `git clone --shared` member repos); `HEAD` stays under
3714/// `git_dir`.
3715#[must_use]
3716pub fn reflog_file_path(git_dir: &Path, refname: &str) -> PathBuf {
3717    let (store, stor_name) = crate::worktree_ref::resolve_ref_storage(git_dir, refname);
3718    store.join("logs").join(stor_name)
3719}
3720
3721fn ref_storage_dir(git_dir: &Path, refname: &str) -> PathBuf {
3722    crate::worktree_ref::resolve_ref_storage(git_dir, refname).0
3723}
3724
3725/// Normalize a ref prefix for filesystem traversal and packed-ref filtering.
3726///
3727/// Loose refs live in a directory tree mirroring ref names. A prefix like
3728/// `refs/remotes/origin` (no trailing slash) must map to the `origin/` directory
3729/// under `refs/remotes/`, not to a sibling file named `origin`. When the prefix
3730/// already names a **single loose ref file** (e.g. `refs/heads/main`), keep it
3731/// without a trailing slash so we read that file instead of a non-existent
3732/// directory.
3733fn normalize_list_refs_prefix(git_dir: &Path, prefix: &str) -> String {
3734    if prefix.is_empty() {
3735        return String::new();
3736    }
3737    if prefix.ends_with('/') {
3738        return prefix.to_string();
3739    }
3740    let candidate = ref_storage_dir(git_dir, prefix).join(prefix);
3741    if candidate.is_file() {
3742        prefix.to_string()
3743    } else {
3744        format!("{prefix}/")
3745    }
3746}
3747
3748/// The `^` line of a `fully-peeled` `packed-refs` entry: the innermost non-tag object when `oid`
3749/// is an annotated tag. Lightweight refs, and tags whose chain cannot be read, get no peel line.
3750fn packed_ref_peel(odb: &crate::odb::Odb, oid: ObjectId) -> Option<ObjectId> {
3751    odb.peel_tag_target(oid).ok().flatten()
3752}
3753
3754/// After the first clone fetch, batch remote-tracking refs into `packed-refs`
3755/// (Git's post-clone layout). Leaves `refs/remotes/{remote}/HEAD` as a loose
3756/// symbolic ref.
3757///
3758/// # Errors
3759///
3760/// Returns [`Error::Io`] on filesystem failures, or when a tag peel cannot be read.
3761pub fn pack_remote_tracking_refs_for_clone(git_dir: &Path, remote: &str) -> Result<()> {
3762    if open_ref_store(git_dir)?.format() != RefStorageFormat::Files {
3763        return Ok(());
3764    }
3765    let prefix = format!("refs/remotes/{remote}/");
3766    let head_sym = format!("refs/remotes/{remote}/HEAD");
3767    let mut entries = list_refs(git_dir, &prefix)?;
3768    entries.retain(|(name, _)| name != &head_sym);
3769    if entries.is_empty() {
3770        return Ok(());
3771    }
3772    entries.sort_by(|a, b| a.0.cmp(&b.0));
3773
3774    let odb =
3775        crate::odb::Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.to_path_buf());
3776    let mut merged: HashMap<String, (ObjectId, Option<ObjectId>)> = HashMap::new();
3777    let packed_path = store::paths::packed_refs_path(git_dir);
3778    if let Ok(data) = fs::read(&packed_path) {
3779        parse_packed_refs_to_map(&packed_path, &data)?;
3780        let content = String::from_utf8(data)
3781            .map_err(|_| packed_refs_unexpected_line(&packed_path, "<invalid utf-8>"))?;
3782        let mut last_name: Option<String> = None;
3783        for line in content.split('\n') {
3784            if line.starts_with('#') {
3785                continue;
3786            }
3787            if let Some(hex) = line.strip_prefix('^') {
3788                let Ok(peel_oid) = ObjectId::from_hex(hex.trim()) else {
3789                    continue;
3790                };
3791                if let Some(name) = &last_name {
3792                    if let Some(entry) = merged.get_mut(name) {
3793                        entry.1 = Some(peel_oid);
3794                    }
3795                }
3796                continue;
3797            }
3798            let mut parts = line.splitn(2, ' ');
3799            let Some(hex) = parts.next() else {
3800                continue;
3801            };
3802            let Some(name) = parts.next() else {
3803                continue;
3804            };
3805            let name = name.trim();
3806            let Ok(oid) = ObjectId::from_hex(hex) else {
3807                continue;
3808            };
3809            if name.starts_with(&prefix) {
3810                last_name = None;
3811                continue;
3812            }
3813            merged.insert(name.to_owned(), (oid, None));
3814            last_name = Some(name.to_owned());
3815        }
3816    }
3817
3818    for (name, oid) in &entries {
3819        merged.insert(name.clone(), (*oid, packed_ref_peel(&odb, *oid)));
3820    }
3821
3822    let mut names: Vec<String> = merged.keys().cloned().collect();
3823    names.sort();
3824
3825    let mut body = String::from("# pack-refs with: peeled fully-peeled sorted\n");
3826    for name in &names {
3827        let Some((oid, peel)) = merged.get(name) else {
3828            continue;
3829        };
3830        body.push_str(&oid.to_hex());
3831        body.push(' ');
3832        body.push_str(name);
3833        body.push('\n');
3834        if let Some(p) = peel {
3835            body.push('^');
3836            body.push_str(&p.to_hex());
3837            body.push('\n');
3838        }
3839    }
3840
3841    atomic_rewrite_packed_refs(git_dir, &body)?;
3842
3843    for (name, _) in &entries {
3844        let storage_dir = ref_storage_dir(git_dir, name);
3845        let stor = crate::ref_namespace::storage_ref_name(name);
3846        let path = storage_dir.join(&stor);
3847        if path.is_file() {
3848            let _ = fs::remove_file(&path);
3849        }
3850        if let Some(parent) = path.parent() {
3851            remove_empty_ref_directory(parent);
3852        }
3853    }
3854    Ok(())
3855}
3856
3857/// Rewrite `packed-refs` under the packed-refs lock (tempfile + rename).
3858fn atomic_rewrite_packed_refs(git_dir: &Path, body: &str) -> Result<()> {
3859    let packed_path = store::paths::packed_refs_path(git_dir);
3860    let lock = lock_path_for_ref(&packed_path);
3861    let abs_lock = fs::canonicalize(git_dir)
3862        .map(|d| d.join("packed-refs.lock"))
3863        .unwrap_or_else(|_| lock.clone());
3864    let _packed_lock = acquire_packed_refs_lock(git_dir, &lock, &abs_lock)?;
3865
3866    let tmp = packed_path.with_extension("new");
3867    let mut created_tmp = false;
3868    let write_result = (|| -> Result<()> {
3869        // Staged packed-refs body (not a lock file); exclusive create avoids clobber.
3870        let mut file = std::fs::OpenOptions::new()
3871            .write(true)
3872            .create_new(true)
3873            .open(&tmp)
3874            .map_err(Error::Io)?;
3875        created_tmp = true;
3876        use std::io::Write as _;
3877        file.write_all(body.as_bytes()).map_err(Error::Io)?;
3878        drop(file);
3879        fs::rename(&tmp, &packed_path).map_err(Error::Io)?;
3880        created_tmp = false;
3881        Ok(())
3882    })();
3883    if write_result.is_err() && created_tmp {
3884        let _ = fs::remove_file(&tmp);
3885    }
3886    write_result
3887}
3888
3889/// One reference line (and optional peel) for [`write_packed_refs_file`].
3890#[derive(Debug, Clone, PartialEq, Eq)]
3891pub struct PackedRefRecord {
3892    /// Full ref name (for example `refs/heads/feature`).
3893    pub refname: String,
3894    /// Object id stored at this ref.
3895    pub oid: ObjectId,
3896    /// When set, emit a following `^` peeled line (annotated tags).
3897    pub peeled: Option<ObjectId>,
3898}
3899
3900/// Replace `packed-refs` with `records`, sorted by ref name.
3901///
3902/// Existing loose ref files for names present in `records` are removed so the
3903/// packed file is authoritative (Git's `pack-refs` behaviour).
3904///
3905/// # Errors
3906///
3907/// Returns [`Error::InvalidRef`] for unsafe ref names, or [`Error::Io`] on failure.
3908pub fn write_packed_refs_file(git_dir: &Path, records: &[PackedRefRecord]) -> Result<()> {
3909    let mut sorted: Vec<&PackedRefRecord> = records.iter().collect();
3910    sorted.sort_by(|a, b| a.refname.cmp(&b.refname));
3911    for rec in &sorted {
3912        ensure_refname_safe_for_storage(&rec.refname)?;
3913    }
3914    let mut body = String::from("# pack-refs with: peeled fully-peeled sorted\n");
3915    for rec in &sorted {
3916        body.push_str(&rec.oid.to_hex());
3917        body.push(' ');
3918        body.push_str(&rec.refname);
3919        body.push('\n');
3920        if let Some(peeled) = rec.peeled {
3921            body.push('^');
3922            body.push_str(&peeled.to_hex());
3923            body.push('\n');
3924        }
3925    }
3926    atomic_rewrite_packed_refs(git_dir, &body)?;
3927    for rec in &sorted {
3928        let storage_dir = ref_storage_dir(git_dir, &rec.refname);
3929        let stor = crate::ref_namespace::storage_ref_name(&rec.refname);
3930        let loose = storage_dir.join(stor);
3931        if loose.is_file() {
3932            fs::remove_file(&loose).map_err(Error::Io)?;
3933        }
3934        if let Some(parent) = loose.parent() {
3935            remove_empty_ref_directory(parent);
3936        }
3937    }
3938    Ok(())
3939}
3940
3941/// List all refs under a given prefix (e.g. `"refs/heads/"`).
3942///
3943/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
3944///
3945/// Returns a sorted list of `(refname, ObjectId)` pairs.
3946///
3947/// # Errors
3948///
3949/// Returns [`Error::Io`] on directory traversal errors.
3950/// List refs under `prefix` using the given repository's ref store.
3951pub fn list_refs_for_repository(
3952    repo: &crate::repo::Repository,
3953    prefix: &str,
3954) -> Result<Vec<(String, ObjectId)>> {
3955    let prefix_norm = normalize_list_refs_prefix(repo.git_dir(), prefix);
3956    list_store_refs_with_name_prefix(repo, &prefix_norm)
3957}
3958
3959/// Refs whose names start with `prefix` (taken literally), from `repo`'s ref store.
3960fn list_store_refs_with_name_prefix(
3961    repo: &crate::repo::Repository,
3962    prefix: &str,
3963) -> Result<Vec<(String, ObjectId)>> {
3964    let git_dir = repo.git_dir();
3965    let mut results = collect_resolved_refs(repo.refs(), prefix)?;
3966    if crate::worktree_ref::is_linked_worktree_git_dir(git_dir) {
3967        results.retain(|(name, _)| crate::worktree_ref::ref_visible_from_worktree(git_dir, name));
3968    }
3969    results.sort_by(|a, b| a.0.cmp(&b.0));
3970    Ok(results)
3971}
3972
3973/// [`list_refs`] for `repo`, honouring an injected ref store.
3974///
3975/// Files and reftable repositories are listed from the git directory with a throwaway store:
3976/// for a one-off listing of many refs that is faster and leaner than going through the
3977/// handle's long-lived store, which keeps a `packed-refs` snapshot. Any other store is
3978/// listed through [`list_refs_for_repository`].
3979///
3980/// # Errors
3981///
3982/// Propagates ref-store open/read failures.
3983pub(crate) fn list_refs_in(
3984    repo: &crate::repo::Repository,
3985    prefix: &str,
3986) -> Result<Vec<(String, ObjectId)>> {
3987    if repo.refs_on_disk() {
3988        list_refs(repo.git_dir(), prefix)
3989    } else {
3990        list_refs_for_repository(repo, prefix)
3991    }
3992}
3993
3994/// [`resolve_ref`] for `repo`, honouring an injected ref store the same way [`list_refs_in`]
3995/// does.
3996///
3997/// # Errors
3998///
3999/// Same as [`resolve_ref`].
4000pub(crate) fn resolve_ref_in(repo: &crate::repo::Repository, refname: &str) -> Result<ObjectId> {
4001    if repo.refs_on_disk() {
4002        resolve_ref(repo.git_dir(), refname)
4003    } else {
4004        repo.resolve_ref_oid(refname)
4005    }
4006}
4007
4008/// [`list_refs_glob`] for `repo`, honouring an injected ref store the same way
4009/// [`list_refs_in`] does.
4010///
4011/// # Errors
4012///
4013/// Propagates ref-store open/read failures.
4014pub(crate) fn list_refs_glob_in(
4015    repo: &crate::repo::Repository,
4016    pattern: &str,
4017) -> Result<Vec<(String, ObjectId)>> {
4018    if repo.refs_on_disk() {
4019        list_refs_glob(repo.git_dir(), pattern)
4020    } else {
4021        list_refs_glob_for_repository(repo, pattern)
4022    }
4023}
4024
4025/// List resolved refs under `git_dir` whose full names start with `prefix`.
4026///
4027/// Uses the on-disk ref backend (files or reftable) and applies linked-worktree
4028/// visibility rules when `git_dir` is a linked worktree git directory.
4029///
4030/// # Errors
4031///
4032/// Propagates ref-store open/read failures.
4033pub fn list_refs(git_dir: &Path, prefix: &str) -> Result<Vec<(String, ObjectId)>> {
4034    let prefix_norm = normalize_list_refs_prefix(git_dir, prefix);
4035    let prefix = prefix_norm.as_str();
4036    let store = open_ref_store(git_dir)?;
4037    if store.format() != RefStorageFormat::Files {
4038        return crate::reftable::reftable_list_refs(git_dir, prefix);
4039    }
4040    let mut results = collect_resolved_refs(store.as_ref(), prefix)?;
4041    if crate::worktree_ref::is_linked_worktree_git_dir(git_dir) {
4042        results.retain(|(name, _)| crate::worktree_ref::ref_visible_from_worktree(git_dir, name));
4043    }
4044    results.sort_by(|a, b| a.0.cmp(&b.0));
4045    Ok(results)
4046}
4047
4048/// [`list_refs`], except that `packed-refs` records `keep` rejects may be left out.
4049///
4050/// `keep` receives each packed record's name, object id and what the file records about
4051/// peeling it, before anything is allocated for the record. Loose refs, and every ref of a
4052/// reftable repository, are always listed, so `keep` only ever narrows the listing to a
4053/// subset of [`list_refs`].
4054///
4055/// # Errors
4056///
4057/// Propagates ref-store open/read failures, like [`list_refs`].
4058pub(crate) fn list_refs_filtering_packed(
4059    git_dir: &Path,
4060    prefix: &str,
4061    keep: PackedRecordFilter<'_>,
4062) -> Result<Vec<(String, ObjectId)>> {
4063    let Some(store) = store::open_files_ref_store(git_dir)? else {
4064        return list_refs(git_dir, prefix);
4065    };
4066    let prefix_norm = normalize_list_refs_prefix(git_dir, prefix);
4067    let prefix = prefix_norm.as_str();
4068    let mut results = collect_resolved_ref_entries(&store, |f| {
4069        store.for_each_ref_filtered(prefix, Some(keep), f)
4070    })?;
4071    if crate::worktree_ref::is_linked_worktree_git_dir(git_dir) {
4072        results.retain(|(name, _)| crate::worktree_ref::ref_visible_from_worktree(git_dir, name));
4073    }
4074    results.sort_by(|a, b| a.0.cmp(&b.0));
4075    Ok(results)
4076}
4077
4078/// Resolve a ref using Git DWIM rules (`expand_ref` / `repo_dwim_ref`).
4079pub fn resolve_ref_dwim(git_dir: &Path, spec: &str) -> (usize, Option<ObjectId>) {
4080    crate::worktree_ref::resolve_ref_dwim(|candidate| resolve_ref(git_dir, candidate).ok(), spec)
4081}
4082
4083/// List refs under `prefix` using **literal** on-disk paths (ignores `GIT_NAMESPACE`).
4084///
4085/// Used by `receive-pack` when advertising: the server must see every physical ref so refs outside
4086/// the active namespace can be offered as `.have` lines (matches Git `show_ref_cb`).
4087pub fn list_refs_physical(git_dir: &Path, prefix: &str) -> Result<Vec<(String, ObjectId)>> {
4088    if open_ref_store(git_dir)?.format() != RefStorageFormat::Files {
4089        return crate::reftable::reftable_list_refs(git_dir, prefix);
4090    }
4091    let mut by_name: HashMap<String, ObjectId> = HashMap::new();
4092    let stored_prefix = prefix.to_owned();
4093
4094    if let Some(cdir) = common_dir(git_dir) {
4095        if cdir != git_dir {
4096            collect_packed_refs_into_map(&cdir, &stored_prefix, true, &mut by_name)?;
4097            let (loose_root, loose_prefix) = loose_ref_list_walk_root(&cdir, &stored_prefix);
4098            collect_loose_refs_into_map(
4099                &loose_root,
4100                &loose_prefix,
4101                &stored_prefix,
4102                &cdir,
4103                true,
4104                &mut by_name,
4105            )?;
4106        }
4107    }
4108
4109    collect_packed_refs_into_map(git_dir, &stored_prefix, true, &mut by_name)?;
4110    let (loose_root, loose_prefix) = loose_ref_list_walk_root(git_dir, &stored_prefix);
4111    collect_loose_refs_into_map(
4112        &loose_root,
4113        &loose_prefix,
4114        &stored_prefix,
4115        git_dir,
4116        true,
4117        &mut by_name,
4118    )?;
4119
4120    let mut results: Vec<(String, ObjectId)> = by_name.into_iter().collect();
4121    results.sort_by(|a, b| a.0.cmp(&b.0));
4122    Ok(results)
4123}
4124
4125/// Collect commit OIDs from alternate repositories' refs, matching Git's
4126/// `git for-each-ref --format=%(objectname)` on each alternate (with optional
4127/// `core.alternateRefsPrefixes` arguments).
4128///
4129/// Order is preserved: alternates file order, then ref iteration order from
4130/// [`list_refs`] under each configured prefix (or all of `refs/` when no
4131/// prefixes are set). Duplicate OIDs are skipped while preserving first-seen
4132/// order.
4133pub fn collect_alternate_ref_oids(receiving_git_dir: &Path) -> Result<Vec<ObjectId>> {
4134    let config = ConfigSet::load(
4135        &crate::environment::Environment::empty(),
4136        Some(receiving_git_dir),
4137        true,
4138    )?;
4139    let objects_dir = receiving_git_dir.join("objects");
4140    let alternates = pack::read_alternates_recursive(&objects_dir).unwrap_or_default();
4141    let mut out = Vec::new();
4142    let mut seen = std::collections::HashSet::new();
4143    for alt_objects in alternates {
4144        let Some(alt_git_dir) = alt_objects.parent().map(PathBuf::from) else {
4145            continue;
4146        };
4147        if !alt_git_dir.join("refs").is_dir() {
4148            continue;
4149        }
4150        if let Some(prefixes) = config
4151            .get("core.alternaterefsprefixes")
4152            .or_else(|| config.get("core.alternateRefsPrefixes"))
4153        {
4154            for part in prefixes.split_whitespace() {
4155                if let Ok(oid) = resolve_ref(&alt_git_dir, part) {
4156                    if seen.insert(oid) {
4157                        out.push(oid);
4158                    }
4159                    continue;
4160                }
4161                for (_, oid) in list_refs(&alt_git_dir, part)? {
4162                    if seen.insert(oid) {
4163                        out.push(oid);
4164                    }
4165                }
4166            }
4167        } else {
4168            for (_, oid) in list_refs(&alt_git_dir, "refs/")? {
4169                if seen.insert(oid) {
4170                    out.push(oid);
4171                }
4172            }
4173        }
4174    }
4175    Ok(out)
4176}
4177
4178/// List refs matching a glob pattern (e.g. `refs/heads/topic/*`) using `repo`'s ref store.
4179pub fn list_refs_glob_for_repository(
4180    repo: &crate::repo::Repository,
4181    pattern: &str,
4182) -> Result<Vec<(String, ObjectId)>> {
4183    // The text before a wildcard is a name prefix, not a directory (`refs/tags/v*` must
4184    // list `refs/tags/v1`), so it skips the directory normalization of
4185    // [`list_refs_for_repository`].
4186    let all = match pattern.find(['*', '?', '[']) {
4187        Some(pos) => list_store_refs_with_name_prefix(repo, &pattern[..pos])?,
4188        None => {
4189            let mut dir = pattern.trim_end_matches('/').to_owned();
4190            if !dir.is_empty() {
4191                dir.push('/');
4192            }
4193            list_refs_for_repository(repo, &dir)?
4194        }
4195    };
4196    Ok(all
4197        .into_iter()
4198        .filter(|(refname, _)| ref_matches_glob(refname, pattern))
4199        .collect())
4200}
4201
4202/// List refs matching a glob pattern (e.g. `refs/heads/topic/*`).
4203pub fn list_refs_glob(git_dir: &Path, pattern: &str) -> Result<Vec<(String, ObjectId)>> {
4204    let glob_pos = pattern.find(['*', '?', '[']);
4205    let prefix_owned: String = match glob_pos {
4206        // Literal segment before the first wildcard (e.g. `refs/heads/b05*` → `refs/heads/b05`).
4207        Some(pos) => pattern[..pos].to_owned(),
4208        None => {
4209            let mut p = pattern.trim_end_matches('/').to_owned();
4210            if !p.is_empty() {
4211                p.push('/');
4212            }
4213            p
4214        }
4215    };
4216    let prefix = prefix_owned.as_str();
4217    let all = list_refs(git_dir, prefix)?;
4218    let mut results = Vec::new();
4219    for (refname, oid) in all {
4220        if ref_matches_glob(&refname, pattern) {
4221            results.push((refname, oid));
4222        }
4223    }
4224    Ok(results)
4225}
4226
4227/// Check whether a ref name matches a glob pattern.
4228///
4229/// Supports `*`, `?`, and `[…]` wildcards. An exact string match is also accepted.
4230pub fn ref_matches_glob(refname: &str, pattern: &str) -> bool {
4231    // For exact matches (no glob characters), check suffix match
4232    if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('[') {
4233        return refname == pattern
4234            || refname.ends_with(&format!("/{pattern}"))
4235            || refname.starts_with(&format!("{pattern}/"));
4236    }
4237    glob_match(pattern, refname)
4238}
4239
4240fn glob_match(pattern: &str, text: &str) -> bool {
4241    let pat = pattern.as_bytes();
4242    let txt = text.as_bytes();
4243    let (mut pi, mut ti) = (0, 0);
4244    let (mut star_pi, mut star_ti) = (usize::MAX, 0);
4245    while ti < txt.len() {
4246        if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == txt[ti]) {
4247            pi += 1;
4248            ti += 1;
4249        } else if pi < pat.len() && pat[pi] == b'*' {
4250            star_pi = pi;
4251            star_ti = ti;
4252            pi += 1;
4253        } else if star_pi != usize::MAX {
4254            pi = star_pi + 1;
4255            star_ti += 1;
4256            ti = star_ti;
4257        } else {
4258            return false;
4259        }
4260    }
4261    while pi < pat.len() && pat[pi] == b'*' {
4262        pi += 1;
4263    }
4264    pi == pat.len()
4265}
4266
4267/// OID stored directly in a loose ref file (40 hex), ignoring symbolic targets.
4268fn loose_ref_file_direct_oid(path: &Path) -> Option<ObjectId> {
4269    let content = fs::read_to_string(path).ok()?;
4270    let content = content.trim_end_matches('\n').trim();
4271    if ObjectId::is_full_hex(content) {
4272        content.parse().ok()
4273    } else {
4274        None
4275    }
4276}
4277
4278fn collect_loose_refs_into_map(
4279    dir: &Path,
4280    prefix: &str,
4281    filter_prefix: &str,
4282    resolve_git_dir: &Path,
4283    physical_keys: bool,
4284    out: &mut HashMap<String, ObjectId>,
4285) -> Result<()> {
4286    let read = match fs::read_dir(dir) {
4287        Ok(r) => r,
4288        Err(e)
4289            if e.kind() == io::ErrorKind::NotFound
4290                || e.kind() == io::ErrorKind::NotADirectory
4291                || e.raw_os_error() == Some(libc::ENOTDIR) =>
4292        {
4293            return Ok(());
4294        }
4295        Err(e) => return Err(Error::Io(e)),
4296    };
4297
4298    for entry in read {
4299        let entry = entry?;
4300        let name = entry.file_name();
4301        let name_str = name.to_string_lossy();
4302        let refname = format!("{prefix}{name_str}");
4303        let path = entry.path();
4304        let meta = match fs::metadata(&path) {
4305            Ok(m) => m,
4306            Err(_) => continue,
4307        };
4308
4309        if meta.is_dir() {
4310            collect_loose_refs_into_map(
4311                &path,
4312                &format!("{refname}/"),
4313                filter_prefix,
4314                resolve_git_dir,
4315                physical_keys,
4316                out,
4317            )?;
4318        } else if meta.is_file() {
4319            if physical_keys {
4320                if let Some(oid) = loose_ref_file_direct_oid(&path) {
4321                    out.insert(refname, oid);
4322                } else if let Ok(Ref::Symbolic(target)) = read_ref_file(&path) {
4323                    if let Ok(oid) = resolve_ref(resolve_git_dir, target.trim()) {
4324                        out.insert(refname, oid);
4325                    }
4326                }
4327            } else {
4328                let logical = crate::ref_namespace::logical_ref_name_from_storage(&refname)
4329                    .unwrap_or_else(|| refname.clone());
4330                if !ref_name_matches_list_prefix(&logical, filter_prefix.trim_end_matches('/')) {
4331                    continue;
4332                }
4333                if let Some(oid) = loose_ref_file_direct_oid(&path) {
4334                    out.insert(logical, oid);
4335                } else if let Ok(Ref::Symbolic(target)) = read_ref_file(&path) {
4336                    if let Ok(oid) = resolve_ref(resolve_git_dir, target.trim()) {
4337                        out.insert(logical, oid);
4338                    }
4339                }
4340            }
4341        }
4342    }
4343    Ok(())
4344}
4345
4346/// Resolve `@{-N}` syntax to the branch name (not an OID).
4347/// Returns the branch name of the Nth previously checked out branch.
4348pub fn resolve_at_n_branch(git_dir: &Path, spec: &str) -> Result<String> {
4349    // Parse the N from @{-N}
4350    let inner = spec
4351        .strip_prefix("@{-")
4352        .and_then(|s| s.strip_suffix('}'))
4353        .ok_or_else(|| Error::InvalidRef(format!("not an @{{-N}} ref: {spec}")))?;
4354    let n: usize = inner
4355        .parse()
4356        .map_err(|_| Error::InvalidRef(format!("invalid N in {spec}")))?;
4357    if n == 0 {
4358        return Err(Error::InvalidRef("@{-0} is not valid".to_string()));
4359    }
4360    let entries = crate::reflog::read_reflog(git_dir, "HEAD")?;
4361    let mut count = 0usize;
4362    for entry in entries.iter().rev() {
4363        let msg = &entry.message;
4364        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
4365            count += 1;
4366            if count == n {
4367                if let Some(to_pos) = rest.find(" to ") {
4368                    return Ok(rest[..to_pos].to_string());
4369                }
4370            }
4371        }
4372    }
4373    Err(Error::InvalidRef(format!(
4374        "{spec}: only {count} checkout(s) in reflog"
4375    )))
4376}
4377
4378fn ref_name_matches_list_prefix(refname: &str, prefix: &str) -> bool {
4379    let prefix = prefix.trim_end_matches('/');
4380    if prefix.is_empty() {
4381        return true;
4382    }
4383    if refname.starts_with(prefix) {
4384        return true;
4385    }
4386    if refname == prefix {
4387        return true;
4388    }
4389    false
4390}
4391
4392fn list_prefix_upper_bound(prefix: &str) -> Option<String> {
4393    let base = prefix.trim_end_matches('/');
4394    let (head, tail) = base.rsplit_once('/')?;
4395    let mut chars: Vec<char> = tail.chars().collect();
4396    while let Some(last) = chars.pop() {
4397        if last != '\u{10ffff}' {
4398            chars.push(char::from_u32(last as u32 + 1).unwrap_or(last));
4399            return Some(format!("{head}/{}", chars.into_iter().collect::<String>()));
4400        }
4401    }
4402    None
4403}
4404
4405/// When `prefix` names a partial ref (e.g. `refs/heads/b05/` with no `b05/` directory),
4406/// start the walk at the parent directory (`refs/heads/`) and filter by prefix.
4407pub(crate) fn loose_ref_list_walk_root(git_dir: &Path, prefix: &str) -> (PathBuf, String) {
4408    let trimmed = prefix.trim_end_matches('/');
4409    if trimmed.is_empty() {
4410        return (git_dir.join("refs"), "refs/".to_owned());
4411    }
4412    let direct = git_dir.join(trimmed);
4413    if direct.is_dir() {
4414        return (direct, format!("{trimmed}/"));
4415    }
4416    if let Some(slash) = trimmed.rfind('/') {
4417        let parent = &trimmed[..slash];
4418        return (git_dir.join(parent), format!("{parent}/"));
4419    }
4420    (git_dir.to_path_buf(), String::new())
4421}
4422
4423fn collect_packed_refs_into_map(
4424    git_dir: &Path,
4425    prefix: &str,
4426    physical_keys: bool,
4427    out: &mut HashMap<String, ObjectId>,
4428) -> Result<()> {
4429    let map = read_packed_refs_map_for_prefix(git_dir, prefix)?;
4430    for (refname, oid) in map {
4431        let key = if physical_keys {
4432            refname.clone()
4433        } else {
4434            crate::ref_namespace::logical_ref_name_from_storage(&refname).unwrap_or(refname)
4435        };
4436        out.insert(key, oid);
4437    }
4438    Ok(())
4439}
4440
4441#[cfg(test)]
4442mod update_branch_for_commit_tests {
4443    use super::*;
4444    use crate::reflog::read_reflog;
4445    use crate::repo::init_repository;
4446    use std::sync::{Arc, Condvar, Mutex};
4447    use std::thread;
4448    use tempfile::TempDir;
4449
4450    fn sample_oid(hex: &str) -> ObjectId {
4451        hex.parse().unwrap()
4452    }
4453
4454    #[test]
4455    fn update_branch_for_commit_holds_lock_until_ref_commit() {
4456        let _pause_serial = super::test_pause_injection_serial_guard();
4457        let tmp = TempDir::new().unwrap();
4458        let repo = init_repository(
4459            tmp.path(),
4460            false,
4461            "main",
4462            None,
4463            crate::RefStorageFormat::Files,
4464        )
4465        .unwrap();
4466        let git_dir = repo.git_dir().to_path_buf();
4467        let oid1 = sample_oid("67bf698f3ab735e92fb011a99cff3497c44d30c1");
4468        let oid2 = sample_oid("1111111111111111111111111111111111111111");
4469        let oid3 = sample_oid("2222222222222222222222222222222222222222");
4470        write_ref(&git_dir, "refs/heads/main", &oid1).unwrap();
4471
4472        let gate = Arc::new(branch_ref_pause_inject::PauseGate {
4473            wait: Mutex::new(false),
4474            cv: Condvar::new(),
4475        });
4476        set_test_inject_branch_ref_pause_before_commit(Some(gate.clone()));
4477
4478        let git_dir_thread = git_dir.clone();
4479        let handle = thread::spawn(move || {
4480            update_branch_for_commit(
4481                &git_dir_thread,
4482                &BranchCommitRefUpdate {
4483                    branch_ref: "refs/heads/main",
4484                    expected_old: Some(oid1),
4485                    new_oid: oid2,
4486                    identity: "T <t@e.com> 1 +0000",
4487                    reflog_message: "commit: test",
4488                },
4489            )
4490        });
4491
4492        let mut wait = gate.wait.lock().unwrap();
4493        while !*wait {
4494            wait = gate.cv.wait(wait).unwrap();
4495        }
4496
4497        assert!(
4498            write_ref(&git_dir, "refs/heads/main", &oid3).is_err(),
4499            "concurrent branch update must block on the held ref lock"
4500        );
4501
4502        *wait = false;
4503        gate.cv.notify_all();
4504        drop(wait);
4505        handle.join().unwrap().unwrap();
4506        set_test_inject_branch_ref_pause_before_commit(None);
4507
4508        assert_eq!(resolve_ref(&git_dir, "refs/heads/main").unwrap(), oid2);
4509    }
4510
4511    #[test]
4512    fn update_branch_for_commit_reftable_transaction_is_all_or_nothing() {
4513        let tmp = TempDir::new().unwrap();
4514        let repo = init_repository(
4515            tmp.path(),
4516            false,
4517            "main",
4518            None,
4519            crate::RefStorageFormat::Reftable,
4520        )
4521        .unwrap();
4522        let git_dir = repo.git_dir().to_path_buf();
4523        let new_oid = sample_oid("3333333333333333333333333333333333333333");
4524
4525        crate::reftable::set_test_inject_reftable_transaction_fail(true);
4526        let err = update_branch_for_commit(
4527            &git_dir,
4528            &BranchCommitRefUpdate {
4529                branch_ref: "refs/heads/main",
4530                expected_old: None,
4531                new_oid,
4532                identity: "T <t@e.com> 1 +0000",
4533                reflog_message: "commit (initial): test",
4534            },
4535        )
4536        .unwrap_err();
4537        crate::reftable::set_test_inject_reftable_transaction_fail(false);
4538        assert!(!matches!(err, Error::Message(_)));
4539
4540        assert!(resolve_ref(&git_dir, "refs/heads/main").is_err());
4541        assert!(read_reflog(&git_dir, "refs/heads/main").unwrap().is_empty());
4542        assert!(read_reflog(&git_dir, "HEAD").unwrap().is_empty());
4543    }
4544}
4545
4546#[cfg(test)]
4547mod refname_available_tests {
4548    use super::*;
4549    use std::collections::{BTreeSet, HashSet};
4550    use tempfile::tempdir;
4551
4552    #[test]
4553    fn verify_ref_transaction_batch_rejects_same_batch_parent_child() {
4554        let dir = tempdir().unwrap();
4555        let git_dir = dir.path();
4556        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
4557        let items = vec![
4558            RefBatchItem {
4559                name: "refs/heads/c".to_owned(),
4560                new_oid: Some(sample_oid()),
4561            },
4562            RefBatchItem {
4563                name: "refs/heads/c/x".to_owned(),
4564                new_oid: Some(sample_oid()),
4565            },
4566        ];
4567        let err = verify_ref_transaction_batch(git_dir, &items).unwrap_err();
4568        assert!(matches!(
4569            err,
4570            RefnameUnavailable::SameBatch { refname, .. } if refname == "refs/heads/c/x"
4571        ));
4572    }
4573
4574    fn sample_oid() -> ObjectId {
4575        "67bf698f3ab735e92fb011a99cff3497c44d30c1".parse().unwrap()
4576    }
4577
4578    #[test]
4579    fn loose_parent_blocks_child_create() {
4580        let dir = tempdir().unwrap();
4581        let git_dir = dir.path();
4582        fs::create_dir_all(git_dir.join("refs/1l")).unwrap();
4583        fs::write(
4584            git_dir.join("refs/1l/c"),
4585            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
4586        )
4587        .unwrap();
4588        assert_eq!(
4589            read_raw_ref(git_dir, "refs/1l/c").unwrap(),
4590            RawRefLookup::Exists
4591        );
4592        let extras = BTreeSet::from([
4593            "refs/1l/b".to_string(),
4594            "refs/1l/c/x".to_string(),
4595            "refs/1l/d".to_string(),
4596        ]);
4597        let skip = HashSet::new();
4598        let err = verify_refname_available_for_create(git_dir, "refs/1l/c/x", &extras, &skip)
4599            .unwrap_err();
4600        assert!(matches!(
4601            err,
4602            RefnameUnavailable::AncestorExists { ref blocking, .. } if blocking == "refs/1l/c"
4603        ));
4604    }
4605
4606    #[test]
4607    fn verify_sees_loose_ref_after_canonical_git_dir() {
4608        let dir = tempdir().unwrap();
4609        let git_dir = dir.path().join(".git");
4610        fs::create_dir_all(git_dir.join("refs/1l")).unwrap();
4611        fs::write(
4612            git_dir.join("refs/1l/c"),
4613            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
4614        )
4615        .unwrap();
4616        let skip = HashSet::new();
4617        let extras = BTreeSet::new();
4618        let err = verify_refname_available_for_create(&git_dir, "refs/1l/c/x", &extras, &skip)
4619            .unwrap_err();
4620        assert!(matches!(
4621            err,
4622            RefnameUnavailable::AncestorExists { ref blocking, .. } if blocking == "refs/1l/c"
4623        ));
4624    }
4625
4626    fn refname_namespace_conflicts(existing: &str, candidate: &str) -> bool {
4627        if existing == candidate {
4628            return false;
4629        }
4630        existing
4631            .strip_prefix(candidate)
4632            .is_some_and(|rest| rest.starts_with('/'))
4633            || candidate
4634                .strip_prefix(existing)
4635                .is_some_and(|rest| rest.starts_with('/'))
4636    }
4637
4638    #[test]
4639    fn packed_namespace_index_matches_pairwise_scan() {
4640        let mut sorted: BTreeSet<String> = BTreeSet::new();
4641        for i in 0..500 {
4642            sorted.insert(format!("refs/heads/b{i:05}"));
4643        }
4644        sorted.insert("refs/heads/parent".into());
4645        sorted.insert("refs/heads/a/b".into());
4646        let candidates = [
4647            "refs/heads/b00001",
4648            "refs/heads/parent/child",
4649            "refs/heads/new",
4650            "refs/heads/a",
4651        ];
4652        for candidate in candidates {
4653            let indexed = packed_refname_namespace_conflict(&sorted, candidate);
4654            let linear = sorted
4655                .iter()
4656                .any(|existing| refname_namespace_conflicts(existing, candidate));
4657            assert_eq!(indexed, linear, "candidate {candidate}");
4658        }
4659    }
4660
4661    #[test]
4662    fn list_refs_glob_b05_star_matches_one_thousand_packed_refs() {
4663        let git_dir = Path::new("/tmp/grit-bench-refs-cache/refs-100k-packed");
4664        if !git_dir.is_dir() {
4665            return;
4666        }
4667        let listed = list_refs_glob(git_dir, "refs/heads/b05*").unwrap();
4668        assert_eq!(listed.len(), 1000, "expected 1k b05* refs");
4669        assert!(listed
4670            .iter()
4671            .all(|(name, _)| name.starts_with("refs/heads/b05")));
4672    }
4673
4674    #[test]
4675    fn list_refs_finds_sibling_under_parent_directory() {
4676        let dir = tempdir().unwrap();
4677        let git_dir = dir.path();
4678        fs::create_dir_all(git_dir.join("refs/ns/p")).unwrap();
4679        fs::write(
4680            git_dir.join("refs/ns/p/x"),
4681            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
4682        )
4683        .unwrap();
4684        let listed = list_refs(git_dir, "refs/ns/p/").unwrap();
4685        assert!(
4686            listed.iter().any(|(n, _)| n == "refs/ns/p/x"),
4687            "got {listed:?}"
4688        );
4689    }
4690
4691    #[test]
4692    fn verify_blocks_parent_when_child_ref_exists() {
4693        let dir = tempdir().unwrap();
4694        let git_dir = dir.path();
4695        fs::create_dir_all(git_dir.join("refs/ns/p")).unwrap();
4696        fs::write(
4697            git_dir.join("refs/ns/p/x"),
4698            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
4699        )
4700        .unwrap();
4701        let extras = BTreeSet::from(["refs/ns/p".to_string()]);
4702        let skip = HashSet::new();
4703        let err =
4704            verify_refname_available_for_create(git_dir, "refs/ns/p", &extras, &skip).unwrap_err();
4705        assert!(matches!(
4706            err,
4707            RefnameUnavailable::DescendantExists { ref blocking, .. }
4708                if blocking == "refs/ns/p/x"
4709        ));
4710    }
4711
4712    #[test]
4713    fn verify_blocks_parent_when_only_a_packed_child_exists() {
4714        // No `refs/ns/q/` directory on disk, so the loose listing is skipped;
4715        // the packed-refs check must still find the descendant.
4716        let dir = tempdir().unwrap();
4717        let git_dir = dir.path();
4718        fs::create_dir_all(git_dir.join("refs")).unwrap();
4719        fs::write(
4720            store::paths::packed_refs_path(git_dir),
4721            "# pack-refs with: peeled fully-peeled\n\
4722             67bf698f3ab735e92fb011a99cff3497c44d30c1 refs/ns/q/x\n",
4723        )
4724        .unwrap();
4725        let err = verify_refname_available_for_create(
4726            git_dir,
4727            "refs/ns/q",
4728            &BTreeSet::new(),
4729            &HashSet::new(),
4730        )
4731        .unwrap_err();
4732        assert!(matches!(
4733            err,
4734            RefnameUnavailable::DescendantExists { ref blocking, .. }
4735                if blocking == "refs/ns/q/x"
4736        ));
4737    }
4738
4739    #[test]
4740    fn verify_allows_name_whose_siblings_share_a_prefix() {
4741        let dir = tempdir().unwrap();
4742        let git_dir = dir.path();
4743        fs::create_dir_all(git_dir.join("refs/tags")).unwrap();
4744        for name in ["v1.0.1", "v1.0-rc1"] {
4745            fs::write(
4746                git_dir.join("refs/tags").join(name),
4747                "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
4748            )
4749            .unwrap();
4750        }
4751        verify_refname_available_for_create(
4752            git_dir,
4753            "refs/tags/v1.0",
4754            &BTreeSet::new(),
4755            &HashSet::new(),
4756        )
4757        .expect("siblings are not descendants");
4758    }
4759
4760    #[test]
4761    fn verify_blocks_parent_git_style_nested_path() {
4762        let dir = tempdir().unwrap();
4763        let git_dir = dir.path();
4764        fs::create_dir_all(git_dir.join("refs/3l/c")).unwrap();
4765        fs::write(
4766            git_dir.join("refs/3l/c/x"),
4767            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
4768        )
4769        .unwrap();
4770        let extras = BTreeSet::from(["refs/3l/c".to_string()]);
4771        let skip = HashSet::new();
4772        let err =
4773            verify_refname_available_for_create(git_dir, "refs/3l/c", &extras, &skip).unwrap_err();
4774        assert!(matches!(
4775            err,
4776            RefnameUnavailable::DescendantExists { ref blocking, .. }
4777                if blocking == "refs/3l/c/x"
4778        ));
4779    }
4780
4781    #[test]
4782    fn intermediate_directory_does_not_block_nested_create() {
4783        let dir = tempdir().unwrap();
4784        let git_dir = dir.path();
4785        fs::create_dir_all(git_dir.join("refs/ns")).unwrap();
4786        fs::write(
4787            git_dir.join("refs/ns/existing"),
4788            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
4789        )
4790        .unwrap();
4791        assert_eq!(
4792            read_raw_ref(git_dir, "refs/ns").unwrap(),
4793            RawRefLookup::IsDirectory
4794        );
4795        let extras = BTreeSet::from(["refs/ns/newchild".to_string()]);
4796        let skip = HashSet::new();
4797        verify_refname_available_for_create(git_dir, "refs/ns/newchild", &extras, &skip).unwrap();
4798    }
4799}
4800
4801#[cfg(test)]
4802mod ref_storage_traversal_guard_tests {
4803    use super::*;
4804    use crate::objects::ObjectId;
4805    use tempfile::tempdir;
4806
4807    const TRAVERSAL_REF: &str = "refs/heads/../../config";
4808
4809    fn bare_repo_with_config() -> (tempfile::TempDir, PathBuf) {
4810        let dir = tempdir().unwrap();
4811        let git_dir = dir.path().to_path_buf();
4812        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
4813        fs::write(
4814            git_dir.join("config"),
4815            "[core]\nrepositoryformatversion = 0\n",
4816        )
4817        .unwrap();
4818        (dir, git_dir)
4819    }
4820
4821    fn sample_oid() -> ObjectId {
4822        "67bf698f3ab735e92fb011a99cff3497c44d30c1".parse().unwrap()
4823    }
4824
4825    fn assert_invalid_ref_preserves_config(git_dir: &Path, result: Result<()>) {
4826        assert!(matches!(result, Err(Error::InvalidRef(_))));
4827        assert!(
4828            git_dir.join("config").is_file(),
4829            "repository config must not be touched"
4830        );
4831    }
4832
4833    #[test]
4834    fn write_ref_rejects_traversal_refname() {
4835        let (_dir, git_dir) = bare_repo_with_config();
4836        let result = write_ref(&git_dir, TRAVERSAL_REF, &sample_oid());
4837        assert_invalid_ref_preserves_config(&git_dir, result);
4838    }
4839
4840    #[test]
4841    fn write_ref_cached_rejects_traversal_refname() {
4842        let (_dir, git_dir) = bare_repo_with_config();
4843        let mut packed = PackedRefs::load(&git_dir).unwrap();
4844        let result = write_ref_cached(&git_dir, TRAVERSAL_REF, &sample_oid(), &mut packed);
4845        assert_invalid_ref_preserves_config(&git_dir, result);
4846    }
4847
4848    #[test]
4849    fn write_symbolic_ref_rejects_traversal_refname() {
4850        let (_dir, git_dir) = bare_repo_with_config();
4851        let result = write_symbolic_ref(&git_dir, TRAVERSAL_REF, "refs/heads/main");
4852        assert_invalid_ref_preserves_config(&git_dir, result);
4853    }
4854
4855    #[test]
4856    fn delete_ref_rejects_traversal_refname() {
4857        let (_dir, git_dir) = bare_repo_with_config();
4858        let result = delete_ref(&git_dir, TRAVERSAL_REF);
4859        assert_invalid_ref_preserves_config(&git_dir, result);
4860    }
4861
4862    #[test]
4863    fn write_ref_rejects_illegal_branch_refname() {
4864        let (_dir, git_dir) = bare_repo_with_config();
4865        for refname in [
4866            "refs/heads/bad name",
4867            "refs/heads/x..y",
4868            "refs/heads/a~b",
4869            "refs/heads/a.lock",
4870        ] {
4871            let result = write_ref(&git_dir, refname, &sample_oid());
4872            assert_invalid_ref_preserves_config(&git_dir, result);
4873            assert!(
4874                !git_dir
4875                    .join(refname.strip_prefix("refs/").unwrap())
4876                    .exists(),
4877                "must not write loose ref for {refname}"
4878            );
4879        }
4880    }
4881
4882    #[test]
4883    fn write_ref_accepts_refs_heads_head() {
4884        let (_dir, git_dir) = bare_repo_with_config();
4885        write_ref(&git_dir, "refs/heads/HEAD", &sample_oid())
4886            .expect("refs/heads/HEAD is valid ref syntax");
4887        assert!(git_dir.join("refs/heads/HEAD").is_file());
4888    }
4889}
4890
4891#[cfg(test)]
4892mod log_refs_config_tests {
4893    use super::*;
4894    use crate::config::{ConfigFile, ConfigScope};
4895
4896    fn set(text: &str) -> ConfigSet {
4897        let file = ConfigFile::parse(Path::new("config"), text, ConfigScope::Local).unwrap();
4898        let mut set = ConfigSet::new();
4899        set.merge(&file);
4900        set
4901    }
4902
4903    #[test]
4904    fn parse_accepts_modes_and_bool_spellings() {
4905        for (raw, mode) in [
4906            ("always", LogRefsConfig::Always),
4907            (" ALWAYS ", LogRefsConfig::Always),
4908            ("true", LogRefsConfig::Normal),
4909            ("Yes", LogRefsConfig::Normal),
4910            ("on", LogRefsConfig::Normal),
4911            ("1", LogRefsConfig::Normal),
4912            ("false", LogRefsConfig::None),
4913            ("no", LogRefsConfig::None),
4914            ("OFF", LogRefsConfig::None),
4915            ("0", LogRefsConfig::None),
4916            ("never", LogRefsConfig::None),
4917        ] {
4918            assert_eq!(LogRefsConfig::parse(raw), Some(mode), "{raw}");
4919        }
4920        assert_eq!(LogRefsConfig::parse("sometimes"), None);
4921    }
4922
4923    #[test]
4924    fn effective_mode_applies_bare_default_only_when_unset() {
4925        assert_eq!(set("").log_refs_config(), LogRefsConfig::Unset);
4926        assert_eq!(set("").effective_log_refs_config(), LogRefsConfig::Normal);
4927        let bare = set("[core]\n\tbare = true\n");
4928        assert_eq!(bare.effective_log_refs_config(), LogRefsConfig::None);
4929        let bogus = set("[core]\n\tbare = true\n\tlogAllRefUpdates = sometimes\n");
4930        assert_eq!(bogus.log_refs_config(), LogRefsConfig::Unset);
4931        assert_eq!(bogus.effective_log_refs_config(), LogRefsConfig::None);
4932        let bare_logged = set("[core]\n\tbare = true\n\tlogAllRefUpdates\n");
4933        assert_eq!(
4934            bare_logged.effective_log_refs_config(),
4935            LogRefsConfig::Normal
4936        );
4937        let always = set("[core]\n\tlogAllRefUpdates = always\n");
4938        assert_eq!(always.effective_log_refs_config(), LogRefsConfig::Always);
4939    }
4940
4941    #[test]
4942    fn autocreate_with_config_matches_mode() {
4943        let normal = set("[core]\n\tlogAllRefUpdates = true\n");
4944        let git_dir = Path::new(".git");
4945        assert!(should_autocreate_reflog_with_config(
4946            &normal,
4947            git_dir,
4948            "refs/heads/main"
4949        ));
4950        assert!(!should_autocreate_reflog_with_config(
4951            &normal,
4952            git_dir,
4953            "refs/tags/v1"
4954        ));
4955        let none = set("[core]\n\tlogAllRefUpdates = false\n");
4956        assert!(!should_autocreate_reflog_with_config(
4957            &none, git_dir, "HEAD"
4958        ));
4959    }
4960}
4961
4962#[cfg(test)]
4963mod read_raw_ref_tests {
4964    use super::*;
4965    use tempfile::tempdir;
4966
4967    #[test]
4968    fn loose_ref_file_is_exists() {
4969        let dir = tempdir().unwrap();
4970        let git_dir = dir.path();
4971        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
4972        fs::write(
4973            git_dir.join("refs/heads/side"),
4974            "0000000000000000000000000000000000000000\n",
4975        )
4976        .unwrap();
4977        assert_eq!(
4978            read_raw_ref(git_dir, "refs/heads/side").unwrap(),
4979            RawRefLookup::Exists
4980        );
4981    }
4982
4983    #[test]
4984    fn missing_ref_is_not_found() {
4985        let dir = tempdir().unwrap();
4986        let git_dir = dir.path();
4987        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
4988        assert_eq!(
4989            read_raw_ref(git_dir, "refs/heads/nope").unwrap(),
4990            RawRefLookup::NotFound
4991        );
4992    }
4993
4994    #[test]
4995    fn directory_where_ref_expected_is_is_directory() {
4996        let dir = tempdir().unwrap();
4997        let git_dir = dir.path();
4998        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
4999        assert_eq!(
5000            read_raw_ref(git_dir, "refs/heads").unwrap(),
5001            RawRefLookup::IsDirectory
5002        );
5003    }
5004
5005    #[test]
5006    fn packed_ref_name_is_exists() {
5007        let dir = tempdir().unwrap();
5008        let git_dir = dir.path();
5009        fs::write(
5010            store::paths::packed_refs_path(git_dir),
5011            "# pack-refs with: peeled fully-peeled \n\
5012             0000000000000000000000000000000000000000 refs/heads/packed\n",
5013        )
5014        .unwrap();
5015        assert_eq!(
5016            read_raw_ref(git_dir, "refs/heads/packed").unwrap(),
5017            RawRefLookup::Exists
5018        );
5019    }
5020}
5021
5022#[cfg(test)]
5023mod packed_refs_parse_tests {
5024    use super::*;
5025
5026    use tempfile::tempdir;
5027
5028    #[test]
5029    fn parse_rejects_garbage_line() {
5030        let dir = tempdir().unwrap();
5031        let path = store::paths::packed_refs_path(dir.path());
5032        fs::write(
5033            &path,
5034            "# pack-refs with: peeled\n\
5035             0000000000000000000000000000000000000000 refs/heads/main\n\
5036             not a ref line\n",
5037        )
5038        .unwrap();
5039        let data = fs::read(&path).unwrap();
5040        assert!(matches!(
5041            parse_packed_refs_to_map(&path, &data),
5042            Err(Error::PackedRefsUnexpectedLine { .. })
5043        ));
5044    }
5045
5046    #[test]
5047    fn parse_rejects_carriage_return_bytes() {
5048        let dir = tempdir().unwrap();
5049        let path = store::paths::packed_refs_path(dir.path());
5050        fs::write(
5051            &path,
5052            "# pack-refs with: peeled\n\
5053             0000000000000000000000000000000000000000 refs/heads/main\r\n",
5054        )
5055        .unwrap();
5056        let data = fs::read(&path).unwrap();
5057        assert!(matches!(
5058            parse_packed_refs_to_map(&path, &data),
5059            Err(Error::PackedRefsUnexpectedLine { .. })
5060        ));
5061    }
5062
5063    #[test]
5064    fn prefix_filter_unsorted_packed_refs_scans_past_later_names() {
5065        let dir = tempdir().unwrap();
5066        let git_dir = dir.path();
5067        let oid = "1111111111111111111111111111111111111111";
5068        fs::write(
5069            store::paths::packed_refs_path(git_dir),
5070            format!(
5071                "# pack-refs with: peeled fully-peeled\n{oid} refs/heads/zzz\n{oid} refs/heads/aaa\n{oid} refs/heads/main\n"
5072            ),
5073        )
5074        .unwrap();
5075        let listed = list_refs_glob(git_dir, "refs/heads/a*").unwrap();
5076        assert_eq!(listed.len(), 1);
5077        assert_eq!(listed[0].0, "refs/heads/aaa");
5078    }
5079
5080    #[test]
5081    fn parse_rejects_blank_line_and_missing_final_newline() {
5082        let dir = tempdir().unwrap();
5083        let path = store::paths::packed_refs_path(dir.path());
5084        fs::write(
5085            &path,
5086            "# pack-refs with: peeled\n\n\
5087             0000000000000000000000000000000000000000 refs/heads/main\n",
5088        )
5089        .unwrap();
5090        let data = fs::read(&path).unwrap();
5091        assert!(matches!(
5092            parse_packed_refs_to_map(&path, &data),
5093            Err(Error::PackedRefsUnexpectedLine { .. })
5094        ));
5095
5096        fs::write(
5097            &path,
5098            "# pack-refs with: peeled\n\
5099             0000000000000000000000000000000000000000 refs/heads/main",
5100        )
5101        .unwrap();
5102        let data = fs::read(&path).unwrap();
5103        assert!(matches!(
5104            parse_packed_refs_to_map(&path, &data),
5105            Err(Error::PackedRefsUnexpectedLine { .. })
5106        ));
5107    }
5108
5109    #[test]
5110    fn packed_record_filter_sees_trusted_peels_and_keeps_strictness() {
5111        let dir = tempdir().unwrap();
5112        let path = store::paths::packed_refs_path(dir.path());
5113        let a = "1111111111111111111111111111111111111111";
5114        let b = "2222222222222222222222222222222222222222";
5115        let peel = "3333333333333333333333333333333333333333";
5116        let body = |header: &str| {
5117            format!(
5118                "{header}{a} refs/heads/main\n\
5119                 {b} refs/heads/tagged\n\
5120                 ^{peel}\n\
5121                 {a} refs/tags/light\n\
5122                 {b} refs/tags/v1\n\
5123                 ^{peel}\n"
5124            )
5125        };
5126        let a: ObjectId = a.parse().unwrap();
5127        let b: ObjectId = b.parse().unwrap();
5128        let peel: ObjectId = peel.parse().unwrap();
5129        let cases = [
5130            (
5131                "# pack-refs with: peeled fully-peeled sorted \n",
5132                [
5133                    PackedPeel::NotTag,
5134                    PackedPeel::Peeled(peel),
5135                    PackedPeel::NotTag,
5136                    PackedPeel::Peeled(peel),
5137                ],
5138            ),
5139            (
5140                "# pack-refs with: peeled sorted \n",
5141                [
5142                    PackedPeel::Unknown,
5143                    PackedPeel::Unknown,
5144                    PackedPeel::NotTag,
5145                    PackedPeel::Peeled(peel),
5146                ],
5147            ),
5148            ("", [PackedPeel::Unknown; 4]),
5149        ];
5150        for (header, want) in cases {
5151            let data = body(header).into_bytes();
5152            let view = PackedRefsFileView::from_bytes(&path, data, HashAlgo::Sha1).unwrap();
5153            let seen = std::cell::RefCell::new(Vec::new());
5154            let keep = |name: &str, oid: &ObjectId, peel: PackedPeel| {
5155                seen.borrow_mut().push((name.to_owned(), *oid, peel));
5156                name.starts_with("refs/tags/")
5157            };
5158            let kept = view
5159                .map_for_prefix_filtered(&path, Some("refs"), Some(&keep))
5160                .unwrap();
5161            assert_eq!(
5162                seen.into_inner(),
5163                vec![
5164                    ("refs/heads/main".to_owned(), a, want[0]),
5165                    ("refs/heads/tagged".to_owned(), b, want[1]),
5166                    ("refs/tags/light".to_owned(), a, want[2]),
5167                    ("refs/tags/v1".to_owned(), b, want[3]),
5168                ],
5169                "header {header:?}"
5170            );
5171            let mut kept: Vec<_> = kept.into_iter().collect();
5172            kept.sort();
5173            assert_eq!(
5174                kept,
5175                vec![
5176                    ("refs/tags/light".to_owned(), a),
5177                    ("refs/tags/v1".to_owned(), b)
5178                ]
5179            );
5180            assert_eq!(
5181                view.map_for_prefix_filtered(&path, Some("refs"), None)
5182                    .unwrap(),
5183                view.map_for_prefix(&path, Some("refs")).unwrap()
5184            );
5185        }
5186
5187        // A malformed record is an error even when the filter would drop it.
5188        let bad = format!("# pack-refs with: peeled fully-peeled \n{a} refs/heads/bad..name\n");
5189        let view = PackedRefsFileView::from_bytes(&path, bad.into_bytes(), HashAlgo::Sha1).unwrap();
5190        assert!(matches!(
5191            view.map_for_prefix_filtered(&path, None, Some(&|_: &str, _: &ObjectId, _| false)),
5192            Err(Error::PackedRefsUnexpectedLine { .. })
5193        ));
5194    }
5195
5196    #[test]
5197    fn indexed_lookup_matches_full_parse_sorted_and_unsorted() {
5198        let dir = tempdir().unwrap();
5199        let path = store::paths::packed_refs_path(dir.path());
5200        let oid = "1111111111111111111111111111111111111111";
5201        let peel = "2222222222222222222222222222222222222222";
5202        let sorted_body = format!(
5203            "# pack-refs with: peeled fully-peeled sorted\n\
5204             {oid} refs/heads/aaa\n\
5205             {oid} refs/heads/main\n\
5206             {oid} refs/heads/zzz\n\
5207             {oid} refs/tags/v1\n\
5208             ^{peel}\n"
5209        );
5210        fs::write(&path, &sorted_body).unwrap();
5211        let data = fs::read(&path).unwrap();
5212        let view = PackedRefsFileView::from_bytes(&path, data.clone(), HashAlgo::Sha1).unwrap();
5213        let full = parse_packed_refs_to_map(&path, &data).unwrap();
5214        for (name, want) in &full {
5215            assert_eq!(view.lookup_oid(&path, name).unwrap(), Some(*want));
5216        }
5217        assert_eq!(
5218            lookup_packed_ref_with_peel(dir.path(), "refs/tags/v1")
5219                .unwrap()
5220                .map(|(o, p)| (o, p.unwrap())),
5221            Some((oid.parse().unwrap(), peel.parse().unwrap()))
5222        );
5223
5224        let unsorted_body = format!(
5225            "# pack-refs with: peeled\n{oid} refs/heads/zzz\n{oid} refs/heads/aaa\n{oid} refs/heads/main\n"
5226        );
5227        fs::write(&path, unsorted_body).unwrap();
5228        let data = fs::read(&path).unwrap();
5229        let view = PackedRefsFileView::from_bytes(&path, data.clone(), HashAlgo::Sha1).unwrap();
5230        let full = parse_packed_refs_to_map(&path, &data).unwrap();
5231        for (name, want) in &full {
5232            assert_eq!(view.lookup_oid(&path, name).unwrap(), Some(*want));
5233        }
5234        assert_eq!(
5235            view.map_for_prefix(&path, Some("refs/heads/a")).unwrap(),
5236            parse_packed_refs_to_map_with_prefix(&path, &data, Some("refs/heads/a")).unwrap()
5237        );
5238    }
5239
5240    #[test]
5241    fn sorted_prefix_without_upper_bound_filters_names() {
5242        let dir = tempdir().unwrap();
5243        let path = store::paths::packed_refs_path(dir.path());
5244        let oid = "1111111111111111111111111111111111111111";
5245        let sorted_body = format!(
5246            "# pack-refs with: peeled fully-peeled sorted\n\
5247             {oid} refs/heads/aaa\n\
5248             {oid} refs/heads/zzz\n\
5249             {oid} refs/tags/v1\n"
5250        );
5251        fs::write(&path, sorted_body).unwrap();
5252        let data = fs::read(&path).unwrap();
5253        let view = PackedRefsFileView::from_bytes(&path, data.clone(), HashAlgo::Sha1).unwrap();
5254        assert_eq!(
5255            view.map_for_prefix(&path, Some("refs/heads/a")).unwrap(),
5256            parse_packed_refs_to_map_with_prefix(&path, &data, Some("refs/heads/a")).unwrap()
5257        );
5258        assert!(
5259            !view
5260                .map_for_prefix(&path, Some("refs/heads/a"))
5261                .unwrap()
5262                .contains_key("refs/heads/zzz"),
5263            "prefix refs/heads/a must not include refs/heads/zzz"
5264        );
5265        assert_eq!(
5266            view.map_for_prefix(&path, Some("refs")).unwrap(),
5267            parse_packed_refs_to_map_with_prefix(&path, &data, Some("refs")).unwrap()
5268        );
5269    }
5270
5271    #[test]
5272    fn sorted_prefix_scan_validates_only_its_range() {
5273        let dir = tempdir().unwrap();
5274        let path = store::paths::packed_refs_path(dir.path());
5275        let oid = "1111111111111111111111111111111111111111";
5276        let view_of =
5277            |body: String| PackedRefsFileView::from_bytes(&path, body.into_bytes(), HashAlgo::Sha1);
5278
5279        let bad_peel_on_branch = view_of(format!(
5280            "# pack-refs with: peeled fully-peeled sorted\n\
5281             {oid} refs/heads/main\n\
5282             ^not-hex\n\
5283             {oid} refs/tags/v1\n"
5284        ))
5285        .unwrap();
5286        assert!(matches!(
5287            bad_peel_on_branch.map_for_prefix(&path, Some("refs/heads")),
5288            Err(Error::PackedRefsUnexpectedLine { .. })
5289        ));
5290        let tags = bad_peel_on_branch
5291            .map_for_prefix(&path, Some("refs/tags"))
5292            .unwrap();
5293        assert_eq!(tags.keys().collect::<Vec<_>>(), ["refs/tags/v1"]);
5294        assert!(bad_peel_on_branch.map_for_prefix(&path, None).is_err());
5295
5296        let blank_among_tags = view_of(format!(
5297            "# pack-refs with: peeled fully-peeled sorted\n\
5298             {oid} refs/heads/main\n\
5299             {oid} refs/tags/v1\n\
5300             \n\
5301             {oid} refs/tags/v2\n"
5302        ))
5303        .unwrap();
5304        assert_eq!(
5305            blank_among_tags
5306                .map_for_prefix(&path, Some("refs/heads"))
5307                .unwrap()
5308                .len(),
5309            1
5310        );
5311        assert!(blank_among_tags
5312            .map_for_prefix(&path, Some("refs/tags"))
5313            .is_err());
5314
5315        let no_match = blank_among_tags
5316            .map_for_prefix(&path, Some("refs/notes"))
5317            .unwrap();
5318        assert!(no_match.is_empty());
5319    }
5320
5321    #[test]
5322    fn non_utf8_record_only_breaks_reads_that_cover_it() {
5323        let dir = tempdir().unwrap();
5324        let path = store::paths::packed_refs_path(dir.path());
5325        let oid = "1111111111111111111111111111111111111111";
5326        let mut body =
5327            format!("# pack-refs with: peeled fully-peeled sorted \n{oid} refs/heads/main\n")
5328                .into_bytes();
5329        body.extend_from_slice(format!("{oid} refs/tags/v1\n{oid} refs/tags/").as_bytes());
5330        body.extend_from_slice(b"\xff\n");
5331        let view = PackedRefsFileView::from_bytes(&path, body, HashAlgo::Sha1).unwrap();
5332        assert!(view.sorted);
5333        for name in ["refs/heads/main", "refs/tags/v1"] {
5334            assert_eq!(
5335                view.lookup_oid(&path, name).unwrap(),
5336                Some(oid.parse().unwrap())
5337            );
5338        }
5339        assert_eq!(view.lookup_oid(&path, "refs/tags/v2").unwrap(), None);
5340        let heads = view.map_for_prefix(&path, Some("refs/heads")).unwrap();
5341        assert_eq!(heads.keys().collect::<Vec<_>>(), ["refs/heads/main"]);
5342        assert!(matches!(
5343            view.map_for_prefix(&path, Some("refs/tags")),
5344            Err(Error::PackedRefsUnexpectedLine { .. })
5345        ));
5346        assert!(view.map_for_prefix(&path, None).is_err());
5347
5348        let mut bad_header = b"# pack-refs with: \xff sorted \n".to_vec();
5349        bad_header.extend_from_slice(format!("{oid} refs/heads/main\n").as_bytes());
5350        assert!(matches!(
5351            PackedRefsFileView::from_bytes(&path, bad_header, HashAlgo::Sha1),
5352            Err(Error::PackedRefsUnexpectedLine { .. })
5353        ));
5354    }
5355
5356    #[test]
5357    fn scan_of_file_without_ref_lines_still_validates_it() {
5358        let dir = tempdir().unwrap();
5359        let path = store::paths::packed_refs_path(dir.path());
5360        let view = PackedRefsFileView::from_bytes(
5361            &path,
5362            b"# pack-refs with: peeled fully-peeled sorted\n\
5363              ^1111111111111111111111111111111111111111\n"
5364                .to_vec(),
5365            HashAlgo::Sha1,
5366        )
5367        .unwrap();
5368        for prefix in [None, Some("refs/heads")] {
5369            assert!(matches!(
5370                view.map_for_prefix(&path, prefix),
5371                Err(Error::PackedRefsUnexpectedLine { .. })
5372            ));
5373        }
5374        let header_only = PackedRefsFileView::from_bytes(
5375            &path,
5376            b"# pack-refs with: peeled fully-peeled sorted\n".to_vec(),
5377            HashAlgo::Sha1,
5378        )
5379        .unwrap();
5380        assert!(header_only.map_for_prefix(&path, None).unwrap().is_empty());
5381    }
5382
5383    #[test]
5384    fn view_checks_last_line_length_against_the_repository_hash() {
5385        let dir = tempdir().unwrap();
5386        let path = store::paths::packed_refs_path(dir.path());
5387        let sha1 = "1111111111111111111111111111111111111111";
5388        let sha256 = "2".repeat(64);
5389        let view =
5390            |body: String, algo| PackedRefsFileView::from_bytes(&path, body.into_bytes(), algo);
5391        let header = "# pack-refs with: peeled fully-peeled sorted\n";
5392        let fifty_bytes = "x".repeat(49);
5393
5394        for (body, algo) in [
5395            (
5396                format!("{header}{sha1} refs/heads/main\ngarbage\n"),
5397                HashAlgo::Sha1,
5398            ),
5399            (
5400                format!("{header}{sha1} refs/heads/main\n^{}\n", &sha1[1..]),
5401                HashAlgo::Sha1,
5402            ),
5403            (
5404                format!("{header}{sha1} refs/heads/main\n#\n"),
5405                HashAlgo::Sha1,
5406            ),
5407            (
5408                format!("{header}{sha256} refs/heads/main\n{fifty_bytes}\n"),
5409                HashAlgo::Sha256,
5410            ),
5411            (format!("{header}{sha1} refs/heads/a\n"), HashAlgo::Sha256),
5412        ] {
5413            assert!(
5414                matches!(
5415                    view(body.clone(), algo),
5416                    Err(Error::PackedRefsUnexpectedLine { .. })
5417                ),
5418                "{algo:?}: {body:?}"
5419            );
5420        }
5421        for (body, algo) in [
5422            (header.to_owned(), HashAlgo::Sha1),
5423            (header.to_owned(), HashAlgo::Sha256),
5424            ("# pack-refs with: peeled\n".to_owned(), HashAlgo::Sha256),
5425            (
5426                format!("{header}{sha1} refs/heads/main\n{fifty_bytes}\n"),
5427                HashAlgo::Sha1,
5428            ),
5429            (
5430                format!("{header}{sha1} refs/heads/main\n^{sha1}\n"),
5431                HashAlgo::Sha1,
5432            ),
5433            (
5434                format!("{header}{sha256} refs/heads/main\n^{sha256}\n"),
5435                HashAlgo::Sha256,
5436            ),
5437        ] {
5438            assert!(view(body.clone(), algo).is_ok(), "{algo:?}: {body:?}");
5439        }
5440    }
5441
5442    #[test]
5443    fn sha256_short_last_record_fails_branch_listing() {
5444        let dir = tempdir().unwrap();
5445        let path = store::paths::packed_refs_path(dir.path());
5446        let sha256 = "2".repeat(64);
5447        let body = format!(
5448            "# pack-refs with: peeled fully-peeled sorted\n{sha256} refs/heads/main\n{}\n",
5449            "x".repeat(49)
5450        );
5451        assert!(
5452            PackedRefsFileView::from_bytes(&path, body.into_bytes(), HashAlgo::Sha256).is_err()
5453        );
5454    }
5455
5456    #[test]
5457    fn corrupt_line_inside_prefix_range_fails_instead_of_dropping_refs() {
5458        let dir = tempdir().unwrap();
5459        let path = store::paths::packed_refs_path(dir.path());
5460        let oid = "1111111111111111111111111111111111111111";
5461        let header = "# pack-refs with: peeled fully-peeled sorted\n";
5462        let view = |body: String| {
5463            PackedRefsFileView::from_bytes(&path, body.into_bytes(), HashAlgo::Sha1).unwrap()
5464        };
5465
5466        let garbage_after_first_branch = view(format!(
5467            "{header}{oid} refs/heads/a\ngarbage line\n{oid} refs/heads/b\n{oid} refs/heads/c\n\
5468             {oid} refs/tags/t1\n"
5469        ));
5470        assert!(matches!(
5471            garbage_after_first_branch.map_for_prefix(&path, Some("refs/heads")),
5472            Err(Error::PackedRefsUnexpectedLine { .. })
5473        ));
5474        let tags = garbage_after_first_branch
5475            .map_for_prefix(&path, Some("refs/tags"))
5476            .unwrap();
5477        assert_eq!(tags.keys().collect::<Vec<_>>(), ["refs/tags/t1"]);
5478
5479        let garbage_before_range = view(format!(
5480            "{header}{oid} refs/heads/a\ngarbage line\n{oid} refs/tags/t1\n"
5481        ));
5482        for prefix in ["refs/heads", "refs/tags", "refs/notes"] {
5483            assert!(
5484                garbage_before_range
5485                    .map_for_prefix(&path, Some(prefix))
5486                    .is_err(),
5487                "{prefix}"
5488            );
5489        }
5490        assert_eq!(
5491            garbage_before_range
5492                .map_for_prefix(&path, Some("refs/zz"))
5493                .unwrap()
5494                .len(),
5495            0
5496        );
5497
5498        let peeled_tag_before_range = view(format!(
5499            "{header}{oid} refs/heads/a\n^{oid}\n{oid} refs/tags/t1\n"
5500        ));
5501        let orphan_peel_before_range = view(format!(
5502            "{header}{oid} refs/heads/a\n^{oid}\n^{oid}\n{oid} refs/tags/t1\n"
5503        ));
5504        assert!(orphan_peel_before_range
5505            .map_for_prefix(&path, Some("refs/tags"))
5506            .is_err());
5507        assert_eq!(
5508            peeled_tag_before_range
5509                .map_for_prefix(&path, Some("refs/tags"))
5510                .unwrap()
5511                .len(),
5512            1
5513        );
5514    }
5515
5516    #[test]
5517    fn sorted_trait_is_trusted_even_when_names_are_out_of_order() {
5518        let dir = tempdir().unwrap();
5519        let path = store::paths::packed_refs_path(dir.path());
5520        let oid = "1111111111111111111111111111111111111111";
5521        let records = format!("{oid} refs/heads/b\n{oid} refs/heads/a\n{oid} refs/tags/t\n");
5522        let view_of = |header: &str| {
5523            PackedRefsFileView::from_bytes(
5524                &path,
5525                format!("{header}{records}").into_bytes(),
5526                HashAlgo::Sha1,
5527            )
5528            .unwrap()
5529        };
5530
5531        let lying = view_of("# pack-refs with: peeled fully-peeled sorted\n");
5532        assert!(lying.sorted);
5533        // The search probes `refs/heads/a` first, then moves right past it.
5534        assert!(lying.lookup_oid(&path, "refs/heads/a").unwrap().is_some());
5535        assert_eq!(lying.lookup_oid(&path, "refs/heads/b").unwrap(), None);
5536        let heads = lying.map_for_prefix(&path, Some("refs/heads")).unwrap();
5537        assert_eq!(heads.len(), 2);
5538
5539        let unsorted = view_of("# pack-refs with: peeled fully-peeled\n");
5540        assert!(!unsorted.sorted);
5541        for name in ["refs/heads/a", "refs/heads/b", "refs/tags/t"] {
5542            assert!(
5543                unsorted.lookup_oid(&path, name).unwrap().is_some(),
5544                "{name}"
5545            );
5546        }
5547    }
5548
5549    #[test]
5550    fn records_are_only_record_shaped_lines() {
5551        let dir = tempdir().unwrap();
5552        let path = store::paths::packed_refs_path(dir.path());
5553        let view = |data: &[u8]| {
5554            PackedRefsFileView::from_bytes(&path, data.to_vec(), HashAlgo::Sha1).unwrap()
5555        };
5556        let hex = "1".repeat(40);
5557        let data = format!("# header\n{hex} x\n^peel\n\n{hex} y\n#c\nshort\n{hex}z\n{hex} z\n");
5558        let starts: Vec<usize> = view(data.as_bytes()).records().collect();
5559        assert_eq!(starts, [9, 59, 153]);
5560        assert_eq!(view(b"").records().count(), 0);
5561        let out_of_order = format!("{hex} b\n{hex} a\n");
5562        assert_eq!(
5563            view(out_of_order.as_bytes()).records().collect::<Vec<_>>(),
5564            [0, 43]
5565        );
5566    }
5567
5568    #[test]
5569    fn parse_rejects_orphan_peel_and_double_space() {
5570        let dir = tempdir().unwrap();
5571        let path = store::paths::packed_refs_path(dir.path());
5572        fs::write(
5573            &path,
5574            "# pack-refs with: peeled\n\
5575             ^0000000000000000000000000000000000000000\n\
5576             0000000000000000000000000000000000000000 refs/heads/main\n",
5577        )
5578        .unwrap();
5579        let data = fs::read(&path).unwrap();
5580        assert!(matches!(
5581            parse_packed_refs_to_map(&path, &data),
5582            Err(Error::PackedRefsUnexpectedLine { .. })
5583        ));
5584
5585        fs::write(
5586            &path,
5587            "# pack-refs with: peeled\n\
5588             0000000000000000000000000000000000000000  refs/heads/main\n",
5589        )
5590        .unwrap();
5591        let data = fs::read(&path).unwrap();
5592        assert!(matches!(
5593            parse_packed_refs_to_map(&path, &data),
5594            Err(Error::PackedRefsUnexpectedLine { .. })
5595        ));
5596    }
5597}
5598
5599#[cfg(test)]
5600mod pack_refs_tests {
5601    use super::*;
5602    use crate::objects::{serialize_commit, CommitData, ObjectKind};
5603    use crate::repo::{init_repository, Repository};
5604    use std::fs;
5605    use std::sync::Arc;
5606
5607    fn write_commit(repo: &Repository, parents: Vec<ObjectId>) -> ObjectId {
5608        let tree = repo.odb().write(ObjectKind::Tree, b"").expect("tree");
5609        let raw = serialize_commit(&CommitData {
5610            tree,
5611            parents,
5612            author: "T <t@example.com> 1 +0000".into(),
5613            committer: "T <t@example.com> 1 +0000".into(),
5614            author_raw: Vec::new(),
5615            committer_raw: Vec::new(),
5616            encoding: None,
5617            message: "m\n".into(),
5618            raw_message: None,
5619            extra_headers: Vec::new(),
5620        });
5621        repo.odb().write(ObjectKind::Commit, &raw).expect("commit")
5622    }
5623
5624    #[test]
5625    fn pack_refs_skips_concurrent_loose_update() {
5626        let dir = tempfile::tempdir().expect("tempdir");
5627        let repo = init_repository(
5628            dir.path(),
5629            false,
5630            "main",
5631            None,
5632            crate::RefStorageFormat::Files,
5633        )
5634        .expect("init");
5635        let git_dir = repo.git_dir().to_path_buf();
5636        let c1 = write_commit(&repo, vec![]);
5637        let c2 = write_commit(&repo, vec![c1]);
5638        write_ref(&git_dir, "refs/heads/race", &c1).expect("seed loose");
5639
5640        let git_dir_hook = git_dir.clone();
5641        let c2 = Arc::new(c2);
5642        let c2_hook = Arc::clone(&c2);
5643        pack_refs_test_hooks::set_before_prune(Some(Box::new(move || {
5644            write_ref(&git_dir_hook, "refs/heads/race", &c2_hook).expect("concurrent update");
5645        })));
5646
5647        let (_path, packed, pruned) =
5648            pack_refs_files(&git_dir, repo.odb(), true, false).expect("pack");
5649        pack_refs_test_hooks::set_before_prune(None);
5650        assert!(packed >= 1);
5651        assert_eq!(pruned, 0, "stale CAS must not delete the updated loose ref");
5652
5653        let resolved = resolve_ref(&git_dir, "refs/heads/race").expect("resolve");
5654        assert_eq!(resolved, *c2);
5655        let packed_body =
5656            fs::read_to_string(store::paths::packed_refs_path(&git_dir)).unwrap_or_default();
5657        assert!(
5658            !packed_body.contains("refs/heads/race"),
5659            "packed entry for concurrently updated ref must be removed"
5660        );
5661    }
5662
5663    #[test]
5664    fn pack_refs_nested_tag_peel_line_points_at_commit() {
5665        let dir = tempfile::tempdir().expect("tempdir");
5666        let repo = init_repository(
5667            dir.path(),
5668            false,
5669            "main",
5670            None,
5671            crate::RefStorageFormat::Files,
5672        )
5673        .expect("init");
5674        let commit = write_commit(&repo, vec![]);
5675        let inner_tag_body = format!(
5676            "object {}\ntype commit\ntagger T <t@example.com> 1 +0000\n\ninner\n",
5677            commit.to_hex()
5678        );
5679        let inner = repo
5680            .odb()
5681            .write(ObjectKind::Tag, inner_tag_body.as_bytes())
5682            .expect("inner tag");
5683        let outer_tag_body = format!(
5684            "object {}\ntype tag\ntagger T <t@example.com> 2 +0000\n\nouter\n",
5685            inner.to_hex()
5686        );
5687        let outer = repo
5688            .odb()
5689            .write(ObjectKind::Tag, outer_tag_body.as_bytes())
5690            .expect("outer tag");
5691        write_ref(repo.git_dir(), "refs/tags/outer", &outer).expect("tag ref");
5692
5693        pack_refs_files(repo.git_dir(), repo.odb(), false, false).expect("pack");
5694        let body =
5695            fs::read_to_string(store::paths::packed_refs_path(repo.git_dir())).expect("packed");
5696        let mut lines = body.lines();
5697        let tag_line = lines
5698            .find(|l| l.contains("refs/tags/outer"))
5699            .expect("tag line");
5700        assert!(tag_line.starts_with(&outer.to_hex()));
5701        let peel_line = lines.find(|l| l.starts_with('^')).expect("peel line");
5702        assert!(
5703            peel_line.contains(&commit.to_hex()),
5704            "peel must reach commit through nested tags, got {peel_line}"
5705        );
5706    }
5707
5708    #[test]
5709    fn pack_refs_tree_target_tag_has_peel_line() {
5710        let dir = tempfile::tempdir().expect("tempdir");
5711        let repo = init_repository(
5712            dir.path(),
5713            false,
5714            "main",
5715            None,
5716            crate::RefStorageFormat::Files,
5717        )
5718        .expect("init");
5719        let tree = repo.odb().write(ObjectKind::Tree, b"").expect("tree");
5720        let tag_body = format!(
5721            "object {}\ntype tree\ntagger T <t@example.com> 1 +0000\n\ntree tag\n",
5722            tree.to_hex()
5723        );
5724        let tag = repo
5725            .odb()
5726            .write(ObjectKind::Tag, tag_body.as_bytes())
5727            .expect("tag");
5728        write_ref(repo.git_dir(), "refs/tags/tree-tag", &tag).expect("tag ref");
5729
5730        pack_refs_files(repo.git_dir(), repo.odb(), true, false).expect("pack");
5731        let body =
5732            fs::read_to_string(store::paths::packed_refs_path(repo.git_dir())).expect("packed");
5733        assert!(body.contains("refs/tags/tree-tag"));
5734        assert!(
5735            body.contains(&format!("^{}", tree.to_hex())),
5736            "expected peel to tree: {body}"
5737        );
5738    }
5739
5740    #[test]
5741    fn write_packed_refs_file_survives_publish_failure() {
5742        let dir = tempfile::tempdir().expect("tempdir");
5743        let git_dir = dir.path();
5744        fs::create_dir_all(git_dir.join("refs/heads")).expect("refs/heads");
5745        let oid = ObjectId::from_hex("0000000000000000000000000000000000000001").expect("oid");
5746        fs::write(git_dir.join("refs/heads/keep"), format!("{oid}\n")).expect("loose ref");
5747        fs::create_dir_all(store::paths::packed_refs_path(git_dir))
5748            .expect("block packed-refs file");
5749        let err = write_packed_refs_file(
5750            git_dir,
5751            &[PackedRefRecord {
5752                refname: "refs/heads/keep".into(),
5753                oid,
5754                peeled: None,
5755            }],
5756        )
5757        .expect_err("publish should fail");
5758        assert!(matches!(err, Error::Io(_)));
5759        assert!(git_dir.join("refs/heads/keep").is_file());
5760    }
5761
5762    #[test]
5763    fn write_packed_refs_file_replaces_loose_and_sorts() {
5764        let dir = tempfile::tempdir().expect("tempdir");
5765        let git_dir = dir.path();
5766        fs::create_dir_all(git_dir.join("refs/heads")).expect("refs/heads");
5767        let oid_a = ObjectId::from_hex("0000000000000000000000000000000000000001").expect("oid a");
5768        let oid_b = ObjectId::from_hex("0000000000000000000000000000000000000002").expect("oid b");
5769        fs::write(git_dir.join("refs/heads/a-first"), format!("{oid_b}\n")).expect("loose ref");
5770        write_packed_refs_file(
5771            git_dir,
5772            &[
5773                PackedRefRecord {
5774                    refname: "refs/heads/z-last".into(),
5775                    oid: oid_b,
5776                    peeled: None,
5777                },
5778                PackedRefRecord {
5779                    refname: "refs/heads/a-first".into(),
5780                    oid: oid_a,
5781                    peeled: None,
5782                },
5783            ],
5784        )
5785        .expect("write packed-refs");
5786        assert!(!git_dir.join("refs/heads/a-first").exists());
5787        let body =
5788            fs::read_to_string(store::paths::packed_refs_path(git_dir)).expect("packed-refs");
5789        assert!(body.contains("refs/heads/a-first"));
5790        assert!(body.find("a-first").expect("a-first") < body.find("z-last").expect("z-last"));
5791    }
5792}
5793
5794#[cfg(test)]
5795mod packed_ref_peels_tests {
5796    use super::*;
5797
5798    fn oid(n: u8) -> ObjectId {
5799        ObjectId::from_bytes(&[n; 20]).expect("oid")
5800    }
5801
5802    fn peels_for(body: &str) -> (tempfile::TempDir, PackedRefPeels) {
5803        let dir = tempfile::tempdir().expect("tempdir");
5804        fs::write(store::paths::packed_refs_path(dir.path()), body).expect("packed-refs");
5805        let peels = PackedRefPeels::load(dir.path(), RefStorageFormat::Files).expect("load");
5806        (dir, peels)
5807    }
5808
5809    fn body(header: &str, records: &[(&str, ObjectId, Option<ObjectId>)]) -> String {
5810        let mut out = String::from(header);
5811        for (name, oid, peel) in records {
5812            out.push_str(&format!("{oid} {name}\n"));
5813            if let Some(peel) = peel {
5814                out.push_str(&format!("^{peel}\n"));
5815            }
5816        }
5817        out
5818    }
5819
5820    #[test]
5821    fn fully_peeled_file_answers_for_every_ref() {
5822        for header in [
5823            "# pack-refs with: peeled fully-peeled sorted \n",
5824            "# pack-refs with: peeled fully-peeled \n",
5825        ] {
5826            let records = [
5827                ("refs/heads/main", oid(1), None),
5828                ("refs/remotes/origin/tag-ish", oid(2), Some(oid(3))),
5829                ("refs/tags/v1", oid(4), Some(oid(1))),
5830                ("refs/tags/light", oid(1), None),
5831            ];
5832            // A `sorted` header is trusted, so only the file without it is written out of order.
5833            let mut ordered = records;
5834            if header.contains(" sorted") {
5835                ordered.sort_by_key(|(name, _, _)| *name);
5836            } else {
5837                ordered.reverse();
5838            }
5839            let (_dir, peels) = peels_for(&body(header, &ordered));
5840            assert_eq!(peels.peeled("refs/heads/main", &oid(1)), Some(None));
5841            assert_eq!(
5842                peels.peeled("refs/remotes/origin/tag-ish", &oid(2)),
5843                Some(Some(oid(3)))
5844            );
5845            assert_eq!(peels.peeled("refs/tags/v1", &oid(4)), Some(Some(oid(1))));
5846            assert_eq!(peels.peeled("refs/tags/light", &oid(1)), Some(None));
5847            // A loose ref that overrides the packed value is not vouched for.
5848            assert_eq!(peels.peeled("refs/tags/v1", &oid(9)), None);
5849            assert_eq!(peels.peeled("refs/tags/missing", &oid(1)), None);
5850        }
5851    }
5852
5853    #[test]
5854    fn peeled_trait_only_covers_tags_and_no_trait_covers_nothing() {
5855        let records = [
5856            ("refs/heads/main", oid(1), None),
5857            ("refs/tags/v1", oid(4), Some(oid(1))),
5858        ];
5859        let (_dir, peels) = peels_for(&body("# pack-refs with: peeled sorted\n", &records));
5860        assert_eq!(peels.peeled("refs/heads/main", &oid(1)), None);
5861        assert_eq!(peels.peeled("refs/tags/v1", &oid(4)), Some(Some(oid(1))));
5862
5863        for header in [
5864            "",
5865            "# pack-refs with: sorted\n",
5866            "# some comment\n",
5867            "#pack-refs with: peeled fully-peeled\n",
5868            "#  pack-refs with: peeled fully-peeled\n",
5869        ] {
5870            let (_dir, peels) = peels_for(&body(header, &records));
5871            assert_eq!(peels.peeled("refs/heads/main", &oid(1)), None, "{header:?}");
5872            assert_eq!(peels.peeled("refs/tags/v1", &oid(4)), None, "{header:?}");
5873        }
5874    }
5875
5876    #[test]
5877    fn missing_file_and_other_backends_know_nothing() {
5878        let dir = tempfile::tempdir().expect("tempdir");
5879        let peels = PackedRefPeels::load(dir.path(), RefStorageFormat::Files).expect("load");
5880        assert_eq!(peels.peeled("refs/heads/main", &oid(1)), None);
5881
5882        let records = [("refs/heads/main", oid(1), None)];
5883        fs::write(
5884            store::paths::packed_refs_path(dir.path()),
5885            body("# pack-refs with: peeled fully-peeled sorted\n", &records),
5886        )
5887        .expect("packed-refs");
5888        let peels = PackedRefPeels::load(dir.path(), RefStorageFormat::Reftable).expect("load");
5889        assert_eq!(peels.peeled("refs/heads/main", &oid(1)), None);
5890    }
5891
5892    #[test]
5893    fn lookups_in_any_order_agree_with_a_fresh_snapshot() {
5894        let records = [
5895            ("refs/heads/main", oid(1), None),
5896            ("refs/tags/a", oid(2), Some(oid(1))),
5897            ("refs/tags/b", oid(1), None),
5898            ("refs/tags/c", oid(3), Some(oid(4))),
5899            ("refs/tags/d", oid(5), None),
5900        ];
5901        let text = body("# pack-refs with: peeled fully-peeled sorted \n", &records);
5902        let (_dir, peels) = peels_for(&text);
5903        let fresh = |name: &str, value: &ObjectId| peels_for(&text).1.peeled(name, value);
5904        let queries = [
5905            ("refs/tags/a", oid(2)),
5906            ("refs/tags/b", oid(1)),
5907            ("refs/tags/b", oid(1)),
5908            ("refs/tags/bb", oid(1)),
5909            ("refs/tags/c", oid(3)),
5910            ("refs/tags/d", oid(9)),
5911            ("refs/tags/d", oid(5)),
5912            ("refs/heads/main", oid(1)),
5913            ("refs/tags/c", oid(3)),
5914            ("refs/tags/a", oid(2)),
5915            ("refs/tags/zzz", oid(1)),
5916            ("refs/tags/a", oid(2)),
5917            ("refs/tags/b", oid(1)),
5918        ];
5919        for (name, value) in queries {
5920            assert_eq!(
5921                peels.peeled(name, &value),
5922                fresh(name, &value),
5923                "{name} {value}"
5924            );
5925        }
5926        assert_eq!(peels.peeled("refs/tags/c", &oid(3)), Some(Some(oid(4))));
5927        assert_eq!(peels.peeled("refs/tags/d", &oid(5)), Some(None));
5928    }
5929
5930    #[test]
5931    fn store_snapshot_reuses_the_files_store_view_and_follows_rewrites() {
5932        let dir = tempfile::tempdir().expect("tempdir");
5933        let path = store::paths::packed_refs_path(dir.path());
5934        let header = "# pack-refs with: peeled fully-peeled sorted \n";
5935        let records = [
5936            ("refs/heads/main", oid(1), None),
5937            ("refs/tags/v1", oid(2), Some(oid(1))),
5938            ("refs/tags/v2", oid(3), None),
5939        ];
5940        fs::write(&path, body(header, &records)).expect("packed-refs");
5941        let files = store::FilesRefStore::from_git_dir(dir.path()).expect("files store");
5942
5943        let peels = PackedRefPeels::for_store(&files, dir.path()).expect("for_store");
5944        let loaded = PackedRefPeels::load(dir.path(), RefStorageFormat::Files).expect("load");
5945        for (name, value, _) in records {
5946            assert_eq!(
5947                peels.peeled(name, &value),
5948                loaded.peeled(name, &value),
5949                "{name}"
5950            );
5951        }
5952        assert_eq!(peels.peeled("refs/tags/v1", &oid(2)), Some(Some(oid(1))));
5953        let cached = files
5954            .packed_refs_view()
5955            .expect("view")
5956            .expect("packed-refs");
5957        let shared = peels.view.as_ref().expect("snapshot view");
5958        assert!(std::sync::Arc::ptr_eq(&cached.data, &shared.data));
5959
5960        let rewritten = [
5961            ("refs/heads/main", oid(1), None),
5962            ("refs/tags/v1", oid(4), Some(oid(5))),
5963            ("refs/tags/v2", oid(3), None),
5964            ("refs/tags/v3", oid(6), Some(oid(1))),
5965        ];
5966        fs::write(&path, body(header, &rewritten)).expect("rewrite packed-refs");
5967        let peels = PackedRefPeels::for_store(&files, dir.path()).expect("for_store");
5968        assert_eq!(peels.peeled("refs/tags/v1", &oid(2)), None);
5969        assert_eq!(peels.peeled("refs/tags/v1", &oid(4)), Some(Some(oid(5))));
5970        assert_eq!(peels.peeled("refs/tags/v3", &oid(6)), Some(Some(oid(1))));
5971
5972        let memory = store::MemoryRefStore::new();
5973        let fallback = PackedRefPeels::for_store(&memory, dir.path()).expect("fallback");
5974        let direct = PackedRefPeels::load(dir.path(), memory.format()).expect("load");
5975        for (name, value, _) in rewritten {
5976            assert_eq!(
5977                fallback.peeled(name, &value),
5978                direct.peeled(name, &value),
5979                "{name}"
5980            );
5981        }
5982    }
5983
5984    /// View over raw bytes, without the checks [`PackedRefsFileView::from_data`] makes.
5985    fn raw_view(data: &[u8], hex_len: usize) -> PackedRefsFileView {
5986        PackedRefsFileView {
5987            data: crate::pack_map::PackData::from_owned(data.to_vec()),
5988            sorted: true,
5989            hex_len,
5990            body_start: 0,
5991            peel_trait: PackedPeelTrait::None,
5992        }
5993    }
5994
5995    /// Reference record scan: the start of every record-shaped line, one line at a time.
5996    fn naive_record_starts(data: &[u8], hex_len: usize) -> Vec<usize> {
5997        let mut out = Vec::new();
5998        let mut start = 0;
5999        for line in data.split(|&b| b == b'\n') {
6000            if start >= data.len() {
6001                break;
6002            }
6003            if packed_line_is_record(line, hex_len) {
6004                out.push(start);
6005            }
6006            start += line.len() + 1;
6007        }
6008        out
6009    }
6010
6011    fn assert_record_walks_match(data: &[u8], hex_len: usize) {
6012        let view = raw_view(data, hex_len);
6013        let want = naive_record_starts(data, hex_len);
6014        assert_eq!(
6015            view.records().collect::<Vec<_>>(),
6016            want,
6017            "{:?} hex_len={hex_len}",
6018            String::from_utf8_lossy(data)
6019        );
6020        let mut line_starts: Vec<usize> = memchr::memchr_iter(b'\n', data)
6021            .map(|nl| nl + 1)
6022            .filter(|&s| s < data.len())
6023            .collect();
6024        line_starts.insert(0, 0);
6025        // Views always end in a newline, so the end is only a line start when it follows one.
6026        if data.ends_with(b"\n") {
6027            line_starts.push(data.len());
6028        }
6029        for before in line_starts {
6030            let prev = want.iter().copied().filter(|&s| s < before).last();
6031            assert_eq!(view.prev_record(before), prev, "{data:?} before={before}");
6032        }
6033    }
6034
6035    fn xorshift(state: &mut u64) -> u64 {
6036        *state ^= *state << 13;
6037        *state ^= *state >> 7;
6038        *state ^= *state << 17;
6039        *state
6040    }
6041
6042    #[test]
6043    fn record_walks_match_a_line_by_line_scan() {
6044        let hex = oid(1).to_hex();
6045        let inputs = [
6046            String::new(),
6047            "\n".to_owned(),
6048            format!("# pack-refs with: peeled \n{hex} refs/heads/a\n^{hex}\n{hex} refs/tags/b\n"),
6049            format!("{hex} refs/heads/z\n{hex} refs/heads/a\n"),
6050            format!("{hex} refs/heads/a\n\n{hex} refs/heads/b"),
6051            format!("{hex}\n{hex} \n{hex} x\nshort\n#{hex} refs/heads/c\n"),
6052            format!("{hex} refs/tags/a\n^{hex}\n^{hex}\n{hex} refs/tags/b\n^{hex}"),
6053            format!("^ab\n{}\n{hex} refs/heads/a\n", "x".repeat(37)),
6054            format!("^\n{hex} refs/heads/a\n^{hex}x\n{hex} refs/heads/b\n"),
6055            format!("^{hex}\n"),
6056            format!("^{}", &hex[1..]),
6057        ];
6058        for input in &inputs {
6059            assert_record_walks_match(input.as_bytes(), 40);
6060        }
6061    }
6062
6063    #[test]
6064    fn record_walks_match_a_line_by_line_scan_on_random_bytes() {
6065        let alphabet = b"^\n #ab";
6066        let mut state = 0x2545_f491_4f6c_dd1du64;
6067        for len in 0..400 {
6068            let input: Vec<u8> = (0..len % 97)
6069                .map(|_| alphabet[(xorshift(&mut state) % alphabet.len() as u64) as usize])
6070                .collect();
6071            for hex_len in [1, 2, 3] {
6072                assert_record_walks_match(&input, hex_len);
6073            }
6074        }
6075    }
6076
6077    /// On a file whose records really are in order, the byte-level search finds exactly what
6078    /// a linear scan finds, whatever peel and stray lines sit between the records.
6079    #[test]
6080    fn search_matches_a_linear_scan_on_sorted_records() {
6081        let hex_len = 2;
6082        let mut state = 0x9e37_79b9_7f4a_7c15u64;
6083        for _ in 0..300 {
6084            let mut names: Vec<String> = (0..xorshift(&mut state) % 12)
6085                .map(|_| format!("n{}", xorshift(&mut state) % 40))
6086                .collect();
6087            names.sort();
6088            names.dedup();
6089            let mut data = b"# h\n".to_vec();
6090            for name in &names {
6091                data.extend_from_slice(format!("ab {name}\n").as_bytes());
6092                match xorshift(&mut state) % 4 {
6093                    0 => data.extend_from_slice(b"^ab\n"),
6094                    1 => data.extend_from_slice(b"junk\n"),
6095                    _ => {}
6096                }
6097            }
6098            let mut view = raw_view(&data, hex_len);
6099            view.body_start = 4;
6100            let records: Vec<usize> = view.records().collect();
6101            for probe in 0..42 {
6102                let target = format!("n{probe}");
6103                let want = records
6104                    .iter()
6105                    .copied()
6106                    .find(|&s| view.record_cmp(s, &target).is_ge())
6107                    .unwrap_or(data.len());
6108                assert_eq!(view.lower_bound(&target), want, "{target} in {data:?}");
6109                let hit = view.search(&target).ok();
6110                assert_eq!(
6111                    hit,
6112                    names.contains(&target).then_some(want),
6113                    "{target} in {data:?}"
6114                );
6115            }
6116        }
6117    }
6118}