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