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::fs;
17use std::io;
18use std::path::{Path, PathBuf};
19
20use crate::error::{Error, Result};
21use crate::objects::ObjectId;
22
23/// A symbolic or direct reference.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Ref {
26    /// Direct reference: stores an [`ObjectId`].
27    Direct(ObjectId),
28    /// Symbolic reference: stores the name of the target ref.
29    Symbolic(String),
30}
31
32/// Read a single reference file from `path`.
33///
34/// # Errors
35///
36/// - [`Error::InvalidRef`] if the file content is not a valid ref.
37/// - [`Error::Io`] on filesystem errors.
38pub fn read_ref_file(path: &Path) -> Result<Ref> {
39    let content = fs::read_to_string(path).map_err(Error::Io)?;
40    let content = content.trim_end_matches('\n');
41    parse_ref_content(content)
42}
43
44/// Parse the content of a ref file (without trailing newline).
45pub(crate) fn parse_ref_content(content: &str) -> Result<Ref> {
46    if let Some(target) = content.strip_prefix("ref: ") {
47        Ok(Ref::Symbolic(target.trim().to_owned()))
48    } else if content.len() == 40 && content.chars().all(|c| c.is_ascii_hexdigit()) {
49        let oid: ObjectId = content.parse()?;
50        Ok(Ref::Direct(oid))
51    } else {
52        Err(Error::InvalidRef(content.to_owned()))
53    }
54}
55
56/// Resolve a reference to its target [`ObjectId`], following symbolic refs.
57///
58/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
59///
60/// # Parameters
61///
62/// - `git_dir` — path to the git directory.
63/// - `refname` — reference name (e.g. `"HEAD"`, `"refs/heads/main"`).
64///
65/// # Errors
66///
67/// - [`Error::InvalidRef`] if the ref is malformed or forms a cycle.
68/// - [`Error::ObjectNotFound`] if a symbolic target does not exist.
69pub fn resolve_ref(git_dir: &Path, refname: &str) -> Result<ObjectId> {
70    if crate::reftable::is_reftable_repo(git_dir) {
71        return crate::reftable::reftable_resolve_ref(git_dir, refname);
72    }
73    let common = common_dir(git_dir);
74    resolve_ref_depth(git_dir, common.as_deref(), refname, 0)
75}
76
77/// Determine the common git directory for worktree-aware ref resolution.
78///
79/// If `<git_dir>/commondir` exists, its contents point to the shared
80/// git directory. Returns `None` when git_dir is already the common dir.
81pub fn common_dir(git_dir: &Path) -> Option<PathBuf> {
82    let commondir_file = git_dir.join("commondir");
83    let raw = fs::read_to_string(commondir_file).ok()?;
84    let rel = raw.trim();
85    // Match Git: `commondir` may be relative to this gitdir or an absolute path (see
86    // `git worktree add` and `refs/files-backend.c`).
87    let path = if Path::new(rel).is_absolute() {
88        PathBuf::from(rel)
89    } else {
90        git_dir.join(rel)
91    };
92    path.canonicalize().ok()
93}
94
95fn notes_merge_state_ref(refname: &str) -> bool {
96    matches!(refname, "NOTES_MERGE_REF" | "NOTES_MERGE_PARTIAL")
97}
98
99/// Internal recursive resolver with cycle detection.
100///
101/// When operating inside a worktree, `common` points to the shared git
102/// directory where most refs live.  The worktree-specific `git_dir` is
103/// checked first for HEAD and per-worktree refs.
104fn resolve_ref_depth(
105    git_dir: &Path,
106    common: Option<&Path>,
107    refname: &str,
108    depth: usize,
109) -> Result<ObjectId> {
110    if depth > 10 {
111        return Err(Error::InvalidRef(format!(
112            "ref symlink too deep: {refname}"
113        )));
114    }
115
116    // First try as a loose ref file in git_dir
117    let path = git_dir.join(refname);
118    match read_ref_file(&path) {
119        Ok(Ref::Direct(oid)) => return Ok(oid),
120        Ok(Ref::Symbolic(target)) => {
121            return resolve_ref_depth(git_dir, common, &target, depth + 1);
122        }
123        Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
124        Err(e) => return Err(e),
125    }
126
127    // For worktrees, try the common dir for shared refs
128    if let Some(cdir) = common {
129        if notes_merge_state_ref(refname) {
130            // These live only under this worktree's gitdir (see Git `is_root_ref` / per-worktree stores).
131        } else if cdir != git_dir {
132            let cpath = cdir.join(refname);
133            match read_ref_file(&cpath) {
134                Ok(Ref::Direct(oid)) => return Ok(oid),
135                Ok(Ref::Symbolic(target)) => {
136                    return resolve_ref_depth(git_dir, common, &target, depth + 1);
137                }
138                Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
139                Err(e) => return Err(e),
140            }
141        }
142    }
143
144    // Fall back to packed-refs (in common dir if available)
145    let packed_dir = common.unwrap_or(git_dir);
146    if let Some(oid) = lookup_packed_ref(packed_dir, refname)? {
147        return Ok(oid);
148    }
149    // Also check git_dir packed-refs if different from common
150    if common.is_some() && common != Some(git_dir) {
151        if let Some(oid) = lookup_packed_ref(git_dir, refname)? {
152            return Ok(oid);
153        }
154    }
155
156    Err(Error::InvalidRef(format!("ref not found: {refname}")))
157}
158
159/// Outcome of a single storage-level ref lookup (Git `refs_read_raw_ref` style).
160///
161/// This checks whether a ref **name** exists in the ref store without applying
162/// DWIM rules. A symbolic ref is considered to exist if its ref file (or
163/// reftable record) is present, even when the target is missing.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum RawRefLookup {
166    /// A loose ref file, packed ref line, or reftable record exists for this name.
167    Exists,
168    /// No ref is recorded under this exact name.
169    NotFound,
170    /// A path component exists as a directory where a ref file was expected (e.g. `refs/heads`).
171    IsDirectory,
172}
173
174/// Return whether `refname` exists as a ref in the repository's ref storage.
175///
176/// This matches `git refs exists` / `git show-ref --exists`: no DWIM, no
177/// resolution of symbolic targets. Dispatches to the reftable backend when
178/// configured.
179///
180/// # Parameters
181///
182/// - `git_dir` — path to the git directory (worktree gitdir or bare `.git`).
183/// - `refname` — full ref name (e.g. `HEAD`, `refs/heads/main`, `CHERRY_PICK_HEAD`).
184///
185/// # Errors
186///
187/// Propagates I/O and reftable errors other than "not found".
188pub fn read_raw_ref(git_dir: &Path, refname: &str) -> Result<RawRefLookup> {
189    if crate::reftable::is_reftable_repo(git_dir) {
190        read_raw_ref_reftable(git_dir, refname)
191    } else {
192        read_raw_ref_files(git_dir, refname)
193    }
194}
195
196fn read_raw_ref_files(git_dir: &Path, refname: &str) -> Result<RawRefLookup> {
197    let common = common_dir(git_dir);
198
199    if let Some(lookup) = read_raw_ref_at(git_dir.join(refname))? {
200        return Ok(lookup);
201    }
202
203    if let Some(cdir) = common.as_ref() {
204        if *cdir != git_dir && !notes_merge_state_ref(refname) {
205            if let Some(lookup) = read_raw_ref_at(cdir.join(refname))? {
206                return Ok(lookup);
207            }
208        }
209    }
210
211    let packed_dir = common.as_deref().unwrap_or(git_dir);
212    if packed_ref_name_exists(packed_dir, refname)? {
213        return Ok(RawRefLookup::Exists);
214    }
215    if common.is_some()
216        && common.as_deref() != Some(git_dir)
217        && packed_ref_name_exists(git_dir, refname)?
218    {
219        return Ok(RawRefLookup::Exists);
220    }
221
222    Ok(RawRefLookup::NotFound)
223}
224
225fn read_raw_ref_at(path: PathBuf) -> Result<Option<RawRefLookup>> {
226    match fs::symlink_metadata(&path) {
227        Ok(meta) => {
228            if meta.is_dir() {
229                return Ok(Some(RawRefLookup::IsDirectory));
230            }
231            Ok(Some(RawRefLookup::Exists))
232        }
233        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
234        Err(e) => Err(Error::Io(e)),
235    }
236}
237
238fn packed_ref_name_exists(git_dir: &Path, refname: &str) -> Result<bool> {
239    let packed = git_dir.join("packed-refs");
240    let content = match fs::read_to_string(&packed) {
241        Ok(c) => c,
242        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(false),
243        Err(e) => return Err(Error::Io(e)),
244    };
245    for line in content.lines() {
246        if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
247            continue;
248        }
249        let mut parts = line.split_whitespace();
250        let _oid = parts.next();
251        if let Some(name) = parts.next() {
252            if name == refname {
253                return Ok(true);
254            }
255        }
256    }
257    Ok(false)
258}
259
260fn read_raw_ref_reftable(git_dir: &Path, refname: &str) -> Result<RawRefLookup> {
261    if refname == "HEAD" {
262        let head_path = git_dir.join("HEAD");
263        match fs::symlink_metadata(&head_path) {
264            Ok(meta) => {
265                if meta.is_dir() {
266                    return Ok(RawRefLookup::IsDirectory);
267                }
268                return Ok(RawRefLookup::Exists);
269            }
270            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(RawRefLookup::NotFound),
271            Err(e) => return Err(Error::Io(e)),
272        }
273    }
274
275    if let Some(lookup) = read_raw_ref_at(git_dir.join(refname))? {
276        return Ok(lookup);
277    }
278
279    let stack = crate::reftable::ReftableStack::open(git_dir)?;
280    match stack.lookup_ref(refname)? {
281        Some(rec) => match rec.value {
282            crate::reftable::RefValue::Deletion => Ok(RawRefLookup::NotFound),
283            _ => Ok(RawRefLookup::Exists),
284        },
285        None => Ok(RawRefLookup::NotFound),
286    }
287}
288
289/// Look up a refname in `packed-refs`.
290fn lookup_packed_ref(git_dir: &Path, refname: &str) -> Result<Option<ObjectId>> {
291    let packed = git_dir.join("packed-refs");
292    let content = match fs::read_to_string(&packed) {
293        Ok(c) => c,
294        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
295        Err(e) => return Err(Error::Io(e)),
296    };
297
298    for line in content.lines() {
299        if line.starts_with('#') || line.starts_with('^') {
300            continue;
301        }
302        let mut parts = line.splitn(2, ' ');
303        let hash = parts.next().unwrap_or("");
304        let name = parts.next().unwrap_or("").trim();
305        if name == refname && hash.len() == 40 {
306            let oid: ObjectId = hash.parse()?;
307            return Ok(Some(oid));
308        }
309    }
310    Ok(None)
311}
312
313/// Write a ref, creating parent directories as needed.
314///
315/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
316///
317/// # Parameters
318///
319/// - `git_dir` — path to the git directory.
320/// - `refname` — reference name (e.g. `"refs/heads/main"`).
321/// - `oid` — the new target object ID.
322///
323/// # Errors
324///
325/// Returns [`Error::Io`] on filesystem errors.
326/// Write a symbolic ref (e.g. `NOTES_MERGE_REF` → `refs/notes/m`).
327///
328/// For reftable-backed repositories this dispatches to the reftable writer.
329pub fn write_symbolic_ref(git_dir: &Path, refname: &str, target: &str) -> Result<()> {
330    if crate::reftable::is_reftable_repo(git_dir) {
331        return crate::reftable::reftable_write_symref(git_dir, refname, target, None, None);
332    }
333    let storage_dir = ref_storage_dir(git_dir, refname);
334    let path = storage_dir.join(refname);
335    if let Some(parent) = path.parent() {
336        fs::create_dir_all(parent)?;
337    }
338    let content = format!("ref: {target}\n");
339    let lock = path.with_extension("lock");
340    fs::write(&lock, &content)?;
341    fs::rename(&lock, &path)?;
342    Ok(())
343}
344
345pub fn write_ref(git_dir: &Path, refname: &str, oid: &ObjectId) -> Result<()> {
346    if crate::reftable::is_reftable_repo(git_dir) {
347        return crate::reftable::reftable_write_ref(git_dir, refname, oid, None, None);
348    }
349    let storage_dir = ref_storage_dir(git_dir, refname);
350    let path = storage_dir.join(refname);
351    if let Some(parent) = path.parent() {
352        fs::create_dir_all(parent)?;
353    }
354    let content = format!("{oid}\n");
355    // Write via lock file for atomicity
356    let lock = path.with_extension("lock");
357    fs::write(&lock, &content)?;
358    fs::rename(&lock, &path)?;
359    Ok(())
360}
361
362/// Delete a ref.
363///
364/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
365///
366/// # Errors
367///
368/// Returns [`Error::Io`] for errors other than "not found".
369pub fn delete_ref(git_dir: &Path, refname: &str) -> Result<()> {
370    if crate::reftable::is_reftable_repo(git_dir) {
371        return crate::reftable::reftable_delete_ref(git_dir, refname);
372    }
373    let storage_dir = ref_storage_dir(git_dir, refname);
374    // Remove the loose ref file
375    let path = storage_dir.join(refname);
376    match fs::remove_file(&path) {
377        Ok(()) => {}
378        Err(e) if e.kind() == io::ErrorKind::NotFound => {}
379        Err(e) => return Err(Error::Io(e)),
380    }
381
382    // Also remove the entry from packed-refs if present
383    remove_packed_ref(&storage_dir, refname)?;
384
385    let log_path = storage_dir.join("logs").join(refname);
386
387    // Keep `logs/refs/heads/<name>` when deleting a branch so `branch -D` + later recreate can
388    // retain history (matches upstream expectations in t1507 `log -g` with `@{now}`).
389    if !refname.starts_with("refs/heads/") {
390        let _ = fs::remove_file(&log_path);
391
392        // Remove empty parent directories under `logs/refs/heads/` so a deleted nested ref
393        // does not leave `logs/refs/heads/d` as a directory (which would block reflogs for
394        // a later branch named `d`).
395        let logs_heads = storage_dir.join("logs/refs/heads");
396        let mut parent = log_path.parent();
397        while let Some(p) = parent {
398            if p == logs_heads.as_path() || !p.starts_with(&logs_heads) {
399                break;
400            }
401            if fs::remove_dir(p).is_err() {
402                break;
403            }
404            parent = p.parent();
405        }
406    }
407
408    Ok(())
409}
410
411/// Remove a single entry from the packed-refs file, rewriting it.
412fn remove_packed_ref(git_dir: &Path, refname: &str) -> Result<()> {
413    let packed_path = git_dir.join("packed-refs");
414    let content = match fs::read_to_string(&packed_path) {
415        Ok(c) => c,
416        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
417        Err(e) => return Err(Error::Io(e)),
418    };
419
420    let mut out = String::new();
421    let mut skip_peeled = false;
422    let mut changed = false;
423    // Write a fresh header (don't preserve old comment lines — real git
424    // regenerates the header on every rewrite).
425    let mut header_written = false;
426
427    for line in content.lines() {
428        if skip_peeled {
429            if line.starts_with('^') {
430                changed = true;
431                continue;
432            }
433            skip_peeled = false;
434        }
435
436        if line.starts_with('#') {
437            // Skip old header lines — we'll write a fresh one
438            continue;
439        }
440        if line.starts_with('^') {
441            out.push_str(line);
442            out.push('\n');
443            continue;
444        }
445
446        // Write fresh header before the first data line
447        if !header_written {
448            out.insert_str(0, "# pack-refs with: peeled fully-peeled sorted\n");
449            header_written = true;
450        }
451
452        // Check if this line matches the ref to remove
453        let mut parts = line.splitn(2, ' ');
454        let _hash = parts.next().unwrap_or("");
455        let name = parts.next().unwrap_or("").trim();
456        if name == refname {
457            changed = true;
458            skip_peeled = true;
459            continue;
460        }
461
462        out.push_str(line);
463        out.push('\n');
464    }
465
466    if changed {
467        let lock = packed_path.with_extension("lock");
468        fs::write(&lock, &out).map_err(Error::Io)?;
469        fs::rename(&lock, &packed_path).map_err(Error::Io)?;
470    }
471
472    Ok(())
473}
474
475/// Read the symbolic ref target of `HEAD`.
476///
477/// Returns `None` if HEAD is detached (points directly to a commit hash).
478///
479/// # Errors
480///
481/// Returns [`Error::Io`] or [`Error::InvalidRef`] on failures.
482pub fn read_head(git_dir: &Path) -> Result<Option<String>> {
483    match read_ref_file(&git_dir.join("HEAD"))? {
484        Ref::Symbolic(target) => Ok(Some(target)),
485        Ref::Direct(_) => Ok(None),
486    }
487}
488
489/// Read symbolic target of any ref.
490///
491/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
492///
493/// Returns `Ok(Some(target))` when `refname` exists and is symbolic,
494/// `Ok(None)` when it is direct or missing.
495pub fn read_symbolic_ref(git_dir: &Path, refname: &str) -> Result<Option<String>> {
496    if crate::reftable::is_reftable_repo(git_dir) {
497        return crate::reftable::reftable_read_symbolic_ref(git_dir, refname);
498    }
499    let path = git_dir.join(refname);
500    match read_ref_file(&path) {
501        Ok(Ref::Symbolic(target)) => Ok(Some(target)),
502        Ok(Ref::Direct(_)) => Ok(None),
503        Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {
504            if !notes_merge_state_ref(refname) {
505                if let Some(common) = common_dir(git_dir) {
506                    if common != git_dir {
507                        let cpath = common.join(refname);
508                        match read_ref_file(&cpath) {
509                            Ok(Ref::Symbolic(target)) => return Ok(Some(target)),
510                            Ok(Ref::Direct(_)) => return Ok(None),
511                            Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
512                            Err(e) => return Err(e),
513                        }
514                    }
515                }
516            }
517            Ok(None)
518        }
519        Err(e) => Err(e),
520    }
521}
522
523/// Core `logAllRefUpdates` modes (after config lookup), matching Git's `log_refs_config`.
524#[derive(Clone, Copy, Debug, PartialEq, Eq)]
525pub enum LogRefsConfig {
526    /// `core.logAllRefUpdates` not set; resolved per-repo (bare vs non-bare).
527    Unset,
528    /// Explicitly disabled.
529    None,
530    /// `true` — log branch-like refs only (see [`should_autocreate_reflog`]).
531    Normal,
532    /// `always` — log updates to any ref.
533    Always,
534}
535
536/// Read `[core] logAllRefUpdates` from the repository config.
537///
538/// Returns [`LogRefsConfig::Unset`] when the key is absent.
539pub fn read_log_refs_config(git_dir: &Path) -> LogRefsConfig {
540    let config_dir = common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
541    let config_path = config_dir.join("config");
542    let content = match fs::read_to_string(config_path) {
543        Ok(c) => c,
544        Err(_) => return LogRefsConfig::Unset,
545    };
546
547    let mut in_core = false;
548    for line in content.lines() {
549        let trimmed = line.trim();
550        if trimmed.starts_with('[') {
551            in_core = trimmed.to_ascii_lowercase().starts_with("[core]");
552            continue;
553        }
554        if !in_core {
555            continue;
556        }
557        let Some((key, value)) = trimmed.split_once('=') else {
558            continue;
559        };
560        if !key.trim().eq_ignore_ascii_case("logallrefupdates") {
561            continue;
562        }
563        let v = value.trim();
564        let lower = v.to_ascii_lowercase();
565        return match lower.as_str() {
566            "always" => LogRefsConfig::Always,
567            "1" | "true" | "yes" | "on" => LogRefsConfig::Normal,
568            "0" | "false" | "no" | "off" | "never" => LogRefsConfig::None,
569            _ => LogRefsConfig::Unset,
570        };
571    }
572    LogRefsConfig::Unset
573}
574
575fn read_core_bare(git_dir: &Path) -> bool {
576    let config_dir = common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
577    let config_path = config_dir.join("config");
578    let Ok(content) = fs::read_to_string(config_path) else {
579        return false;
580    };
581    let mut in_core = false;
582    for line in content.lines() {
583        let trimmed = line.trim();
584        if trimmed.starts_with('[') {
585            in_core = trimmed.to_ascii_lowercase().starts_with("[core]");
586            continue;
587        }
588        if !in_core {
589            continue;
590        }
591        let Some((key, value)) = trimmed.split_once('=') else {
592            continue;
593        };
594        if key.trim().eq_ignore_ascii_case("bare") {
595            let v = value.trim().to_ascii_lowercase();
596            return matches!(v.as_str(), "1" | "true" | "yes" | "on");
597        }
598    }
599    false
600}
601
602/// Effective `logAllRefUpdates` after applying Git's `LOG_REFS_UNSET` rule.
603pub fn effective_log_refs_config(git_dir: &Path) -> LogRefsConfig {
604    match read_log_refs_config(git_dir) {
605        LogRefsConfig::Unset => {
606            if read_core_bare(git_dir) {
607                LogRefsConfig::None
608            } else {
609                LogRefsConfig::Normal
610            }
611        }
612        other => other,
613    }
614}
615
616/// Whether a new reflog file may be auto-created for `refname` given an already-resolved
617/// `core.logAllRefUpdates` mode (including command-line config).
618#[must_use]
619pub fn should_autocreate_reflog_for_mode(refname: &str, mode: LogRefsConfig) -> bool {
620    match mode {
621        LogRefsConfig::Always => true,
622        LogRefsConfig::Normal => {
623            refname == "HEAD"
624                || refname.starts_with("refs/heads/")
625                || refname.starts_with("refs/remotes/")
626                || refname.starts_with("refs/notes/")
627        }
628        LogRefsConfig::None | LogRefsConfig::Unset => false,
629    }
630}
631
632/// Whether a new reflog file may be auto-created for `refname` (Git `should_autocreate_reflog`).
633#[must_use]
634pub fn should_autocreate_reflog(git_dir: &Path, refname: &str) -> bool {
635    should_autocreate_reflog_for_mode(refname, effective_log_refs_config(git_dir))
636}
637
638/// Write a reflog entry.
639///
640/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
641///
642/// # Parameters
643///
644/// - `git_dir` — path to the git directory.
645/// - `refname` — reference name (e.g. `"refs/heads/main"`).
646/// - `old_oid` — previous OID (use `ObjectId::from_bytes(&[0;20])` for a new ref).
647/// - `new_oid` — new OID.
648/// - `identity` — `"Name <email> <timestamp> <tz>"` formatted string.
649/// - `message` — short log message.
650/// - `force_create` — if true, create the log file even when [`should_autocreate_reflog`] would not.
651///
652/// # Errors
653///
654/// Returns [`Error::Io`] on filesystem errors.
655pub fn append_reflog(
656    git_dir: &Path,
657    refname: &str,
658    old_oid: &ObjectId,
659    new_oid: &ObjectId,
660    identity: &str,
661    message: &str,
662    force_create: bool,
663) -> Result<()> {
664    if crate::reftable::is_reftable_repo(git_dir) {
665        return crate::reftable::reftable_append_reflog(
666            git_dir,
667            refname,
668            old_oid,
669            new_oid,
670            identity,
671            message,
672            force_create,
673        );
674    }
675    let storage_dir = ref_storage_dir(git_dir, refname);
676    let log_path = storage_dir.join("logs").join(refname);
677    let may_write =
678        force_create || should_autocreate_reflog(git_dir, refname) || !message.is_empty();
679    if !may_write && !log_path.exists() {
680        return Ok(());
681    }
682    if let Some(parent) = log_path.parent() {
683        fs::create_dir_all(parent)?;
684    }
685    let line = if message.is_empty() {
686        format!("{old_oid} {new_oid} {identity}\n")
687    } else {
688        format!("{old_oid} {new_oid} {identity}\t{message}\n")
689    };
690    let mut file = fs::OpenOptions::new()
691        .create(true)
692        .append(true)
693        .open(&log_path)?;
694    use io::Write;
695    file.write_all(line.as_bytes())?;
696    Ok(())
697}
698
699fn ref_storage_dir(git_dir: &Path, refname: &str) -> PathBuf {
700    // Per-worktree refs live under this worktree's git dir; shared refs (including
701    // `refs/bisect/*`) live in the common repository directory so all worktrees
702    // see the same bisection state.
703    if refname == "HEAD" || refname == "NOTES_MERGE_PARTIAL" || refname == "NOTES_MERGE_REF" {
704        return git_dir.to_path_buf();
705    }
706    common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf())
707}
708
709/// List all refs under a given prefix (e.g. `"refs/heads/"`).
710///
711/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
712///
713/// Returns a sorted list of `(refname, ObjectId)` pairs.
714///
715/// # Errors
716///
717/// Returns [`Error::Io`] on directory traversal errors.
718pub fn list_refs(git_dir: &Path, prefix: &str) -> Result<Vec<(String, ObjectId)>> {
719    if crate::reftable::is_reftable_repo(git_dir) {
720        return crate::reftable::reftable_list_refs(git_dir, prefix);
721    }
722    let mut results = Vec::new();
723    let base = git_dir.join(prefix);
724    collect_refs(&base, prefix, git_dir, &mut results)?;
725    collect_packed_refs(git_dir, prefix, &mut results)?;
726
727    // For worktrees, also collect refs from the common dir
728    if let Some(cdir) = common_dir(git_dir) {
729        if cdir != git_dir {
730            let cbase = cdir.join(prefix);
731            collect_refs(&cbase, prefix, &cdir, &mut results)?;
732            collect_packed_refs(&cdir, prefix, &mut results)?;
733            // Deduplicate: worktree-local refs take priority
734            results.sort_by(|a, b| a.0.cmp(&b.0));
735            results.dedup_by(|b, a| a.0 == b.0);
736        }
737    }
738
739    results.sort_by(|a, b| a.0.cmp(&b.0));
740    Ok(results)
741}
742
743/// List refs matching a glob pattern (e.g. `refs/heads/topic/*`).
744pub fn list_refs_glob(git_dir: &Path, pattern: &str) -> Result<Vec<(String, ObjectId)>> {
745    let glob_pos = pattern.find(['*', '?', '[']);
746    let prefix = match glob_pos {
747        Some(pos) => match pattern[..pos].rfind('/') {
748            Some(slash) => &pattern[..=slash],
749            None => "",
750        },
751        None => pattern,
752    };
753    let all = list_refs(git_dir, prefix)?;
754    let mut results = Vec::new();
755    for (refname, oid) in all {
756        if glob_match(pattern, &refname) {
757            results.push((refname, oid));
758        }
759    }
760    Ok(results)
761}
762
763/// Check whether a ref name matches a glob pattern.
764///
765/// Supports `*`, `?`, and `[…]` wildcards. An exact string match is also accepted.
766pub fn ref_matches_glob(refname: &str, pattern: &str) -> bool {
767    // For exact matches (no glob characters), check suffix match
768    if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('[') {
769        return refname == pattern
770            || refname.ends_with(&format!("/{pattern}"))
771            || refname.starts_with(&format!("{pattern}/"));
772    }
773    glob_match(pattern, refname)
774}
775
776fn glob_match(pattern: &str, text: &str) -> bool {
777    let pat = pattern.as_bytes();
778    let txt = text.as_bytes();
779    let (mut pi, mut ti) = (0, 0);
780    let (mut star_pi, mut star_ti) = (usize::MAX, 0);
781    while ti < txt.len() {
782        if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == txt[ti]) {
783            pi += 1;
784            ti += 1;
785        } else if pi < pat.len() && pat[pi] == b'*' {
786            star_pi = pi;
787            star_ti = ti;
788            pi += 1;
789        } else if star_pi != usize::MAX {
790            pi = star_pi + 1;
791            star_ti += 1;
792            ti = star_ti;
793        } else {
794            return false;
795        }
796    }
797    while pi < pat.len() && pat[pi] == b'*' {
798        pi += 1;
799    }
800    pi == pat.len()
801}
802
803fn collect_refs(
804    dir: &Path,
805    prefix: &str,
806    git_dir: &Path,
807    out: &mut Vec<(String, ObjectId)>,
808) -> Result<()> {
809    let read = match fs::read_dir(dir) {
810        Ok(r) => r,
811        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
812        Err(e) => return Err(Error::Io(e)),
813    };
814
815    for entry in read {
816        let entry = entry?;
817        let name = entry.file_name();
818        let name_str = name.to_string_lossy();
819        let refname = format!("{prefix}{name_str}");
820        let path = entry.path();
821        // Follow symlinks: loose refs may be symlinks; `DirEntry::file_type` does not.
822        let meta = match fs::metadata(&path) {
823            Ok(m) => m,
824            Err(_) => continue,
825        };
826
827        if meta.is_dir() {
828            collect_refs(&path, &format!("{refname}/"), git_dir, out)?;
829        } else if meta.is_file() {
830            if let Ok(oid) = resolve_ref(git_dir, &refname) {
831                out.push((refname, oid))
832            }
833        }
834    }
835    Ok(())
836}
837
838/// Resolve `@{-N}` syntax to the branch name (not an OID).
839/// Returns the branch name of the Nth previously checked out branch.
840pub fn resolve_at_n_branch(git_dir: &Path, spec: &str) -> Result<String> {
841    // Parse the N from @{-N}
842    let inner = spec
843        .strip_prefix("@{-")
844        .and_then(|s| s.strip_suffix('}'))
845        .ok_or_else(|| Error::InvalidRef(format!("not an @{{-N}} ref: {spec}")))?;
846    let n: usize = inner
847        .parse()
848        .map_err(|_| Error::InvalidRef(format!("invalid N in {spec}")))?;
849    if n == 0 {
850        return Err(Error::InvalidRef("@{-0} is not valid".to_string()));
851    }
852    let entries = crate::reflog::read_reflog(git_dir, "HEAD")?;
853    let mut count = 0usize;
854    for entry in entries.iter().rev() {
855        let msg = &entry.message;
856        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
857            count += 1;
858            if count == n {
859                if let Some(to_pos) = rest.find(" to ") {
860                    return Ok(rest[..to_pos].to_string());
861                }
862            }
863        }
864    }
865    Err(Error::InvalidRef(format!(
866        "{spec}: only {count} checkout(s) in reflog"
867    )))
868}
869
870fn collect_packed_refs(
871    git_dir: &Path,
872    prefix: &str,
873    out: &mut Vec<(String, ObjectId)>,
874) -> Result<()> {
875    let packed_path = git_dir.join("packed-refs");
876    let content = match fs::read_to_string(&packed_path) {
877        Ok(c) => c,
878        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
879        Err(e) => return Err(Error::Io(e)),
880    };
881
882    for line in content.lines() {
883        if line.starts_with('#') || line.starts_with('^') || line.is_empty() {
884            continue;
885        }
886        let mut parts = line.splitn(2, ' ');
887        let hash = parts.next().unwrap_or("");
888        let refname = parts.next().unwrap_or("").trim();
889        if !refname.starts_with(prefix) || hash.len() != 40 {
890            continue;
891        }
892        let oid: ObjectId = hash.parse()?;
893        out.push((refname.to_string(), oid));
894    }
895    Ok(())
896}
897
898#[cfg(test)]
899mod read_raw_ref_tests {
900    use super::*;
901    use tempfile::tempdir;
902
903    #[test]
904    fn loose_ref_file_is_exists() {
905        let dir = tempdir().unwrap();
906        let git_dir = dir.path();
907        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
908        fs::write(
909            git_dir.join("refs/heads/side"),
910            "0000000000000000000000000000000000000000\n",
911        )
912        .unwrap();
913        assert_eq!(
914            read_raw_ref(git_dir, "refs/heads/side").unwrap(),
915            RawRefLookup::Exists
916        );
917    }
918
919    #[test]
920    fn missing_ref_is_not_found() {
921        let dir = tempdir().unwrap();
922        let git_dir = dir.path();
923        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
924        assert_eq!(
925            read_raw_ref(git_dir, "refs/heads/nope").unwrap(),
926            RawRefLookup::NotFound
927        );
928    }
929
930    #[test]
931    fn directory_where_ref_expected_is_is_directory() {
932        let dir = tempdir().unwrap();
933        let git_dir = dir.path();
934        fs::create_dir_all(git_dir.join("refs/heads")).unwrap();
935        assert_eq!(
936            read_raw_ref(git_dir, "refs/heads").unwrap(),
937            RawRefLookup::IsDirectory
938        );
939    }
940
941    #[test]
942    fn packed_ref_name_is_exists() {
943        let dir = tempdir().unwrap();
944        let git_dir = dir.path();
945        fs::write(
946            git_dir.join("packed-refs"),
947            "# pack-refs with: peeled fully-peeled \n\
948             0000000000000000000000000000000000000000 refs/heads/packed\n",
949        )
950        .unwrap();
951        assert_eq!(
952            read_raw_ref(git_dir, "refs/heads/packed").unwrap(),
953            RawRefLookup::Exists
954        );
955    }
956}