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    let path = if Path::new(rel).is_absolute() {
86        PathBuf::from(rel)
87    } else {
88        git_dir.join(rel)
89    };
90    path.canonicalize().ok()
91}
92
93/// Internal recursive resolver with cycle detection.
94///
95/// When operating inside a worktree, `common` points to the shared git
96/// directory where most refs live.  The worktree-specific `git_dir` is
97/// checked first for HEAD and per-worktree refs.
98fn resolve_ref_depth(
99    git_dir: &Path,
100    common: Option<&Path>,
101    refname: &str,
102    depth: usize,
103) -> Result<ObjectId> {
104    if depth > 10 {
105        return Err(Error::InvalidRef(format!(
106            "ref symlink too deep: {refname}"
107        )));
108    }
109
110    // First try as a loose ref file in git_dir
111    let path = git_dir.join(refname);
112    match read_ref_file(&path) {
113        Ok(Ref::Direct(oid)) => return Ok(oid),
114        Ok(Ref::Symbolic(target)) => {
115            return resolve_ref_depth(git_dir, common, &target, depth + 1);
116        }
117        Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
118        Err(e) => return Err(e),
119    }
120
121    // For worktrees, try the common dir for shared refs
122    if let Some(cdir) = common {
123        if cdir != git_dir {
124            let cpath = cdir.join(refname);
125            match read_ref_file(&cpath) {
126                Ok(Ref::Direct(oid)) => return Ok(oid),
127                Ok(Ref::Symbolic(target)) => {
128                    return resolve_ref_depth(git_dir, common, &target, depth + 1);
129                }
130                Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
131                Err(e) => return Err(e),
132            }
133        }
134    }
135
136    // Fall back to packed-refs (in common dir if available)
137    let packed_dir = common.unwrap_or(git_dir);
138    if let Some(oid) = lookup_packed_ref(packed_dir, refname)? {
139        return Ok(oid);
140    }
141    // Also check git_dir packed-refs if different from common
142    if common.is_some() && common != Some(git_dir) {
143        if let Some(oid) = lookup_packed_ref(git_dir, refname)? {
144            return Ok(oid);
145        }
146    }
147
148    Err(Error::InvalidRef(format!("ref not found: {refname}")))
149}
150
151/// Look up a refname in `packed-refs`.
152fn lookup_packed_ref(git_dir: &Path, refname: &str) -> Result<Option<ObjectId>> {
153    let packed = git_dir.join("packed-refs");
154    let content = match fs::read_to_string(&packed) {
155        Ok(c) => c,
156        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
157        Err(e) => return Err(Error::Io(e)),
158    };
159
160    for line in content.lines() {
161        if line.starts_with('#') || line.starts_with('^') {
162            continue;
163        }
164        let mut parts = line.splitn(2, ' ');
165        let hash = parts.next().unwrap_or("");
166        let name = parts.next().unwrap_or("").trim();
167        if name == refname && hash.len() == 40 {
168            let oid: ObjectId = hash.parse()?;
169            return Ok(Some(oid));
170        }
171    }
172    Ok(None)
173}
174
175/// Write a ref, creating parent directories as needed.
176///
177/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
178///
179/// # Parameters
180///
181/// - `git_dir` — path to the git directory.
182/// - `refname` — reference name (e.g. `"refs/heads/main"`).
183/// - `oid` — the new target object ID.
184///
185/// # Errors
186///
187/// Returns [`Error::Io`] on filesystem errors.
188pub fn write_ref(git_dir: &Path, refname: &str, oid: &ObjectId) -> Result<()> {
189    if crate::reftable::is_reftable_repo(git_dir) {
190        return crate::reftable::reftable_write_ref(git_dir, refname, oid, None, None);
191    }
192    let storage_dir = ref_storage_dir(git_dir, refname);
193    let path = storage_dir.join(refname);
194    if let Some(parent) = path.parent() {
195        fs::create_dir_all(parent)?;
196    }
197    let content = format!("{oid}\n");
198    // Write via lock file for atomicity
199    let lock = path.with_extension("lock");
200    fs::write(&lock, &content)?;
201    fs::rename(&lock, &path)?;
202    Ok(())
203}
204
205/// Delete a ref.
206///
207/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
208///
209/// # Errors
210///
211/// Returns [`Error::Io`] for errors other than "not found".
212pub fn delete_ref(git_dir: &Path, refname: &str) -> Result<()> {
213    if crate::reftable::is_reftable_repo(git_dir) {
214        return crate::reftable::reftable_delete_ref(git_dir, refname);
215    }
216    let storage_dir = ref_storage_dir(git_dir, refname);
217    // Remove the loose ref file
218    let path = storage_dir.join(refname);
219    match fs::remove_file(&path) {
220        Ok(()) => {}
221        Err(e) if e.kind() == io::ErrorKind::NotFound => {}
222        Err(e) => return Err(Error::Io(e)),
223    }
224
225    // Also remove the entry from packed-refs if present
226    remove_packed_ref(&storage_dir, refname)?;
227
228    let log_path = storage_dir.join("logs").join(refname);
229    let _ = fs::remove_file(&log_path);
230
231    Ok(())
232}
233
234/// Remove a single entry from the packed-refs file, rewriting it.
235fn remove_packed_ref(git_dir: &Path, refname: &str) -> Result<()> {
236    let packed_path = git_dir.join("packed-refs");
237    let content = match fs::read_to_string(&packed_path) {
238        Ok(c) => c,
239        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
240        Err(e) => return Err(Error::Io(e)),
241    };
242
243    let mut out = String::new();
244    let mut skip_peeled = false;
245    let mut changed = false;
246    // Write a fresh header (don't preserve old comment lines — real git
247    // regenerates the header on every rewrite).
248    let mut header_written = false;
249
250    for line in content.lines() {
251        if skip_peeled {
252            if line.starts_with('^') {
253                changed = true;
254                continue;
255            }
256            skip_peeled = false;
257        }
258
259        if line.starts_with('#') {
260            // Skip old header lines — we'll write a fresh one
261            continue;
262        }
263        if line.starts_with('^') {
264            out.push_str(line);
265            out.push('\n');
266            continue;
267        }
268
269        // Write fresh header before the first data line
270        if !header_written {
271            out.insert_str(0, "# pack-refs with: peeled fully-peeled sorted\n");
272            header_written = true;
273        }
274
275        // Check if this line matches the ref to remove
276        let mut parts = line.splitn(2, ' ');
277        let _hash = parts.next().unwrap_or("");
278        let name = parts.next().unwrap_or("").trim();
279        if name == refname {
280            changed = true;
281            skip_peeled = true;
282            continue;
283        }
284
285        out.push_str(line);
286        out.push('\n');
287    }
288
289    if changed {
290        let lock = packed_path.with_extension("lock");
291        fs::write(&lock, &out).map_err(Error::Io)?;
292        fs::rename(&lock, &packed_path).map_err(Error::Io)?;
293    }
294
295    Ok(())
296}
297
298/// Read the symbolic ref target of `HEAD`.
299///
300/// Returns `None` if HEAD is detached (points directly to a commit hash).
301///
302/// # Errors
303///
304/// Returns [`Error::Io`] or [`Error::InvalidRef`] on failures.
305pub fn read_head(git_dir: &Path) -> Result<Option<String>> {
306    match read_ref_file(&git_dir.join("HEAD"))? {
307        Ref::Symbolic(target) => Ok(Some(target)),
308        Ref::Direct(_) => Ok(None),
309    }
310}
311
312/// Read symbolic target of any ref.
313///
314/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
315///
316/// Returns `Ok(Some(target))` when `refname` exists and is symbolic,
317/// `Ok(None)` when it is direct or missing.
318pub fn read_symbolic_ref(git_dir: &Path, refname: &str) -> Result<Option<String>> {
319    if crate::reftable::is_reftable_repo(git_dir) {
320        return crate::reftable::reftable_read_symbolic_ref(git_dir, refname);
321    }
322    let path = git_dir.join(refname);
323    match read_ref_file(&path) {
324        Ok(Ref::Symbolic(target)) => Ok(Some(target)),
325        Ok(Ref::Direct(_)) => Ok(None),
326        Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {
327            if let Some(common) = common_dir(git_dir) {
328                if common != git_dir {
329                    let cpath = common.join(refname);
330                    match read_ref_file(&cpath) {
331                        Ok(Ref::Symbolic(target)) => return Ok(Some(target)),
332                        Ok(Ref::Direct(_)) => return Ok(None),
333                        Err(Error::Io(ref e)) if e.kind() == io::ErrorKind::NotFound => {}
334                        Err(e) => return Err(e),
335                    }
336                }
337            }
338            Ok(None)
339        }
340        Err(e) => Err(e),
341    }
342}
343
344/// Core `logAllRefUpdates` modes (after config lookup), matching Git's `log_refs_config`.
345#[derive(Clone, Copy, Debug, PartialEq, Eq)]
346pub enum LogRefsConfig {
347    /// `core.logAllRefUpdates` not set; resolved per-repo (bare vs non-bare).
348    Unset,
349    /// Explicitly disabled.
350    None,
351    /// `true` — log branch-like refs only (see [`should_autocreate_reflog`]).
352    Normal,
353    /// `always` — log updates to any ref.
354    Always,
355}
356
357/// Read `[core] logAllRefUpdates` from the repository config.
358///
359/// Returns [`LogRefsConfig::Unset`] when the key is absent.
360pub fn read_log_refs_config(git_dir: &Path) -> LogRefsConfig {
361    let config_dir = common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
362    let config_path = config_dir.join("config");
363    let content = match fs::read_to_string(config_path) {
364        Ok(c) => c,
365        Err(_) => return LogRefsConfig::Unset,
366    };
367
368    let mut in_core = false;
369    for line in content.lines() {
370        let trimmed = line.trim();
371        if trimmed.starts_with('[') {
372            in_core = trimmed.to_ascii_lowercase().starts_with("[core]");
373            continue;
374        }
375        if !in_core {
376            continue;
377        }
378        let Some((key, value)) = trimmed.split_once('=') else {
379            continue;
380        };
381        if !key.trim().eq_ignore_ascii_case("logallrefupdates") {
382            continue;
383        }
384        let v = value.trim();
385        let lower = v.to_ascii_lowercase();
386        return match lower.as_str() {
387            "always" => LogRefsConfig::Always,
388            "1" | "true" | "yes" | "on" => LogRefsConfig::Normal,
389            "0" | "false" | "no" | "off" | "never" => LogRefsConfig::None,
390            _ => LogRefsConfig::Unset,
391        };
392    }
393    LogRefsConfig::Unset
394}
395
396fn read_core_bare(git_dir: &Path) -> bool {
397    let config_dir = common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf());
398    let config_path = config_dir.join("config");
399    let Ok(content) = fs::read_to_string(config_path) else {
400        return false;
401    };
402    let mut in_core = false;
403    for line in content.lines() {
404        let trimmed = line.trim();
405        if trimmed.starts_with('[') {
406            in_core = trimmed.to_ascii_lowercase().starts_with("[core]");
407            continue;
408        }
409        if !in_core {
410            continue;
411        }
412        let Some((key, value)) = trimmed.split_once('=') else {
413            continue;
414        };
415        if key.trim().eq_ignore_ascii_case("bare") {
416            let v = value.trim().to_ascii_lowercase();
417            return matches!(v.as_str(), "1" | "true" | "yes" | "on");
418        }
419    }
420    false
421}
422
423/// Effective `logAllRefUpdates` after applying Git's `LOG_REFS_UNSET` rule.
424pub fn effective_log_refs_config(git_dir: &Path) -> LogRefsConfig {
425    match read_log_refs_config(git_dir) {
426        LogRefsConfig::Unset => {
427            if read_core_bare(git_dir) {
428                LogRefsConfig::None
429            } else {
430                LogRefsConfig::Normal
431            }
432        }
433        other => other,
434    }
435}
436
437/// Whether a new reflog file may be auto-created for `refname` (Git `should_autocreate_reflog`).
438#[must_use]
439pub fn should_autocreate_reflog(git_dir: &Path, refname: &str) -> bool {
440    match effective_log_refs_config(git_dir) {
441        LogRefsConfig::Always => true,
442        LogRefsConfig::Normal => {
443            refname == "HEAD"
444                || refname.starts_with("refs/heads/")
445                || refname.starts_with("refs/remotes/")
446                || refname.starts_with("refs/notes/")
447        }
448        LogRefsConfig::None | LogRefsConfig::Unset => false,
449    }
450}
451
452/// Write a reflog entry.
453///
454/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
455///
456/// # Parameters
457///
458/// - `git_dir` — path to the git directory.
459/// - `refname` — reference name (e.g. `"refs/heads/main"`).
460/// - `old_oid` — previous OID (use `ObjectId::from_bytes(&[0;20])` for a new ref).
461/// - `new_oid` — new OID.
462/// - `identity` — `"Name <email> <timestamp> <tz>"` formatted string.
463/// - `message` — short log message.
464/// - `force_create` — if true, create the log file even when [`should_autocreate_reflog`] would not.
465///
466/// # Errors
467///
468/// Returns [`Error::Io`] on filesystem errors.
469pub fn append_reflog(
470    git_dir: &Path,
471    refname: &str,
472    old_oid: &ObjectId,
473    new_oid: &ObjectId,
474    identity: &str,
475    message: &str,
476    force_create: bool,
477) -> Result<()> {
478    if crate::reftable::is_reftable_repo(git_dir) {
479        return crate::reftable::reftable_append_reflog(
480            git_dir,
481            refname,
482            old_oid,
483            new_oid,
484            identity,
485            message,
486            force_create,
487        );
488    }
489    let storage_dir = ref_storage_dir(git_dir, refname);
490    let log_path = storage_dir.join("logs").join(refname);
491    let may_write =
492        force_create || should_autocreate_reflog(git_dir, refname) || !message.is_empty();
493    if !may_write && !log_path.exists() {
494        return Ok(());
495    }
496    if let Some(parent) = log_path.parent() {
497        fs::create_dir_all(parent)?;
498    }
499    let line = if message.is_empty() {
500        format!("{old_oid} {new_oid} {identity}\n")
501    } else {
502        format!("{old_oid} {new_oid} {identity}\t{message}\n")
503    };
504    let mut file = fs::OpenOptions::new()
505        .create(true)
506        .append(true)
507        .open(&log_path)?;
508    use io::Write;
509    file.write_all(line.as_bytes())?;
510    Ok(())
511}
512
513fn ref_storage_dir(git_dir: &Path, refname: &str) -> PathBuf {
514    if refname == "HEAD" || refname.starts_with("refs/bisect/") {
515        return git_dir.to_path_buf();
516    }
517    common_dir(git_dir).unwrap_or_else(|| git_dir.to_path_buf())
518}
519
520/// List all refs under a given prefix (e.g. `"refs/heads/"`).
521///
522/// Dispatches to the reftable backend when `extensions.refStorage = reftable`.
523///
524/// Returns a sorted list of `(refname, ObjectId)` pairs.
525///
526/// # Errors
527///
528/// Returns [`Error::Io`] on directory traversal errors.
529pub fn list_refs(git_dir: &Path, prefix: &str) -> Result<Vec<(String, ObjectId)>> {
530    if crate::reftable::is_reftable_repo(git_dir) {
531        return crate::reftable::reftable_list_refs(git_dir, prefix);
532    }
533    let mut results = Vec::new();
534    let base = git_dir.join(prefix);
535    collect_refs(&base, prefix, git_dir, &mut results)?;
536    collect_packed_refs(git_dir, prefix, &mut results)?;
537
538    // For worktrees, also collect refs from the common dir
539    if let Some(cdir) = common_dir(git_dir) {
540        if cdir != git_dir {
541            let cbase = cdir.join(prefix);
542            collect_refs(&cbase, prefix, &cdir, &mut results)?;
543            collect_packed_refs(&cdir, prefix, &mut results)?;
544            // Deduplicate: worktree-local refs take priority
545            results.sort_by(|a, b| a.0.cmp(&b.0));
546            results.dedup_by(|b, a| a.0 == b.0);
547        }
548    }
549
550    results.sort_by(|a, b| a.0.cmp(&b.0));
551    Ok(results)
552}
553
554/// List refs matching a glob pattern (e.g. `refs/heads/topic/*`).
555pub fn list_refs_glob(git_dir: &Path, pattern: &str) -> Result<Vec<(String, ObjectId)>> {
556    let glob_pos = pattern.find(['*', '?', '[']);
557    let prefix = match glob_pos {
558        Some(pos) => match pattern[..pos].rfind('/') {
559            Some(slash) => &pattern[..=slash],
560            None => "",
561        },
562        None => pattern,
563    };
564    let all = list_refs(git_dir, prefix)?;
565    let mut results = Vec::new();
566    for (refname, oid) in all {
567        if glob_match(pattern, &refname) {
568            results.push((refname, oid));
569        }
570    }
571    Ok(results)
572}
573
574/// Check whether a ref name matches a glob pattern.
575///
576/// Supports `*`, `?`, and `[…]` wildcards. An exact string match is also accepted.
577pub fn ref_matches_glob(refname: &str, pattern: &str) -> bool {
578    // For exact matches (no glob characters), check suffix match
579    if !pattern.contains('*') && !pattern.contains('?') && !pattern.contains('[') {
580        return refname == pattern
581            || refname.ends_with(&format!("/{pattern}"))
582            || refname.starts_with(&format!("{pattern}/"));
583    }
584    glob_match(pattern, refname)
585}
586
587fn glob_match(pattern: &str, text: &str) -> bool {
588    let pat = pattern.as_bytes();
589    let txt = text.as_bytes();
590    let (mut pi, mut ti) = (0, 0);
591    let (mut star_pi, mut star_ti) = (usize::MAX, 0);
592    while ti < txt.len() {
593        if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == txt[ti]) {
594            pi += 1;
595            ti += 1;
596        } else if pi < pat.len() && pat[pi] == b'*' {
597            star_pi = pi;
598            star_ti = ti;
599            pi += 1;
600        } else if star_pi != usize::MAX {
601            pi = star_pi + 1;
602            star_ti += 1;
603            ti = star_ti;
604        } else {
605            return false;
606        }
607    }
608    while pi < pat.len() && pat[pi] == b'*' {
609        pi += 1;
610    }
611    pi == pat.len()
612}
613
614fn collect_refs(
615    dir: &Path,
616    prefix: &str,
617    git_dir: &Path,
618    out: &mut Vec<(String, ObjectId)>,
619) -> Result<()> {
620    let read = match fs::read_dir(dir) {
621        Ok(r) => r,
622        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
623        Err(e) => return Err(Error::Io(e)),
624    };
625
626    for entry in read {
627        let entry = entry?;
628        let file_type = entry.file_type()?;
629        let name = entry.file_name();
630        let name_str = name.to_string_lossy();
631        let refname = format!("{prefix}{name_str}");
632
633        if file_type.is_dir() {
634            collect_refs(&entry.path(), &format!("{refname}/"), git_dir, out)?;
635        } else if file_type.is_file() {
636            if let Ok(oid) = resolve_ref(git_dir, &refname) {
637                out.push((refname, oid))
638            }
639        }
640    }
641    Ok(())
642}
643
644/// Resolve `@{-N}` syntax to the branch name (not an OID).
645/// Returns the branch name of the Nth previously checked out branch.
646pub fn resolve_at_n_branch(git_dir: &Path, spec: &str) -> Result<String> {
647    // Parse the N from @{-N}
648    let inner = spec
649        .strip_prefix("@{-")
650        .and_then(|s| s.strip_suffix('}'))
651        .ok_or_else(|| Error::InvalidRef(format!("not an @{{-N}} ref: {spec}")))?;
652    let n: usize = inner
653        .parse()
654        .map_err(|_| Error::InvalidRef(format!("invalid N in {spec}")))?;
655    if n == 0 {
656        return Err(Error::InvalidRef("@{-0} is not valid".to_string()));
657    }
658    let entries = crate::reflog::read_reflog(git_dir, "HEAD")?;
659    let mut count = 0usize;
660    for entry in entries.iter().rev() {
661        let msg = &entry.message;
662        if let Some(rest) = msg.strip_prefix("checkout: moving from ") {
663            count += 1;
664            if count == n {
665                if let Some(to_pos) = rest.find(" to ") {
666                    return Ok(rest[..to_pos].to_string());
667                }
668            }
669        }
670    }
671    Err(Error::InvalidRef(format!(
672        "{spec}: only {count} checkout(s) in reflog"
673    )))
674}
675
676fn collect_packed_refs(
677    git_dir: &Path,
678    prefix: &str,
679    out: &mut Vec<(String, ObjectId)>,
680) -> Result<()> {
681    let packed_path = git_dir.join("packed-refs");
682    let content = match fs::read_to_string(&packed_path) {
683        Ok(c) => c,
684        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
685        Err(e) => return Err(Error::Io(e)),
686    };
687
688    for line in content.lines() {
689        if line.starts_with('#') || line.starts_with('^') || line.is_empty() {
690            continue;
691        }
692        let mut parts = line.splitn(2, ' ');
693        let hash = parts.next().unwrap_or("");
694        let refname = parts.next().unwrap_or("").trim();
695        if !refname.starts_with(prefix) || hash.len() != 40 {
696            continue;
697        }
698        let oid: ObjectId = hash.parse()?;
699        out.push((refname.to_string(), oid));
700    }
701    Ok(())
702}