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