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}