Skip to main content

grit_lib/
refs.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
16use std::collections::{BTreeSet, HashMap, HashSet};
17use std::fs;
18use std::io;
19use std::path::{Path, PathBuf};
20
21use crate::config::ConfigSet;
22use crate::error::{Error, Result};
23use crate::objects::ObjectId;
24use crate::pack;
25
26/// A symbolic or direct reference.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Ref {
29    /// Direct reference: stores an [`ObjectId`].
30    Direct(ObjectId),
31    /// Symbolic reference: stores the name of the target ref.
32    Symbolic(String),
33}
34
35/// Read a single reference file from `path`.
36///
37/// # Errors
38///
39/// - [`Error::InvalidRef`] if the file content is not a valid ref.
40/// - [`Error::Io`] on filesystem errors.
41pub fn read_ref_file(path: &Path) -> Result<Ref> {
42    let content = match fs::read_to_string(path) {
43        Ok(c) => c,
44        // `refs/heads/master` can be a directory when a branch named `master/...` exists (Git
45        // creates `refs/heads/master` as a dir). Treat like a missing loose ref so resolution
46        // falls through to packed-refs (`t6437-submodule-merge` merge-search setup).
47        Err(e)
48            if e.kind() == io::ErrorKind::IsADirectory
49                || e.raw_os_error() == Some(libc::EISDIR) =>
50        {
51            return Err(Error::Io(io::Error::new(io::ErrorKind::NotFound, e)));
52        }
53        Err(e) => return Err(Error::Io(e)),
54    };
55    let content = content.trim_end_matches('\n');
56    parse_ref_content(content)
57}
58
59/// Parse the content of a ref file (without trailing newline).
60pub(crate) fn parse_ref_content(content: &str) -> Result<Ref> {
61    if let Some(target) = content.strip_prefix("ref: ") {
62        Ok(Ref::Symbolic(target.trim().to_owned()))
63    } else if content.len() == 40 && content.chars().all(|c| c.is_ascii_hexdigit()) {
64        let oid: ObjectId = content.parse()?;
65        Ok(Ref::Direct(oid))
66    } else if content == "unknown-oid" {
67        // Simplified harness `test_oid` placeholder (not valid hex). Match
68        // `for-each-ref` loose ref loading: treat as a direct ref to a
69        // non-resident OID so missing-object diagnostics match t6301.
70        const PLACEHOLDER: &[u8; 20] = b"GritUnknownOidPlc!X!";
71        let oid = ObjectId::from_bytes(PLACEHOLDER)?;
72        Ok(Ref::Direct(oid))
73    } else {
74        Err(Error::InvalidRef(content.to_owned()))
75    }
76}
77
78/// Resolve a reference to its target [`ObjectId`], following symbolic refs.
79///
80/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
81///
82/// # Parameters
83///
84/// - `git_dir` — path to the git directory.
85/// - `refname` — reference name (e.g. `"HEAD"`, `"refs/heads/main"`).
86///
87/// # Errors
88///
89/// - [`Error::InvalidRef`] if the ref is malformed or forms a cycle.
90/// - [`Error::ObjectNotFound`] if a symbolic target does not exist.
91pub fn resolve_ref(git_dir: &Path, refname: &str) -> Result<ObjectId> {
92    if crate::reftable::is_reftable_repo(git_dir) {
93        return crate::reftable::reftable_resolve_ref(git_dir, refname);
94    }
95    let common = common_dir(git_dir);
96    resolve_ref_depth(git_dir, common.as_deref(), refname, 0)
97}
98
99/// Determine the common git directory for worktree-aware ref resolution.
100///
101/// If `<git_dir>/commondir` exists, its contents point to the shared
102/// git directory. Returns `None` when git_dir is already the common dir.
103pub fn common_dir(git_dir: &Path) -> Option<PathBuf> {
104    let commondir_file = git_dir.join("commondir");
105    let raw = fs::read_to_string(commondir_file).ok()?;
106    let rel = raw.trim();
107    // Match Git: `commondir` may be relative to this gitdir or an absolute path (see
108    // `git worktree add` and `refs/files-backend.c`).
109    let path = if Path::new(rel).is_absolute() {
110        PathBuf::from(rel)
111    } else {
112        git_dir.join(rel)
113    };
114    path.canonicalize().ok()
115}
116
117fn notes_merge_state_ref(refname: &str) -> bool {
118    matches!(refname, "NOTES_MERGE_REF" | "NOTES_MERGE_PARTIAL")
119}
120
121/// Internal recursive resolver with cycle detection.
122///
123/// When operating inside a worktree, `common` points to the shared git
124/// directory where most refs live.  The worktree-specific `git_dir` is
125/// checked first for HEAD and per-worktree refs.
126fn resolve_ref_depth(
127    git_dir: &Path,
128    common: Option<&Path>,
129    refname: &str,
130    depth: usize,
131) -> Result<ObjectId> {
132    if depth > 10 {
133        return Err(Error::InvalidRef(format!(
134            "ref symlink too deep: {refname}"
135        )));
136    }
137
138    let storage_owned = crate::ref_namespace::storage_ref_name(refname);
139    let try_names: Vec<&str> =
140        if refname == "HEAD" && crate::ref_namespace::ref_storage_prefix().is_some() {
141            vec![storage_owned.as_str()]
142        } else if storage_owned != refname {
143            vec![storage_owned.as_str(), refname]
144        } else {
145            vec![refname]
146        };
147
148    for name in try_names {
149        let path = git_dir.join(name);
150        match read_ref_file(&path) {
151            Ok(Ref::Direct(oid)) => return Ok(oid),
152            Ok(Ref::Symbolic(target)) => {
153                return resolve_ref_depth(git_dir, common, &target, depth + 1);
154            }
155            Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
156            Err(e) => return Err(e),
157        }
158
159        if let Some(cdir) = common {
160            if notes_merge_state_ref(name) {
161            } else if cdir != git_dir {
162                let cpath = cdir.join(name);
163                match read_ref_file(&cpath) {
164                    Ok(Ref::Direct(oid)) => return Ok(oid),
165                    Ok(Ref::Symbolic(target)) => {
166                        return resolve_ref_depth(git_dir, common, &target, depth + 1);
167                    }
168                    Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
169                    Err(e) => return Err(e),
170                }
171            }
172        }
173
174        let packed_dir = common.unwrap_or(git_dir);
175        if let Some(oid) = lookup_packed_ref(packed_dir, name)? {
176            return Ok(oid);
177        }
178        if common.is_some() && common != Some(git_dir) {
179            if let Some(oid) = lookup_packed_ref(git_dir, name)? {
180                return Ok(oid);
181            }
182        }
183    }
184
185    Err(Error::InvalidRef(format!("ref not found: {refname}")))
186}
187
188/// Outcome of a single storage-level ref lookup (Git `refs_read_raw_ref` style).
189///
190/// This checks whether a ref **name** exists in the ref store without applying
191/// DWIM rules. A symbolic ref is considered to exist if its ref file (or
192/// reftable record) is present, even when the target is missing.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum RawRefLookup {
195    /// A loose ref file, packed ref line, or reftable record exists for this name.
196    Exists,
197    /// No ref is recorded under this exact name.
198    NotFound,
199    /// A path component exists as a directory where a ref file was expected (e.g. `refs/heads`).
200    IsDirectory,
201}
202
203/// Return whether `refname` exists as a ref in the repository's ref storage.
204///
205/// This matches `git refs exists` / `git show-ref --exists`: no DWIM, no
206/// resolution of symbolic targets. Dispatches to the reftable backend when
207/// configured.
208///
209/// # Parameters
210///
211/// - `git_dir` — path to the git directory (worktree gitdir or bare `.git`).
212/// - `refname` — full ref name (e.g. `HEAD`, `refs/heads/main`, `CHERRY_PICK_HEAD`).
213///
214/// # Errors
215///
216/// Propagates I/O and reftable errors other than "not found".
217pub fn read_raw_ref(git_dir: &Path, refname: &str) -> Result<RawRefLookup> {
218    if crate::reftable::is_reftable_repo(git_dir) {
219        read_raw_ref_reftable(git_dir, refname)
220    } else {
221        read_raw_ref_files(git_dir, refname)
222    }
223}
224
225fn read_raw_ref_files(git_dir: &Path, refname: &str) -> Result<RawRefLookup> {
226    let common = common_dir(git_dir);
227    let storage_owned = crate::ref_namespace::storage_ref_name(refname);
228    let (names, n): ([&str; 2], usize) = if storage_owned != refname {
229        ([storage_owned.as_str(), refname], 2)
230    } else {
231        ([refname, refname], 1)
232    };
233
234    for name in names.iter().take(n) {
235        if let Some(lookup) = read_raw_ref_at(git_dir.join(name))? {
236            return Ok(lookup);
237        }
238
239        if let Some(cdir) = common.as_ref() {
240            if *cdir != git_dir && !notes_merge_state_ref(name) {
241                if let Some(lookup) = read_raw_ref_at(cdir.join(name))? {
242                    return Ok(lookup);
243                }
244            }
245        }
246
247        let packed_dir = common.as_deref().unwrap_or(git_dir);
248        if packed_ref_name_exists(packed_dir, name)? {
249            return Ok(RawRefLookup::Exists);
250        }
251        if common.is_some()
252            && common.as_deref() != Some(git_dir)
253            && packed_ref_name_exists(git_dir, name)?
254        {
255            return Ok(RawRefLookup::Exists);
256        }
257    }
258
259    Ok(RawRefLookup::NotFound)
260}
261
262/// Lock file path for a loose ref file (`<refpath>.lock`), matching Git's naming for nested refs.
263#[must_use]
264pub fn lock_path_for_ref(path: &Path) -> PathBuf {
265    let mut s = path.as_os_str().to_owned();
266    s.push(".lock");
267    PathBuf::from(s)
268}
269
270fn read_raw_ref_at(path: PathBuf) -> Result<Option<RawRefLookup>> {
271    match fs::symlink_metadata(&path) {
272        Ok(meta) => {
273            if meta.is_dir() {
274                return Ok(Some(RawRefLookup::IsDirectory));
275            }
276            Ok(Some(RawRefLookup::Exists))
277        }
278        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
279        Err(e) => Err(Error::Io(e)),
280    }
281}
282
283fn packed_ref_with_prefix(git_dir: &Path, prefix_with_slash: &str) -> Result<Option<String>> {
284    let packed = git_dir.join("packed-refs");
285    let content = match fs::read_to_string(&packed) {
286        Ok(c) => c,
287        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
288        Err(e) => return Err(Error::Io(e)),
289    };
290    let mut best: Option<String> = None;
291    for line in content.lines() {
292        if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
293            continue;
294        }
295        let mut parts = line.split_whitespace();
296        let _oid = parts.next();
297        let Some(name) = parts.next() else {
298            continue;
299        };
300        let name = name.trim();
301        if name.starts_with(prefix_with_slash) {
302            let take = match &best {
303                None => true,
304                Some(b) => name < b.as_str(),
305            };
306            if take {
307                best = Some(name.to_owned());
308            }
309        }
310    }
311    Ok(best)
312}
313
314fn packed_ref_name_exists(git_dir: &Path, refname: &str) -> Result<bool> {
315    let packed = git_dir.join("packed-refs");
316    let content = match fs::read_to_string(&packed) {
317        Ok(c) => c,
318        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
319        Err(e) => return Err(Error::Io(e)),
320    };
321    for line in content.lines() {
322        if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
323            continue;
324        }
325        let mut parts = line.split_whitespace();
326        let _oid = parts.next();
327        if let Some(name) = parts.next() {
328            if name == refname {
329                return Ok(true);
330            }
331        }
332    }
333    Ok(false)
334}
335
336fn refname_namespace_conflicts(existing: &str, candidate: &str) -> bool {
337    if existing == candidate {
338        return false;
339    }
340    existing
341        .strip_prefix(candidate)
342        .is_some_and(|rest| rest.starts_with('/'))
343        || candidate
344            .strip_prefix(existing)
345            .is_some_and(|rest| rest.starts_with('/'))
346}
347
348fn packed_ref_namespace_conflict(git_dir: &Path, refname: &str) -> Result<bool> {
349    let packed = git_dir.join("packed-refs");
350    let content = match fs::read_to_string(&packed) {
351        Ok(c) => c,
352        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
353        Err(e) => return Err(Error::Io(e)),
354    };
355    for line in content.lines() {
356        if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
357            continue;
358        }
359        let mut parts = line.split_whitespace();
360        let _oid = parts.next();
361        if let Some(name) = parts.next() {
362            if refname_namespace_conflicts(name, refname) {
363                return Ok(true);
364            }
365        }
366    }
367    Ok(false)
368}
369
370/// Returns true if `packed-refs` in the ref storage directory for `refname` contains that name.
371///
372/// Used to mirror Git's `is_packed_transaction_needed` behaviour: deleting a ref may open a nested
373/// packed-refs transaction whose abort runs the `reference-transaction` hook in the `aborted`
374/// state between `preparing` and `prepared` on the main transaction.
375///
376/// Returns `false` for reftable repositories and for `HEAD` (packed-refs does not store `HEAD`).
377///
378/// # Errors
379///
380/// Propagates I/O errors reading `packed-refs`.
381pub fn packed_refs_entry_exists(git_dir: &Path, refname: &str) -> Result<bool> {
382    if crate::reftable::is_reftable_repo(git_dir) || refname == "HEAD" {
383        return Ok(false);
384    }
385    let storage_dir = ref_storage_dir(git_dir, refname);
386    packed_ref_name_exists(&storage_dir, refname)
387}
388
389/// Why a reference name cannot be created (Git `refs_verify_refname_available` style).
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub enum RefnameUnavailable {
392    /// An ancestor ref already exists in the store (e.g. `refs/foo` blocks `refs/foo/bar`).
393    AncestorExists {
394        /// Existing ref that blocks creation.
395        blocking: String,
396        /// Ref the caller tried to create.
397        new_ref: String,
398    },
399    /// A descendant ref already exists (e.g. `refs/foo/bar` blocks `refs/foo`).
400    DescendantExists {
401        /// Existing ref under `new_ref/`.
402        blocking: String,
403        /// Ref the caller tried to create.
404        new_ref: String,
405    },
406    /// Two refnames in the same batch are mutually incompatible (parent vs child).
407    SameBatch {
408        /// Ref being validated (Git prints this first).
409        refname: String,
410        /// Other ref in the batch (parent dirname or descendant name).
411        other: String,
412    },
413}
414
415impl RefnameUnavailable {
416    /// Suffix after `cannot lock ref '<display_ref>': ` for stderr (no trailing newline).
417    #[must_use]
418    pub fn lock_message_suffix(&self) -> String {
419        match self {
420            RefnameUnavailable::AncestorExists { blocking, new_ref } => {
421                format!("'{blocking}' exists; cannot create '{new_ref}'")
422            }
423            RefnameUnavailable::DescendantExists { blocking, new_ref } => {
424                format!("'{blocking}' exists; cannot create '{new_ref}'")
425            }
426            RefnameUnavailable::SameBatch { refname, other } => {
427                format!("cannot process '{refname}' and '{other}' at the same time")
428            }
429        }
430    }
431}
432
433fn find_descendant_in_sorted_extras(
434    dirname_with_slash: &str,
435    extras: &BTreeSet<String>,
436) -> Option<String> {
437    let start = extras
438        .range(dirname_with_slash.to_string()..)
439        .next()
440        .cloned()?;
441    if start.starts_with(dirname_with_slash) {
442        Some(start)
443    } else {
444        None
445    }
446}
447
448/// Verify that `refname` can be created without directory/file conflicts with the ref store
449/// and with other refnames queued in the same transaction (`extras`).
450///
451/// `skip` names are ignored when checking the filesystem (updates that delete or replace
452/// those refs in the same batch). Matches Git's `refs_verify_refname_available`.
453///
454/// # Parameters
455///
456/// - `git_dir` — repository git directory.
457/// - `refname` — full ref name to create.
458/// - `extras` — other refnames touched in the same stdin batch / transaction (sorted set).
459/// - `skip` — refnames that may be deleted or updated away in the same batch.
460pub fn verify_refname_available_for_create(
461    git_dir: &Path,
462    refname: &str,
463    extras: &BTreeSet<String>,
464    skip: &HashSet<String>,
465) -> std::result::Result<(), RefnameUnavailable> {
466    // `Repository::git_dir` may be a relative path (e.g. `.git`); resolve so lookups match the
467    // on-disk ref store regardless of process cwd (test harness runs from `trash/.git/...`).
468    let git_dir = fs::canonicalize(git_dir).unwrap_or_else(|_| git_dir.to_path_buf());
469    let mut seen_dirnames: HashSet<String> = HashSet::new();
470    let segments: Vec<&str> = refname.split('/').filter(|s| !s.is_empty()).collect();
471    if segments.len() <= 1 {
472        // No slash-separated parent prefixes (e.g. `HEAD`).
473    } else {
474        let mut dirname = String::new();
475        for part in &segments[..segments.len() - 1] {
476            if !dirname.is_empty() {
477                dirname.push('/');
478            }
479            dirname.push_str(part);
480
481            if !seen_dirnames.insert(dirname.clone()) {
482                continue;
483            }
484
485            if skip.contains(&dirname) {
486                continue;
487            }
488
489            match read_raw_ref(&git_dir, &dirname) {
490                Ok(RawRefLookup::Exists) => {
491                    return Err(RefnameUnavailable::AncestorExists {
492                        blocking: dirname.clone(),
493                        new_ref: refname.to_owned(),
494                    });
495                }
496                // A directory at `refs/prefix` is normal when storing `refs/prefix/child`; only a
497                // real ref (loose file or packed line) blocks creating `refs/prefix/...`.
498                Ok(RawRefLookup::NotFound | RawRefLookup::IsDirectory) => {}
499                Err(_) => {}
500            }
501
502            if extras.contains(&dirname) {
503                return Err(RefnameUnavailable::SameBatch {
504                    refname: refname.to_owned(),
505                    other: dirname.clone(),
506                });
507            }
508        }
509    }
510
511    let mut leaf_dir = String::with_capacity(refname.len() + 1);
512    leaf_dir.push_str(refname);
513    leaf_dir.push('/');
514
515    let under = list_refs(&git_dir, &leaf_dir).unwrap_or_default();
516    if under.is_empty() {
517        let packed_dir = common_dir(&git_dir).unwrap_or_else(|| git_dir.clone());
518        if let Ok(Some(name)) = packed_ref_with_prefix(&packed_dir, &leaf_dir) {
519            if !skip.contains(&name) {
520                return Err(RefnameUnavailable::DescendantExists {
521                    blocking: name,
522                    new_ref: refname.to_owned(),
523                });
524            }
525        }
526        if packed_dir != git_dir {
527            if let Ok(Some(name)) = packed_ref_with_prefix(&git_dir, &leaf_dir) {
528                if !skip.contains(&name) {
529                    return Err(RefnameUnavailable::DescendantExists {
530                        blocking: name,
531                        new_ref: refname.to_owned(),
532                    });
533                }
534            }
535        }
536    }
537    if under.is_empty()
538        && fs::symlink_metadata(git_dir.join(refname))
539            .map(|m| m.is_dir())
540            .unwrap_or(false)
541    {
542        let mut blocking: Option<String> = None;
543        let dir_path = git_dir.join(refname);
544        if let Ok(read) = fs::read_dir(&dir_path) {
545            for entry in read.flatten() {
546                let path = entry.path();
547                let Ok(meta) = fs::metadata(&path) else {
548                    continue;
549                };
550                if !meta.is_file() {
551                    continue;
552                }
553                let name = entry.file_name().to_string_lossy().into_owned();
554                let full = format!("{refname}/{name}");
555                blocking = Some(full);
556                break;
557            }
558        }
559        if let Some(b) = blocking {
560            if !skip.contains(&b) {
561                return Err(RefnameUnavailable::DescendantExists {
562                    blocking: b,
563                    new_ref: refname.to_owned(),
564                });
565            }
566        }
567    }
568
569    for (existing, _) in under {
570        if skip.contains(&existing) {
571            continue;
572        }
573        return Err(RefnameUnavailable::DescendantExists {
574            blocking: existing,
575            new_ref: refname.to_owned(),
576        });
577    }
578
579    if let Some(extra) = find_descendant_in_sorted_extras(&leaf_dir, extras) {
580        if !skip.contains(&extra) {
581            return Err(RefnameUnavailable::SameBatch {
582                refname: refname.to_owned(),
583                other: extra,
584            });
585        }
586    }
587
588    Ok(())
589}
590
591fn read_raw_ref_reftable(git_dir: &Path, refname: &str) -> Result<RawRefLookup> {
592    if refname == "HEAD" {
593        let head_path = git_dir.join("HEAD");
594        match fs::symlink_metadata(&head_path) {
595            Ok(meta) => {
596                if meta.is_dir() {
597                    return Ok(RawRefLookup::IsDirectory);
598                }
599                return Ok(RawRefLookup::Exists);
600            }
601            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(RawRefLookup::NotFound),
602            Err(e) => return Err(Error::Io(e)),
603        }
604    }
605
606    if let Some(lookup) = read_raw_ref_at(git_dir.join(refname))? {
607        return Ok(lookup);
608    }
609
610    let stack = crate::reftable::ReftableStack::open(git_dir)?;
611    match stack.lookup_ref(refname)? {
612        Some(rec) => match rec.value {
613            crate::reftable::RefValue::Deletion => Ok(RawRefLookup::NotFound),
614            _ => Ok(RawRefLookup::Exists),
615        },
616        None => Ok(RawRefLookup::NotFound),
617    }
618}
619
620/// Look up a refname in `packed-refs`.
621fn lookup_packed_ref(git_dir: &Path, refname: &str) -> Result<Option<ObjectId>> {
622    let packed = git_dir.join("packed-refs");
623    let content = match fs::read_to_string(&packed) {
624        Ok(c) => c,
625        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
626        Err(e) => return Err(Error::Io(e)),
627    };
628
629    for line in content.lines() {
630        if line.starts_with('#') || line.starts_with('^') {
631            continue;
632        }
633        let mut parts = line.splitn(2, ' ');
634        let hash = parts.next().unwrap_or("");
635        let name = parts.next().unwrap_or("").trim();
636        if name == refname && hash.len() == 40 {
637            let oid: ObjectId = hash.parse()?;
638            return Ok(Some(oid));
639        }
640    }
641    Ok(None)
642}
643
644/// Write a ref, creating parent directories as needed.
645///
646/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
647///
648/// # Parameters
649///
650/// - `git_dir` — path to the git directory.
651/// - `refname` — reference name (e.g. `"refs/heads/main"`).
652/// - `oid` — the new target object ID.
653///
654/// # Errors
655///
656/// Returns [`Error::Io`] on filesystem errors.
657/// Write a symbolic ref (e.g. `NOTES_MERGE_REF` → `refs/notes/m`).
658///
659/// For reftable-backed repositories this dispatches to the reftable writer.
660pub fn write_symbolic_ref(git_dir: &Path, refname: &str, target: &str) -> Result<()> {
661    if crate::reftable::is_reftable_repo(git_dir) {
662        return crate::reftable::reftable_write_symref(git_dir, refname, target, None, None);
663    }
664    let storage_dir = ref_storage_dir(git_dir, refname);
665    if packed_ref_namespace_conflict(&storage_dir, refname)? {
666        return Err(Error::InvalidRef(format!(
667            "cannot update ref '{refname}': reference namespace conflict"
668        )));
669    }
670    let stor = crate::ref_namespace::storage_ref_name(refname);
671    let path = storage_dir.join(stor);
672    if let Some(parent) = path.parent() {
673        fs::create_dir_all(parent)?;
674    }
675    let content = format!("ref: {target}\n");
676    let lock = lock_path_for_ref(&path);
677    fs::write(&lock, &content)?;
678    fs::rename(&lock, &path)?;
679    Ok(())
680}
681
682pub fn write_ref(git_dir: &Path, refname: &str, oid: &ObjectId) -> Result<()> {
683    if crate::reftable::is_reftable_repo(git_dir) {
684        return crate::reftable::reftable_write_ref(git_dir, refname, oid, None, None);
685    }
686    let storage_dir = ref_storage_dir(git_dir, refname);
687    if packed_ref_namespace_conflict(&storage_dir, refname)? {
688        return Err(Error::InvalidRef(format!(
689            "cannot update ref '{refname}': reference namespace conflict"
690        )));
691    }
692    let stor = crate::ref_namespace::storage_ref_name(refname);
693    let path = storage_dir.join(stor);
694    if let Some(parent) = path.parent() {
695        fs::create_dir_all(parent)?;
696    }
697    let content = format!("{oid}\n");
698    // Write via lock file for atomicity
699    let lock = lock_path_for_ref(&path);
700    fs::write(&lock, &content)?;
701    fs::rename(&lock, &path)?;
702    Ok(())
703}
704
705/// Delete a ref.
706///
707/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
708///
709/// # Errors
710///
711/// Returns [`Error::Io`] for errors other than "not found".
712pub fn delete_ref(git_dir: &Path, refname: &str) -> Result<()> {
713    if crate::reftable::is_reftable_repo(git_dir) {
714        return crate::reftable::reftable_delete_ref(git_dir, refname);
715    }
716    let storage_dir = ref_storage_dir(git_dir, refname);
717    let stor = crate::ref_namespace::storage_ref_name(refname);
718    // Remove the loose ref file
719    let path = storage_dir.join(&stor);
720    match fs::remove_file(&path) {
721        Ok(()) => {}
722        Err(e) if e.kind() == io::ErrorKind::NotFound => {}
723        Err(e) => return Err(Error::Io(e)),
724    }
725
726    // Also remove the entry from packed-refs if present
727    remove_packed_ref(&storage_dir, &stor)?;
728
729    let log_path = storage_dir.join("logs").join(&stor);
730
731    // Keep `logs/refs/heads/<name>` when deleting a branch so `branch -D` + later recreate can
732    // retain history (matches upstream expectations in t1507 `log -g` with `@{now}`).
733    if !refname.starts_with("refs/heads/") {
734        let _ = fs::remove_file(&log_path);
735
736        // Remove empty parent directories under `logs/refs/heads/` so a deleted nested ref
737        // does not leave `logs/refs/heads/d` as a directory (which would block reflogs for
738        // a later branch named `d`).
739        let logs_heads = storage_dir.join("logs/refs/heads");
740        let mut parent = log_path.parent();
741        while let Some(p) = parent {
742            if p == logs_heads.as_path() || !p.starts_with(&logs_heads) {
743                break;
744            }
745            if fs::remove_dir(p).is_err() {
746                break;
747            }
748            parent = p.parent();
749        }
750    }
751
752    Ok(())
753}
754
755/// Remove a single entry from the packed-refs file, rewriting it.
756fn remove_packed_ref(git_dir: &Path, refname: &str) -> Result<()> {
757    let packed_path = git_dir.join("packed-refs");
758    let content = match fs::read_to_string(&packed_path) {
759        Ok(c) => c,
760        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
761        Err(e) => return Err(Error::Io(e)),
762    };
763
764    let mut out = String::new();
765    let mut skip_peeled = false;
766    let mut changed = false;
767    // Write a fresh header (don't preserve old comment lines — real git
768    // regenerates the header on every rewrite).
769    let mut header_written = false;
770
771    for line in content.lines() {
772        if skip_peeled {
773            if line.starts_with('^') {
774                changed = true;
775                continue;
776            }
777            skip_peeled = false;
778        }
779
780        if line.starts_with('#') {
781            // Skip old header lines — we'll write a fresh one
782            continue;
783        }
784        if line.starts_with('^') {
785            out.push_str(line);
786            out.push('\n');
787            continue;
788        }
789
790        // Write fresh header before the first data line
791        if !header_written {
792            out.insert_str(0, "# pack-refs with: peeled fully-peeled sorted\n");
793            header_written = true;
794        }
795
796        // Check if this line matches the ref to remove
797        let mut parts = line.splitn(2, ' ');
798        let _hash = parts.next().unwrap_or("");
799        let name = parts.next().unwrap_or("").trim();
800        if name == refname {
801            changed = true;
802            skip_peeled = true;
803            continue;
804        }
805
806        out.push_str(line);
807        out.push('\n');
808    }
809
810    if changed {
811        // Match Git's packed-refs rewrite lock path (`packed-refs.new`).
812        // Tests expect prune/delete to fail when this lock already exists.
813        let lock = packed_path.with_extension("new");
814        let mut file = std::fs::OpenOptions::new()
815            .write(true)
816            .create_new(true)
817            .open(&lock)
818            .map_err(Error::Io)?;
819        use std::io::Write as _;
820        file.write_all(out.as_bytes()).map_err(Error::Io)?;
821        drop(file);
822        fs::rename(&lock, &packed_path).map_err(Error::Io)?;
823    }
824
825    Ok(())
826}
827
828/// Read the symbolic ref target of `HEAD`.
829///
830/// Returns `None` if HEAD is detached (points directly to a commit hash).
831///
832/// # Errors
833///
834/// Returns [`Error::Io`] or [`Error::InvalidRef`] on failures.
835pub fn read_head(git_dir: &Path) -> Result<Option<String>> {
836    match read_ref_file(&git_dir.join("HEAD"))? {
837        Ref::Symbolic(target) => Ok(Some(target)),
838        Ref::Direct(_) => Ok(None),
839    }
840}
841
842/// Read symbolic target of any ref.
843///
844/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
845///
846/// Returns `Ok(Some(target))` when `refname` exists and is symbolic,
847/// `Ok(None)` when it is direct or missing.
848pub fn read_symbolic_ref(git_dir: &Path, refname: &str) -> Result<Option<String>> {
849    if crate::reftable::is_reftable_repo(git_dir) {
850        return crate::reftable::reftable_read_symbolic_ref(git_dir, refname);
851    }
852    let storage_owned = crate::ref_namespace::storage_ref_name(refname);
853    let try_names: Vec<&str> =
854        if refname == "HEAD" && crate::ref_namespace::ref_storage_prefix().is_some() {
855            vec![storage_owned.as_str()]
856        } else if storage_owned != refname {
857            vec![storage_owned.as_str(), refname]
858        } else {
859            vec![refname]
860        };
861
862    for name in try_names {
863        let path = git_dir.join(name);
864        match read_ref_file(&path) {
865            Ok(Ref::Symbolic(target)) => return Ok(Some(target)),
866            Ok(Ref::Direct(_)) => return Ok(None),
867            Err(Error::Io(ref e))
868                if e.kind() == io::ErrorKind::NotFound
869                    || e.kind() == io::ErrorKind::NotADirectory
870                    || e.kind() == io::ErrorKind::IsADirectory => {}
871            Err(e) => return Err(e),
872        }
873
874        if !notes_merge_state_ref(name) {
875            if let Some(common) = common_dir(git_dir) {
876                if common != git_dir {
877                    let cpath = common.join(name);
878                    match read_ref_file(&cpath) {
879                        Ok(Ref::Symbolic(target)) => return Ok(Some(target)),
880                        Ok(Ref::Direct(_)) => return Ok(None),
881                        Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
882                        Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotADirectory => {}
883                        Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::IsADirectory => {}
884                        Err(e) => return Err(e),
885                    }
886                }
887            }
888        }
889    }
890
891    Ok(None)
892}
893
894/// Core `logAllRefUpdates` modes (after config lookup), matching Git's `log_refs_config`.
895#[derive(Clone, Copy, Debug, PartialEq, Eq)]
896pub enum LogRefsConfig {
897    /// `core.logAllRefUpdates` not set; resolved per-repo (bare vs non-bare).
898    Unset,
899    /// Explicitly disabled.
900    None,
901    /// `true` — log branch-like refs only (see [`should_autocreate_reflog`]).
902    Normal,
903    /// `always` — log updates to any ref.
904    Always,
905}
906
907/// Read `[core] logAllRefUpdates` from the repository config.
908///
909/// Returns [`LogRefsConfig::Unset`] when the key is absent.
910pub fn read_log_refs_config(git_dir: &Path) -> LogRefsConfig {
911    let config_dir = common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
912    let config_path = config_dir.join("config");
913    let content = match fs::read_to_string(config_path) {
914        Ok(c) => c,
915        Err(_) => return LogRefsConfig::Unset,
916    };
917
918    let mut in_core = false;
919    for line in content.lines() {
920        let trimmed = line.trim();
921        if trimmed.starts_with('[') {
922            in_core = trimmed.to_ascii_lowercase().starts_with("[core]");
923            continue;
924        }
925        if !in_core {
926            continue;
927        }
928        let Some((key, value)) = trimmed.split_once('=') else {
929            continue;
930        };
931        if !key.trim().eq_ignore_ascii_case("logallrefupdates") {
932            continue;
933        }
934        let v = value.trim();
935        let lower = v.to_ascii_lowercase();
936        return match lower.as_str() {
937            "always" => LogRefsConfig::Always,
938            "1" | "true" | "yes" | "on" => LogRefsConfig::Normal,
939            "0" | "false" | "no" | "off" | "never" => LogRefsConfig::None,
940            _ => LogRefsConfig::Unset,
941        };
942    }
943    LogRefsConfig::Unset
944}
945
946fn read_core_bare(git_dir: &Path) -> bool {
947    let config_dir = common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
948    let config_path = config_dir.join("config");
949    let Ok(content) = fs::read_to_string(config_path) else {
950        return false;
951    };
952    let mut in_core = false;
953    for line in content.lines() {
954        let trimmed = line.trim();
955        if trimmed.starts_with('[') {
956            in_core = trimmed.to_ascii_lowercase().starts_with("[core]");
957            continue;
958        }
959        if !in_core {
960            continue;
961        }
962        let Some((key, value)) = trimmed.split_once('=') else {
963            continue;
964        };
965        if key.trim().eq_ignore_ascii_case("bare") {
966            let v = value.trim().to_ascii_lowercase();
967            return matches!(v.as_str(), "1" | "true" | "yes" | "on");
968        }
969    }
970    false
971}
972
973/// Effective `logAllRefUpdates` after applying Git's `LOG_REFS_UNSET` rule.
974pub fn effective_log_refs_config(git_dir: &Path) -> LogRefsConfig {
975    match read_log_refs_config(git_dir) {
976        LogRefsConfig::Unset => {
977            if read_core_bare(git_dir) {
978                LogRefsConfig::None
979            } else {
980                LogRefsConfig::Normal
981            }
982        }
983        other => other,
984    }
985}
986
987/// Whether a new reflog file may be auto-created for `refname` given an already-resolved
988/// `core.logAllRefUpdates` mode (including command-line config).
989#[must_use]
990pub fn should_autocreate_reflog_for_mode(refname: &str, mode: LogRefsConfig) -> bool {
991    match mode {
992        LogRefsConfig::Always => true,
993        LogRefsConfig::Normal => {
994            refname == "HEAD"
995                || refname.starts_with("refs/heads/")
996                || refname.starts_with("refs/remotes/")
997                || refname.starts_with("refs/notes/")
998        }
999        LogRefsConfig::None | LogRefsConfig::Unset => false,
1000    }
1001}
1002
1003/// Whether a new reflog file may be auto-created for `refname` (Git `should_autocreate_reflog`).
1004#[must_use]
1005pub fn should_autocreate_reflog(git_dir: &Path, refname: &str) -> bool {
1006    should_autocreate_reflog_for_mode(refname, effective_log_refs_config(git_dir))
1007}
1008
1009/// Write a reflog entry.
1010///
1011/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
1012///
1013/// # Parameters
1014///
1015/// - `git_dir` — path to the git directory.
1016/// - `refname` — reference name (e.g. `"refs/heads/main"`).
1017/// - `old_oid` — previous OID (use `ObjectId::from_bytes(&[0;20])` for a new ref).
1018/// - `new_oid` — new OID.
1019/// - `identity` — `"Name <email> <timestamp> <tz>"` formatted string.
1020/// - `message` — short log message.
1021/// - `force_create` — if true, create the log file even when [`should_autocreate_reflog`] would not.
1022///
1023/// # Errors
1024///
1025/// Returns [`Error::Io`] on filesystem errors.
1026pub fn append_reflog(
1027    git_dir: &Path,
1028    refname: &str,
1029    old_oid: &ObjectId,
1030    new_oid: &ObjectId,
1031    identity: &str,
1032    message: &str,
1033    force_create: bool,
1034) -> Result<()> {
1035    if crate::reftable::is_reftable_repo(git_dir) {
1036        return crate::reftable::reftable_append_reflog(
1037            git_dir,
1038            refname,
1039            old_oid,
1040            new_oid,
1041            identity,
1042            message,
1043            force_create,
1044        );
1045    }
1046    let storage_dir = ref_storage_dir(git_dir, refname);
1047    let stor = crate::ref_namespace::storage_ref_name(refname);
1048    let log_path = storage_dir.join("logs").join(&stor);
1049    let may_write =
1050        force_create || should_autocreate_reflog(git_dir, refname) || !message.is_empty();
1051    if !may_write && !log_path.exists() {
1052        return Ok(());
1053    }
1054    if let Some(parent) = log_path.parent() {
1055        fs::create_dir_all(parent)?;
1056    }
1057    let line = if message.is_empty() {
1058        format!("{old_oid} {new_oid} {identity}\n")
1059    } else {
1060        format!("{old_oid} {new_oid} {identity}\t{message}\n")
1061    };
1062    let mut file = fs::OpenOptions::new()
1063        .create(true)
1064        .append(true)
1065        .open(&log_path)?;
1066    use io::Write;
1067    file.write_all(line.as_bytes())?;
1068    Ok(())
1069}
1070
1071/// Filesystem path to the reflog file for `refname` (same layout as [`append_reflog`]).
1072///
1073/// Branch and tag reflogs live under the shared [`common_dir`] when the repository uses a
1074/// `commondir` link (linked worktrees / `git clone --shared` member repos); `HEAD` stays under
1075/// `git_dir`.
1076#[must_use]
1077pub fn reflog_file_path(git_dir: &Path, refname: &str) -> PathBuf {
1078    ref_storage_dir(git_dir, refname).join("logs").join(refname)
1079}
1080
1081fn ref_storage_dir(git_dir: &Path, refname: &str) -> PathBuf {
1082    // Per-worktree refs live under this worktree's git dir; shared refs (including
1083    // `refs/bisect/*`) live in the common repository directory so all worktrees
1084    // see the same bisection state.
1085    if refname == "HEAD" || refname == "NOTES_MERGE_PARTIAL" || refname == "NOTES_MERGE_REF" {
1086        return git_dir.to_path_buf();
1087    }
1088    // `refs/worktree/*` is private to each linked checkout (Git: `files_ref_store` per worktree).
1089    if refname.starts_with("refs/worktree/") {
1090        return git_dir.to_path_buf();
1091    }
1092    common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf())
1093}
1094
1095/// Normalize a ref prefix for filesystem traversal and packed-ref filtering.
1096///
1097/// Loose refs live in a directory tree mirroring ref names. A prefix like
1098/// `refs/remotes/origin` (no trailing slash) must map to the `origin/` directory
1099/// under `refs/remotes/`, not to a sibling file named `origin`. When the prefix
1100/// already names a **single loose ref file** (e.g. `refs/heads/main`), keep it
1101/// without a trailing slash so we read that file instead of a non-existent
1102/// directory.
1103fn normalize_list_refs_prefix(git_dir: &Path, prefix: &str) -> String {
1104    if prefix.is_empty() {
1105        return String::new();
1106    }
1107    if prefix.ends_with('/') {
1108        return prefix.to_string();
1109    }
1110    let candidate = ref_storage_dir(git_dir, prefix).join(prefix);
1111    if candidate.is_file() {
1112        prefix.to_string()
1113    } else {
1114        format!("{prefix}/")
1115    }
1116}
1117
1118/// List all refs under a given prefix (e.g. `"refs/heads/"`).
1119///
1120/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
1121///
1122/// Returns a sorted list of `(refname, ObjectId)` pairs.
1123///
1124/// # Errors
1125///
1126/// Returns [`Error::Io`] on directory traversal errors.
1127pub fn list_refs(git_dir: &Path, prefix: &str) -> Result<Vec<(String, ObjectId)>> {
1128    let prefix_norm = normalize_list_refs_prefix(git_dir, prefix);
1129    let prefix = prefix_norm.as_str();
1130    if crate::reftable::is_reftable_repo(git_dir) {
1131        return crate::reftable::reftable_list_refs(git_dir, prefix);
1132    }
1133    // Merge packed + loose so **loose always wins** for the same ref name (matches Git and
1134    // `resolve_ref`). Previously we concatenated packed then loose and never deduplicated the
1135    // main git dir case, so `pack-refs` could leave stale packed lines that shadowed updates.
1136    let mut by_name: HashMap<String, ObjectId> = HashMap::new();
1137
1138    let stored_prefixes: Vec<String> = if let Some(ns) = crate::ref_namespace::ref_storage_prefix()
1139    {
1140        if prefix.starts_with("refs/namespaces/") {
1141            vec![prefix.to_owned()]
1142        } else if prefix.starts_with("refs/") {
1143            vec![format!("{ns}{prefix}")]
1144        } else {
1145            vec![prefix.to_owned()]
1146        }
1147    } else {
1148        vec![prefix.to_owned()]
1149    };
1150
1151    for stored_prefix in stored_prefixes {
1152        if let Some(cdir) = common_dir(git_dir) {
1153            if cdir != git_dir {
1154                collect_packed_refs_into_map(&cdir, &stored_prefix, false, &mut by_name)?;
1155                let cbase = cdir.join(&stored_prefix);
1156                collect_loose_refs_into_map(&cbase, &stored_prefix, &cdir, false, &mut by_name)?;
1157            }
1158        }
1159
1160        collect_packed_refs_into_map(git_dir, &stored_prefix, false, &mut by_name)?;
1161        let base = git_dir.join(&stored_prefix);
1162        collect_loose_refs_into_map(&base, &stored_prefix, git_dir, false, &mut by_name)?;
1163    }
1164
1165    let mut results: Vec<(String, ObjectId)> = by_name.into_iter().collect();
1166    results.sort_by(|a, b| a.0.cmp(&b.0));
1167    Ok(results)
1168}
1169
1170/// List refs under `prefix` using **literal** on-disk paths (ignores `GIT_NAMESPACE`).
1171///
1172/// Used by `receive-pack` when advertising: the server must see every physical ref so refs outside
1173/// the active namespace can be offered as `.have` lines (matches Git `show_ref_cb`).
1174pub fn list_refs_physical(git_dir: &Path, prefix: &str) -> Result<Vec<(String, ObjectId)>> {
1175    if crate::reftable::is_reftable_repo(git_dir) {
1176        return crate::reftable::reftable_list_refs(git_dir, prefix);
1177    }
1178    let mut by_name: HashMap<String, ObjectId> = HashMap::new();
1179    let stored_prefix = prefix.to_owned();
1180
1181    if let Some(cdir) = common_dir(git_dir) {
1182        if cdir != git_dir {
1183            collect_packed_refs_into_map(&cdir, &stored_prefix, true, &mut by_name)?;
1184            let cbase = cdir.join(&stored_prefix);
1185            collect_loose_refs_into_map(&cbase, &stored_prefix, &cdir, true, &mut by_name)?;
1186        }
1187    }
1188
1189    collect_packed_refs_into_map(git_dir, &stored_prefix, true, &mut by_name)?;
1190    let base = git_dir.join(&stored_prefix);
1191    collect_loose_refs_into_map(&base, &stored_prefix, git_dir, true, &mut by_name)?;
1192
1193    let mut results: Vec<(String, ObjectId)> = by_name.into_iter().collect();
1194    results.sort_by(|a, b| a.0.cmp(&b.0));
1195    Ok(results)
1196}
1197
1198/// Collect commit OIDs from alternate repositories' refs, matching Git's
1199/// `git for-each-ref --format=%(objectname)` on each alternate (with optional
1200/// `core.alternateRefsPrefixes` arguments).
1201///
1202/// Order is preserved: alternates file order, then ref iteration order from
1203/// [`list_refs`] under each configured prefix (or all of `refs/` when no
1204/// prefixes are set). Duplicate OIDs are skipped while preserving first-seen
1205/// order.
1206pub fn collect_alternate_ref_oids(receiving_git_dir: &Path) -> Result<Vec<ObjectId>> {
1207    let config = ConfigSet::load(Some(receiving_git_dir), true)?;
1208    let objects_dir = receiving_git_dir.join("objects");
1209    let alternates = pack::read_alternates_recursive(&objects_dir).unwrap_or_default();
1210    let mut out = Vec::new();
1211    let mut seen = std::collections::HashSet::new();
1212    for alt_objects in alternates {
1213        let Some(alt_git_dir) = alt_objects.parent().map(PathBuf::from) else {
1214            continue;
1215        };
1216        if !alt_git_dir.join("refs").is_dir() {
1217            continue;
1218        }
1219        if let Some(prefixes) = config.get("core.alternateRefsPrefixes") {
1220            for part in prefixes.split_whitespace() {
1221                for (_, oid) in list_refs(&alt_git_dir, part)? {
1222                    if seen.insert(oid) {
1223                        out.push(oid);
1224                    }
1225                }
1226            }
1227        } else {
1228            for (_, oid) in list_refs(&alt_git_dir, "refs/")? {
1229                if seen.insert(oid) {
1230                    out.push(oid);
1231                }
1232            }
1233        }
1234    }
1235    Ok(out)
1236}
1237
1238/// List refs matching a glob pattern (e.g. `refs/heads/topic/*`).
1239pub fn list_refs_glob(git_dir: &Path, pattern: &str) -> Result<Vec<(String, ObjectId)>> {
1240    let glob_pos = pattern.find(['*', '?', '[']);
1241    let prefix_owned: String = match glob_pos {
1242        Some(pos) => match pattern[..pos].rfind('/') {
1243            Some(slash) => pattern[..=slash].to_owned(),
1244            None => String::new(),
1245        },
1246        None => {
1247            let mut p = pattern.trim_end_matches('/').to_owned();
1248            if !p.is_empty() {
1249                p.push('/');
1250            }
1251            p
1252        }
1253    };
1254    let prefix = prefix_owned.as_str();
1255    let all = list_refs(git_dir, prefix)?;
1256    let mut results = Vec::new();
1257    for (refname, oid) in all {
1258        if ref_matches_glob(&refname, pattern) {
1259            results.push((refname, oid));
1260        }
1261    }
1262    Ok(results)
1263}
1264
1265/// Check whether a ref name matches a glob pattern.
1266///
1267/// Supports `*`, `?`, and `[…]` wildcards. An exact string match is also accepted.
1268pub fn ref_matches_glob(refname: &str, pattern: &str) -> bool {
1269    // For exact matches (no glob characters), check suffix match
1270    if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('[') {
1271        return refname == pattern
1272            || refname.ends_with(&format!("/{pattern}"))
1273            || refname.starts_with(&format!("{pattern}/"));
1274    }
1275    glob_match(pattern, refname)
1276}
1277
1278fn glob_match(pattern: &str, text: &str) -> bool {
1279    let pat = pattern.as_bytes();
1280    let txt = text.as_bytes();
1281    let (mut pi, mut ti) = (0, 0);
1282    let (mut star_pi, mut star_ti) = (usize::MAX, 0);
1283    while ti < txt.len() {
1284        if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == txt[ti]) {
1285            pi += 1;
1286            ti += 1;
1287        } else if pi < pat.len() && pat[pi] == b'*' {
1288            star_pi = pi;
1289            star_ti = ti;
1290            pi += 1;
1291        } else if star_pi != usize::MAX {
1292            pi = star_pi + 1;
1293            star_ti += 1;
1294            ti = star_ti;
1295        } else {
1296            return false;
1297        }
1298    }
1299    while pi < pat.len() && pat[pi] == b'*' {
1300        pi += 1;
1301    }
1302    pi == pat.len()
1303}
1304
1305/// OID stored directly in a loose ref file (40 hex), ignoring symbolic targets.
1306fn loose_ref_file_direct_oid(path: &Path) -> Option<ObjectId> {
1307    let content = fs::read_to_string(path).ok()?;
1308    let content = content.trim_end_matches('\n').trim();
1309    if content.len() == 40 && content.chars().all(|c| c.is_ascii_hexdigit()) {
1310        content.parse().ok()
1311    } else {
1312        None
1313    }
1314}
1315
1316fn collect_loose_refs_into_map(
1317    dir: &Path,
1318    prefix: &str,
1319    resolve_git_dir: &Path,
1320    physical_keys: bool,
1321    out: &mut HashMap<String, ObjectId>,
1322) -> Result<()> {
1323    let read = match fs::read_dir(dir) {
1324        Ok(r) => r,
1325        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1326        Err(e) => return Err(Error::Io(e)),
1327    };
1328
1329    for entry in read {
1330        let entry = entry?;
1331        let name = entry.file_name();
1332        let name_str = name.to_string_lossy();
1333        let refname = format!("{prefix}{name_str}");
1334        let path = entry.path();
1335        let meta = match fs::metadata(&path) {
1336            Ok(m) => m,
1337            Err(_) => continue,
1338        };
1339
1340        if meta.is_dir() {
1341            collect_loose_refs_into_map(
1342                &path,
1343                &format!("{refname}/"),
1344                resolve_git_dir,
1345                physical_keys,
1346                out,
1347            )?;
1348        } else if meta.is_file() {
1349            if physical_keys {
1350                if let Some(oid) = loose_ref_file_direct_oid(&path) {
1351                    out.insert(refname, oid);
1352                } else if let Ok(Ref::Symbolic(target)) = read_ref_file(&path) {
1353                    if let Ok(oid) = resolve_ref(resolve_git_dir, target.trim()) {
1354                        out.insert(refname, oid);
1355                    }
1356                }
1357            } else {
1358                let logical = crate::ref_namespace::logical_ref_name_from_storage(&refname)
1359                    .unwrap_or_else(|| refname.clone());
1360                if let Ok(oid) = resolve_ref(resolve_git_dir, &logical) {
1361                    out.insert(logical, oid);
1362                }
1363            }
1364        }
1365    }
1366    Ok(())
1367}
1368
1369/// Resolve `@{-N}` syntax to the branch name (not an OID).
1370/// Returns the branch name of the Nth previously checked out branch.
1371pub fn resolve_at_n_branch(git_dir: &Path, spec: &str) -> Result<String> {
1372    // Parse the N from @{-N}
1373    let inner = spec
1374        .strip_prefix("@{-")
1375        .and_then(|s| s.strip_suffix('}'))
1376        .ok_or_else(|| Error::InvalidRef(format!("not an @{{-N}} ref: {spec}")))?;
1377    let n: usize = inner
1378        .parse()
1379        .map_err(|_| Error::InvalidRef(format!("invalid N in {spec}")))?;
1380    if n == 0 {
1381        return Err(Error::InvalidRef("@{-0} is not valid".to_string()));
1382    }
1383    let entries = crate::reflog::read_reflog(git_dir, "HEAD")?;
1384    let mut count = 0usize;
1385    for entry in entries.iter().rev() {
1386        let msg = &entry.message;
1387        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
1388            count += 1;
1389            if count == n {
1390                if let Some(to_pos) = rest.find(" to ") {
1391                    return Ok(rest[..to_pos].to_string());
1392                }
1393            }
1394        }
1395    }
1396    Err(Error::InvalidRef(format!(
1397        "{spec}: only {count} checkout(s) in reflog"
1398    )))
1399}
1400
1401fn ref_name_matches_list_prefix(refname: &str, prefix: &str) -> bool {
1402    if refname.starts_with(prefix) {
1403        return true;
1404    }
1405    if prefix.ends_with('/') {
1406        let trimmed = prefix.trim_end_matches('/');
1407        if refname == trimmed {
1408            return true;
1409        }
1410    }
1411    false
1412}
1413
1414fn collect_packed_refs_into_map(
1415    git_dir: &Path,
1416    prefix: &str,
1417    physical_keys: bool,
1418    out: &mut HashMap<String, ObjectId>,
1419) -> Result<()> {
1420    let packed_path = git_dir.join("packed-refs");
1421    let content = match fs::read_to_string(&packed_path) {
1422        Ok(c) => c,
1423        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
1424        Err(e) => return Err(Error::Io(e)),
1425    };
1426
1427    for line in content.lines() {
1428        if line.starts_with('#') || line.starts_with('^') || line.is_empty() {
1429            continue;
1430        }
1431        let mut parts = line.splitn(2, ' ');
1432        let hash = parts.next().unwrap_or("");
1433        let refname = parts.next().unwrap_or("").trim();
1434        if !ref_name_matches_list_prefix(refname, prefix) || hash.len() != 40 {
1435            continue;
1436        }
1437        let oid: ObjectId = hash.parse()?;
1438        let key = if physical_keys {
1439            refname.to_owned()
1440        } else {
1441            crate::ref_namespace::logical_ref_name_from_storage(refname)
1442                .unwrap_or_else(|| refname.to_owned())
1443        };
1444        out.insert(key, oid);
1445    }
1446    Ok(())
1447}
1448
1449#[cfg(test)]
1450mod refname_available_tests {
1451    use super::*;
1452    use std::collections::{BTreeSet, HashSet};
1453    use tempfile::tempdir;
1454
1455    #[test]
1456    fn loose_parent_blocks_child_create() {
1457        let dir = tempdir().unwrap();
1458        let git_dir = dir.path();
1459        fs::create_dir_all(git_dir.join("refs/1l")).unwrap();
1460        fs::write(
1461            git_dir.join("refs/1l/c"),
1462            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
1463        )
1464        .unwrap();
1465        assert_eq!(
1466            read_raw_ref(git_dir, "refs/1l/c").unwrap(),
1467            RawRefLookup::Exists
1468        );
1469        let extras = BTreeSet::from([
1470            "refs/1l/b".to_string(),
1471            "refs/1l/c/x".to_string(),
1472            "refs/1l/d".to_string(),
1473        ]);
1474        let skip = HashSet::new();
1475        let err = verify_refname_available_for_create(git_dir, "refs/1l/c/x", &extras, &skip)
1476            .unwrap_err();
1477        assert!(matches!(
1478            err,
1479            RefnameUnavailable::AncestorExists { ref blocking, .. } if blocking == "refs/1l/c"
1480        ));
1481    }
1482
1483    #[test]
1484    fn verify_sees_loose_ref_after_canonical_git_dir() {
1485        let dir = tempdir().unwrap();
1486        let git_dir = dir.path().join(".git");
1487        fs::create_dir_all(git_dir.join("refs/1l")).unwrap();
1488        fs::write(
1489            git_dir.join("refs/1l/c"),
1490            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
1491        )
1492        .unwrap();
1493        let skip = HashSet::new();
1494        let extras = BTreeSet::new();
1495        let err = verify_refname_available_for_create(&git_dir, "refs/1l/c/x", &extras, &skip)
1496            .unwrap_err();
1497        assert!(matches!(
1498            err,
1499            RefnameUnavailable::AncestorExists { ref blocking, .. } if blocking == "refs/1l/c"
1500        ));
1501    }
1502
1503    #[test]
1504    fn list_refs_finds_sibling_under_parent_directory() {
1505        let dir = tempdir().unwrap();
1506        let git_dir = dir.path();
1507        fs::create_dir_all(git_dir.join("refs/ns/p")).unwrap();
1508        fs::write(
1509            git_dir.join("refs/ns/p/x"),
1510            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
1511        )
1512        .unwrap();
1513        let listed = list_refs(git_dir, "refs/ns/p/").unwrap();
1514        assert!(
1515            listed.iter().any(|(n, _)| n == "refs/ns/p/x"),
1516            "got {listed:?}"
1517        );
1518    }
1519
1520    #[test]
1521    fn verify_blocks_parent_when_child_ref_exists() {
1522        let dir = tempdir().unwrap();
1523        let git_dir = dir.path();
1524        fs::create_dir_all(git_dir.join("refs/ns/p")).unwrap();
1525        fs::write(
1526            git_dir.join("refs/ns/p/x"),
1527            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
1528        )
1529        .unwrap();
1530        let extras = BTreeSet::from(["refs/ns/p".to_string()]);
1531        let skip = HashSet::new();
1532        let err =
1533            verify_refname_available_for_create(git_dir, "refs/ns/p", &extras, &skip).unwrap_err();
1534        assert!(matches!(
1535            err,
1536            RefnameUnavailable::DescendantExists { ref blocking, .. }
1537                if blocking == "refs/ns/p/x"
1538        ));
1539    }
1540
1541    #[test]
1542    fn verify_blocks_parent_git_style_nested_path() {
1543        let dir = tempdir().unwrap();
1544        let git_dir = dir.path();
1545        fs::create_dir_all(git_dir.join("refs/3l/c")).unwrap();
1546        fs::write(
1547            git_dir.join("refs/3l/c/x"),
1548            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
1549        )
1550        .unwrap();
1551        let extras = BTreeSet::from(["refs/3l/c".to_string()]);
1552        let skip = HashSet::new();
1553        let err =
1554            verify_refname_available_for_create(git_dir, "refs/3l/c", &extras, &skip).unwrap_err();
1555        assert!(matches!(
1556            err,
1557            RefnameUnavailable::DescendantExists { ref blocking, .. }
1558                if blocking == "refs/3l/c/x"
1559        ));
1560    }
1561
1562    #[test]
1563    fn intermediate_directory_does_not_block_nested_create() {
1564        let dir = tempdir().unwrap();
1565        let git_dir = dir.path();
1566        fs::create_dir_all(git_dir.join("refs/ns")).unwrap();
1567        fs::write(
1568            git_dir.join("refs/ns/existing"),
1569            "67bf698f3ab735e92fb011a99cff3497c44d30c1\n",
1570        )
1571        .unwrap();
1572        assert_eq!(
1573            read_raw_ref(git_dir, "refs/ns").unwrap(),
1574            RawRefLookup::IsDirectory
1575        );
1576        let extras = BTreeSet::from(["refs/ns/newchild".to_string()]);
1577        let skip = HashSet::new();
1578        verify_refname_available_for_create(git_dir, "refs/ns/newchild", &extras, &skip).unwrap();
1579    }
1580}
1581
1582#[cfg(test)]
1583mod read_raw_ref_tests {
1584    use super::*;
1585    use tempfile::tempdir;
1586
1587    #[test]
1588    fn loose_ref_file_is_exists() {
1589        let dir = tempdir().unwrap();
1590        let git_dir = dir.path();
1591        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
1592        fs::write(
1593            git_dir.join("refs/heads/side"),
1594            "0000000000000000000000000000000000000000\n",
1595        )
1596        .unwrap();
1597        assert_eq!(
1598            read_raw_ref(git_dir, "refs/heads/side").unwrap(),
1599            RawRefLookup::Exists
1600        );
1601    }
1602
1603    #[test]
1604    fn missing_ref_is_not_found() {
1605        let dir = tempdir().unwrap();
1606        let git_dir = dir.path();
1607        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
1608        assert_eq!(
1609            read_raw_ref(git_dir, "refs/heads/nope").unwrap(),
1610            RawRefLookup::NotFound
1611        );
1612    }
1613
1614    #[test]
1615    fn directory_where_ref_expected_is_is_directory() {
1616        let dir = tempdir().unwrap();
1617        let git_dir = dir.path();
1618        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
1619        assert_eq!(
1620            read_raw_ref(git_dir, "refs/heads").unwrap(),
1621            RawRefLookup::IsDirectory
1622        );
1623    }
1624
1625    #[test]
1626    fn packed_ref_name_is_exists() {
1627        let dir = tempdir().unwrap();
1628        let git_dir = dir.path();
1629        fs::write(
1630            git_dir.join("packed-refs"),
1631            "# pack-refs with: peeled fully-peeled \n\
1632             0000000000000000000000000000000000000000 refs/heads/packed\n",
1633        )
1634        .unwrap();
1635        assert_eq!(
1636            read_raw_ref(git_dir, "refs/heads/packed").unwrap(),
1637            RawRefLookup::Exists
1638        );
1639    }
1640}