Skip to main content

grit_lib/refs/
mod.rs

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