Skip to main content

git_forensic/
unreachable.rs

1//! Reachability analysis: objects present in the store but reachable from no
2//! ref. Such an object is residue of deleted or rewritten history — it remains
3//! resurrectable from the object store until garbage-collected. Commits are the
4//! most telling (a whole dropped line of development); blobs and trees are
5//! lower-signal. An examiner follows these leads; they are never a verdict.
6
7use std::collections::HashSet;
8
9use forensicnomicon::report::{Category, Evidence, Observation, Severity};
10use git_core::{GitHash, GitRepo, ObjectKind, Result};
11
12/// An object present in the store yet reachable from no ref.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct UnreachableObject {
15    /// The unreachable object's hash.
16    pub object: GitHash,
17    /// Its kind as a lowercase git type (`commit`, `tree`, `blob`, `tag`).
18    pub kind: String,
19}
20
21impl Observation for UnreachableObject {
22    fn severity(&self) -> Option<Severity> {
23        // A resurrectable dropped commit (deleted/rewritten history) is the
24        // sharper lead; a loose blob/tree is lower signal.
25        if self.kind == "commit" {
26            Some(Severity::Medium)
27        } else {
28            Some(Severity::Low)
29        }
30    }
31
32    fn code(&self) -> &'static str {
33        "GIT-UNREACHABLE-OBJECT"
34    }
35
36    fn category(&self) -> Category {
37        // Residue of deleted/rewritten history.
38        Category::Residue
39    }
40
41    fn note(&self) -> String {
42        format!(
43            "{} object reachable from no ref; consistent with deleted or rewritten \
44             history (the object remains resurrectable from the store until gc)",
45            self.kind
46        )
47    }
48
49    fn evidence(&self) -> Vec<Evidence> {
50        vec![
51            Evidence {
52                field: "object".into(),
53                value: self.object.to_hex(),
54                location: None,
55            },
56            Evidence {
57                field: "kind".into(),
58                value: self.kind.clone(),
59                location: None,
60            },
61        ]
62    }
63}
64
65fn kind_str(kind: ObjectKind) -> &'static str {
66    match kind {
67        ObjectKind::Commit => "commit",
68        ObjectKind::Tree => "tree",
69        ObjectKind::Blob => "blob",
70        ObjectKind::Tag => "tag",
71    }
72}
73
74/// Compute the set of objects reachable from every ref tip.
75///
76/// Walks commit → parents + tree, tree → entries (subtrees + blobs). A tag is
77/// opaque (git-core does not parse tag targets), so a tag tip contributes only
78/// itself; this is sound for the common branch/HEAD tips used in practice.
79///
80/// Objects that fail to read or parse are recorded as reached-but-not-expanded
81/// rather than aborting the walk — robustness on a damaged store beats a hard
82/// failure.
83///
84/// # Errors
85/// Propagates a [`git_core::GitError::Io`] if the ref roots cannot be enumerated. This is
86/// a *bootstrap* failure and MUST NOT be swallowed into an empty root set, which
87/// would make every object look unreachable (a false-positive inversion).
88fn reachable_set(repo: &GitRepo) -> Result<HashSet<GitHash>> {
89    let mut reached = HashSet::new();
90    let mut stack: Vec<GitHash> = repo
91        .all_refs_checked()?
92        .into_iter()
93        .map(|(_, h)| h)
94        .collect();
95
96    while let Some(hash) = stack.pop() {
97        if !reached.insert(hash) {
98            continue;
99        }
100        let Ok(obj) = repo.read_object(&hash) else {
101            continue;
102        };
103        match obj.kind {
104            ObjectKind::Commit => {
105                if let Ok(commit) = repo.read_commit(&hash) {
106                    stack.push(commit.tree);
107                    stack.extend(commit.parents);
108                }
109            }
110            ObjectKind::Tree => {
111                if let Ok(tree) = repo.read_tree(&hash) {
112                    stack.extend(tree.entries.into_iter().map(|e| e.hash));
113                }
114            }
115            // Blobs are leaves; tags are opaque here.
116            ObjectKind::Blob | ObjectKind::Tag => {}
117        }
118    }
119    Ok(reached)
120}
121
122/// Audit `repo` for objects reachable from no ref (`all_objects − reachable`).
123///
124/// # Errors
125/// Propagates a [`git_core`] error from ref enumeration or object enumeration.
126/// A ref-enumeration failure is surfaced rather than misreported as every
127/// object being unreachable.
128pub fn audit_unreachable(repo: &GitRepo) -> Result<Vec<UnreachableObject>> {
129    let reached = reachable_set(repo)?;
130    let mut out = Vec::new();
131    for hash in repo.all_objects()? {
132        if reached.contains(&hash) {
133            continue;
134        }
135        // Determine the kind for grading; an unreadable object is reported as
136        // "unknown" rather than dropped, so its presence is still surfaced.
137        let kind = repo
138            .read_object(&hash)
139            .map_or("unknown", |o| kind_str(o.kind))
140            .to_string();
141        out.push(UnreachableObject { object: hash, kind });
142    }
143    Ok(out)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn note_and_grading_depend_on_kind() {
152        let commit = UnreachableObject {
153            object: GitHash::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap(),
154            kind: "commit".into(),
155        };
156        let blob = UnreachableObject {
157            object: GitHash::from_hex("89abcdef0123456789abcdef0123456789abcdef").unwrap(),
158            kind: "blob".into(),
159        };
160        assert_eq!(commit.severity(), Some(Severity::Medium));
161        assert_eq!(blob.severity(), Some(Severity::Low));
162        assert_eq!(commit.code(), "GIT-UNREACHABLE-OBJECT");
163        assert_eq!(commit.category(), Category::Residue);
164        assert!(commit.note().contains("commit"));
165    }
166
167    /// The inversion guard: when ref enumeration FAILS (a bootstrap failure), the
168    /// audit must ERROR, not silently treat the empty root set as "everything is
169    /// unreachable" (which would flag every object in the store as orphaned — a
170    /// false-positive flood indistinguishable from a genuinely all-orphaned repo).
171    #[test]
172    fn audit_unreachable_errs_when_ref_bootstrap_fails() {
173        let tmp = tempfile::tempdir().unwrap();
174        std::fs::write(tmp.path().join("HEAD"), b"ref: refs/heads/main\n").unwrap();
175        std::fs::create_dir(tmp.path().join("objects")).unwrap();
176        // Corrupt the refs subsystem: `refs` is a FILE, so enumeration fails.
177        std::fs::write(tmp.path().join("refs"), b"corrupt").unwrap();
178        let repo = GitRepo::open(tmp.path()).unwrap();
179        assert!(
180            audit_unreachable(&repo).is_err(),
181            "a failed ref bootstrap must error, not report all objects unreachable"
182        );
183    }
184}