Skip to main content

git_core/
refs.rs

1use std::path::Path;
2
3use crate::error::{GitError, Result};
4use crate::hash::GitHash;
5
6/// Enumerate every ref in `git_dir`: the loose `refs/**` tree, `packed-refs`,
7/// and `HEAD` (when it resolves to a hash). Returns `(refname, target_hash)`
8/// pairs. Unresolvable or malformed refs are skipped; never panics.
9#[must_use]
10pub fn list_refs(git_dir: &Path) -> Vec<(String, GitHash)> {
11    // Best-effort facade: a refs-subsystem I/O failure degrades to fewer refs.
12    // Reachability callers MUST use `list_refs_checked` instead — see its docs.
13    list_refs_checked(git_dir).unwrap_or_default()
14}
15
16/// Like [`list_refs`] but **fails loud on a ref-enumeration I/O error** instead of
17/// silently returning fewer refs. A genuine failure to read the refs subsystem
18/// (e.g. `refs/` present but unreadable, a `packed-refs` that exists but can't be
19/// read) is a *bootstrap* failure: callers that compute reachability from these
20/// refs (the unreachable-object audit) must NOT treat it as "zero refs" — that
21/// would flag every object as orphaned (a false-positive inversion). A genuinely
22/// *absent* file/dir (`NotFound`) is the legitimate empty case and is NOT an error.
23///
24/// # Errors
25/// [`GitError::Io`] if a refs path that exists cannot be enumerated/read.
26pub fn list_refs_checked(git_dir: &Path) -> Result<Vec<(String, GitHash)>> {
27    let mut out: Vec<(String, GitHash)> = Vec::new();
28
29    // Loose refs under refs/ (recursively). An absent refs/ is the legitimate
30    // empty case; any other read failure is a bootstrap error that propagates.
31    let refs_root = git_dir.join("refs");
32    collect_loose_refs_checked(&refs_root, "refs", git_dir, &mut out)?;
33
34    // packed-refs: lines of "<sha> <refname>"; "^<sha>" peel lines are skipped
35    // (the peeled tag commit is reachable via the tag object itself). Absent is
36    // fine; an existing-but-unreadable packed-refs is a bootstrap failure.
37    match std::fs::read_to_string(git_dir.join("packed-refs")) {
38        Ok(text) => {
39            for line in text.lines() {
40                let line = line.trim();
41                if line.is_empty() || line.starts_with('#') || line.starts_with('^') {
42                    continue;
43                }
44                if let Some((sha, name)) = line.split_once(' ') {
45                    if out.iter().any(|(n, _)| n == name) {
46                        continue; // a loose ref shadows the packed one.
47                    }
48                    if let Ok(hash) = GitHash::from_hex(sha.trim()) {
49                        out.push((name.trim().to_string(), hash));
50                    }
51                }
52            }
53        }
54        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
55        Err(e) => return Err(GitError::Io(e)),
56    }
57
58    // HEAD, resolved to a hash (symbolic or detached). A missing/unborn HEAD is
59    // a per-ref miss (RefNotFound); only an I/O failure is a bootstrap error.
60    match resolve_ref(git_dir, "HEAD") {
61        Ok(hash) => {
62            if !out.iter().any(|(n, _)| n == "HEAD") {
63                out.push(("HEAD".to_string(), hash));
64            }
65        }
66        Err(GitError::RefNotFound(_)) => {}
67        Err(e) => return Err(e),
68    }
69
70    Ok(out)
71}
72
73/// Recursively walk a loose-ref directory, appending `(refname, hash)` pairs.
74///
75/// A genuinely-absent directory (`NotFound`) is the empty case and yields
76/// `Ok(())`; any other `read_dir`/entry I/O error — including `refs/` existing
77/// but not being a directory — propagates as [`GitError::Io`]. Individual refs
78/// that fail to *resolve* (`RefNotFound`) are skipped as per-artifact misses,
79/// not bootstrap failures.
80fn collect_loose_refs_checked(
81    dir: &Path,
82    prefix: &str,
83    git_dir: &Path,
84    out: &mut Vec<(String, GitHash)>,
85) -> Result<()> {
86    let entries = match std::fs::read_dir(dir) {
87        Ok(e) => e,
88        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
89        Err(e) => return Err(GitError::Io(e)),
90    };
91    for entry in entries {
92        let entry = entry.map_err(GitError::Io)?;
93        let name = entry.file_name();
94        let Some(name) = name.to_str() else {
95            continue;
96        };
97        let refname = format!("{prefix}/{name}");
98        let path = entry.path();
99        if path.is_dir() {
100            collect_loose_refs_checked(&path, &refname, git_dir, out)?;
101        } else {
102            match resolve_ref(git_dir, &refname) {
103                Ok(hash) => out.push((refname, hash)),
104                Err(GitError::RefNotFound(_)) => {} // unresolvable ref: per-artifact miss
105                Err(e) => return Err(e),            // I/O error reading an existing ref
106            }
107        }
108    }
109    Ok(())
110}
111
112/// Resolve a ref name to its target hash.
113///
114/// Handles:
115/// - `HEAD` (may be symbolic or a detached commit hash)
116/// - `refs/heads/<branch>`
117/// - bare 40-hex strings
118pub fn resolve_ref(git_dir: &Path, refname: &str) -> Result<GitHash> {
119    if refname.len() == 40 && refname.chars().all(|c| c.is_ascii_hexdigit()) {
120        return GitHash::from_hex(refname);
121    }
122
123    let ref_path = git_dir.join(refname);
124    let content = std::fs::read_to_string(&ref_path).map_err(|e| {
125        if e.kind() == std::io::ErrorKind::NotFound {
126            GitError::RefNotFound(refname.to_string())
127        } else {
128            GitError::Io(e)
129        }
130    })?;
131
132    let content = content.trim();
133
134    // Symbolic ref: "ref: refs/heads/main"
135    if let Some(target) = content.strip_prefix("ref: ") {
136        return resolve_ref(git_dir, target);
137    }
138
139    GitHash::from_hex(content)
140        .map_err(|_| GitError::RefNotFound(format!("{refname}: invalid hash {content:?}")))
141}
142
143#[cfg(test)]
144mod refs_bootstrap_tests {
145    use super::*;
146
147    #[test]
148    fn list_refs_checked_errs_on_unreadable_refs() {
149        let tmp = tempfile::tempdir().unwrap();
150        // Corrupt repo: `refs` is a FILE where a directory is expected, so
151        // read_dir fails with a non-NotFound error. That I/O failure to enumerate
152        // refs MUST surface — swallowing it into empty roots makes the unreachable
153        // audit flag every object as orphaned (a false-positive flood).
154        std::fs::write(tmp.path().join("refs"), b"corrupt").unwrap();
155        assert!(
156            list_refs_checked(tmp.path()).is_err(),
157            "an unreadable refs/ must surface as an error, not empty refs"
158        );
159    }
160
161    #[test]
162    fn list_refs_checked_ok_when_refs_genuinely_absent() {
163        let tmp = tempfile::tempdir().unwrap();
164        // No refs/, no packed-refs, no HEAD → genuinely zero refs (a fresh/refless
165        // repo). The legitimate empty case must be Ok(empty), NOT an error — else
166        // we'd refuse on a genuinely all-orphaned repository.
167        assert_eq!(list_refs_checked(tmp.path()).unwrap(), Vec::new());
168    }
169}