Skip to main content

grit_lib/refs/
mod.rs

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