Skip to main content

git_forensic/
reflog.rs

1//! Reflog-residue analysis: history-rewriting operations recorded in the reflog.
2//!
3//! Git stamps every ref movement into `.git/logs/<ref>` with a message naming
4//! the operation that moved it (`commit:`, `reset:`, `rebase (finish):`,
5//! `filter-branch:`, `commit (amend):`, `… (forced update)`). A message whose
6//! operation rewrites history is residue an examiner follows — the original tip
7//! is still resurrectable from the reflog and the object store — never a verdict.
8
9use forensicnomicon::report::{Category, Evidence, Observation, Severity};
10use git_core::{GitHash, GitRepo, ReflogEntry, Result};
11
12/// A history-rewriting operation observed in a ref's reflog.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum ReflogAnomaly {
16    /// A reflog entry whose operation rewrote history (reset/rebase/amend/…).
17    HistoryRewrite {
18        /// The ref whose reflog this entry belongs to (e.g. `HEAD`).
19        ref_name: String,
20        /// The ref's value before the rewrite.
21        old: GitHash,
22        /// The ref's value after the rewrite.
23        new: GitHash,
24        /// The classified operation keyword (`reset`, `rebase`, `amend`, …).
25        operation: String,
26        /// The full reflog message.
27        message: String,
28    },
29}
30
31impl ReflogAnomaly {
32    /// The stable, published anomaly code.
33    #[must_use]
34    pub fn code(&self) -> &'static str {
35        match self {
36            Self::HistoryRewrite { .. } => "GIT-HISTORY-REWRITE",
37        }
38    }
39}
40
41impl Observation for ReflogAnomaly {
42    fn severity(&self) -> Option<Severity> {
43        match self {
44            // A rewrite is a routine developer action as often as it is an
45            // attempt to bury history, so it is a Medium-grade lead, not High.
46            Self::HistoryRewrite { .. } => Some(Severity::Medium),
47        }
48    }
49
50    fn code(&self) -> &'static str {
51        ReflogAnomaly::code(self)
52    }
53
54    fn category(&self) -> Category {
55        // Rewriting a ref's past is a History signal.
56        Category::History
57    }
58
59    fn note(&self) -> String {
60        match self {
61            Self::HistoryRewrite {
62                ref_name,
63                operation,
64                ..
65            } => format!(
66                "reflog of {ref_name} records a {operation} operation; consistent \
67                 with history rewriting (the prior tip remains resurrectable from \
68                 the reflog and object store)"
69            ),
70        }
71    }
72
73    fn evidence(&self) -> Vec<Evidence> {
74        match self {
75            Self::HistoryRewrite {
76                old, new, message, ..
77            } => vec![
78                Evidence {
79                    field: "old".into(),
80                    value: old.to_hex(),
81                    location: None,
82                },
83                Evidence {
84                    field: "new".into(),
85                    value: new.to_hex(),
86                    location: None,
87                },
88                Evidence {
89                    field: "message".into(),
90                    value: message.clone(),
91                    location: None,
92                },
93            ],
94        }
95    }
96}
97
98/// Classify a reflog message as a history-rewriting operation, returning the
99/// operation keyword if it is one (pure; the analyzer's decision core).
100///
101/// Recognized rewrites: `reset:`, any `rebase` variant, `filter-branch`,
102/// `commit (amend)`, and a trailing `(forced update)` (e.g. a force-push).
103#[must_use]
104pub fn classify_rewrite(message: &str) -> Option<&'static str> {
105    if message.starts_with("reset:") {
106        Some("reset")
107    } else if message.contains("commit (amend)") {
108        Some("amend")
109    } else if message.contains("filter-branch") {
110        Some("filter-branch")
111    } else if message.contains("rebase") {
112        Some("rebase")
113    } else if message.contains("(forced update)") {
114        Some("forced-update")
115    } else {
116        None
117    }
118}
119
120/// Audit a set of reflog entries for `ref_name`, flagging history rewrites
121/// (pure; side-effect free).
122#[must_use]
123pub fn audit_reflog_entries(ref_name: &str, entries: &[ReflogEntry]) -> Vec<ReflogAnomaly> {
124    entries
125        .iter()
126        .filter_map(|e| {
127            classify_rewrite(&e.message).map(|operation| ReflogAnomaly::HistoryRewrite {
128                ref_name: ref_name.to_string(),
129                old: e.old,
130                new: e.new,
131                operation: operation.to_string(),
132                message: e.message.clone(),
133            })
134        })
135        .collect()
136}
137
138/// Read `refname`'s reflog from `repo` and audit it for history rewrites.
139///
140/// # Errors
141/// Propagates any [`git_core`] error encountered reading the reflog.
142pub fn audit_reflog(repo: &GitRepo, refname: &str) -> Result<Vec<ReflogAnomaly>> {
143    let entries = repo.reflog(refname)?;
144    Ok(audit_reflog_entries(refname, &entries))
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn classifies_each_rewrite_kind() {
153        assert_eq!(classify_rewrite("reset: moving to HEAD~1"), Some("reset"));
154        assert_eq!(classify_rewrite("rebase (finish): refs/heads/x"), Some("rebase"));
155        assert_eq!(classify_rewrite("rebase -i (start): x"), Some("rebase"));
156        assert_eq!(classify_rewrite("filter-branch: rewrite"), Some("filter-branch"));
157        assert_eq!(classify_rewrite("commit (amend): reworded"), Some("amend"));
158        assert_eq!(
159            classify_rewrite("update by push (forced update)"),
160            Some("forced-update")
161        );
162    }
163
164    #[test]
165    fn does_not_flag_ordinary_operations() {
166        assert!(classify_rewrite("commit: add feature").is_none());
167        assert!(classify_rewrite("commit (initial): first").is_none());
168        assert!(classify_rewrite("merge topic: Fast-forward").is_none());
169        assert!(classify_rewrite("checkout: moving from a to b").is_none());
170        assert!(classify_rewrite("clone: from https://x").is_none());
171    }
172
173    fn entry(message: &str) -> ReflogEntry {
174        ReflogEntry {
175            old: GitHash::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap(),
176            new: GitHash::from_hex("89abcdef0123456789abcdef0123456789abcdef").unwrap(),
177            name: "A".into(),
178            email: "a@b.x".into(),
179            timestamp: 100,
180            tz_offset: "+0000".into(),
181            message: message.into(),
182        }
183    }
184
185    #[test]
186    fn audit_flags_only_rewrites() {
187        let entries = vec![
188            entry("commit (initial): first"),
189            entry("commit: second"),
190            entry("reset: moving to HEAD~1"),
191        ];
192        let found = audit_reflog_entries("HEAD", &entries);
193        assert_eq!(found.len(), 1);
194        let ReflogAnomaly::HistoryRewrite { operation, .. } = &found[0];
195        assert_eq!(operation, "reset");
196    }
197}