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