Skip to main content

git_core/
reflog.rs

1//! Reflog reading (`.git/logs/<ref>`), git's local record of every ref movement.
2//!
3//! Each line has the form
4//! `<oldsha40> <newsha40> <name> <email> <unix_ts> <tzoffset>\t<message>\n`.
5//! The tab before the message is the reliable separator; the identity prefix is
6//! parsed git-style (the email lives in `<...>`, so a name may contain spaces).
7//!
8//! Reference: git `Documentation/gitrevisions.txt` (reflog syntax) and
9//! `Documentation/git-reflog.txt`.
10
11use std::path::Path;
12
13use crate::error::Result;
14use crate::hash::GitHash;
15
16/// One reflog line: a single movement of a ref from `old` to `new`.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ReflogEntry {
19    /// The ref's value before the movement (all-zero for the very first write).
20    pub old: GitHash,
21    /// The ref's value after the movement.
22    pub new: GitHash,
23    /// Name of the committer who performed the movement.
24    pub name: String,
25    /// Email of the committer who performed the movement.
26    pub email: String,
27    /// Unix timestamp (seconds since epoch) of the movement.
28    pub timestamp: i64,
29    /// Timezone offset as recorded (e.g. `+0800`), kept verbatim.
30    pub tz_offset: String,
31    /// The operation message (e.g. `commit: x`, `reset: moving to HEAD~1`).
32    pub message: String,
33}
34
35/// Parse a whole reflog file's bytes into entries.
36///
37/// Malformed lines are skipped rather than fatal: a reflog is local, mutable,
38/// and may be partially truncated, so robustness beats strictness here. This
39/// never panics regardless of input.
40#[must_use]
41pub fn parse_reflog(bytes: &[u8]) -> Vec<ReflogEntry> {
42    let text = String::from_utf8_lossy(bytes);
43    text.lines().filter_map(parse_line).collect()
44}
45
46/// Parse a single reflog line, returning `None` if it is malformed.
47fn parse_line(line: &str) -> Option<ReflogEntry> {
48    // The message is everything after the first tab.
49    let (prefix, message) = line.split_once('\t')?;
50
51    // Prefix: "<oldsha> <newsha> <name> <email> <ts> <tz>".
52    // The email is delimited by '<' .. '>', so split around it to tolerate a
53    // name containing spaces.
54    let email_start = prefix.find('<')?;
55    let email_end = prefix[email_start..].find('>')? + email_start;
56
57    let head = prefix.get(..email_start)?.trim_end();
58    let email = prefix.get(email_start + 1..email_end)?.to_string();
59    let tail = prefix.get(email_end + 1..)?.trim();
60
61    // head = "<oldsha> <newsha> <name>"
62    let mut head_parts = head.splitn(3, ' ');
63    let old = GitHash::from_hex(head_parts.next()?).ok()?;
64    let new = GitHash::from_hex(head_parts.next()?).ok()?;
65    let name = head_parts.next()?.trim().to_string();
66
67    // tail = "<ts> <tz>"
68    let mut tail_parts = tail.split_whitespace();
69    let timestamp: i64 = tail_parts.next()?.parse().ok()?;
70    let tz_offset = tail_parts.next().unwrap_or("+0000").to_string();
71
72    Some(ReflogEntry {
73        old,
74        new,
75        name,
76        email,
77        timestamp,
78        tz_offset,
79        message: message.to_string(),
80    })
81}
82
83/// Read and parse `.git/logs/<refname>` for `git_dir`.
84///
85/// Returns an empty vec (not an error) when the log file is absent — git only
86/// creates a log once a ref has moved, so absence is normal, not a failure.
87///
88/// # Errors
89/// Propagates a non-`NotFound` I/O error encountered reading the log file.
90pub fn read_reflog(git_dir: &Path, refname: &str) -> Result<Vec<ReflogEntry>> {
91    let path = git_dir.join("logs").join(refname);
92    match std::fs::read(&path) {
93        Ok(bytes) => Ok(parse_reflog(&bytes)),
94        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
95        Err(e) => Err(e.into()),
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn parses_a_basic_line() {
105        let e = parse_line(
106            "0000000000000000000000000000000000000000 \
107             3abc579ce97f2484371fbe6e52d1fa43699479b5 A <a@b.x> 100 +0000\tcommit: x",
108        )
109        .unwrap();
110        assert_eq!(e.name, "A");
111        assert_eq!(e.email, "a@b.x");
112        assert_eq!(e.timestamp, 100);
113        assert_eq!(e.message, "commit: x");
114    }
115
116    #[test]
117    fn rejects_a_line_without_a_tab() {
118        assert!(parse_line("no tab here").is_none());
119    }
120
121    #[test]
122    fn rejects_a_line_without_an_email() {
123        assert!(parse_line("aaa bbb name 100 +0000\tmsg").is_none());
124    }
125
126    #[test]
127    fn rejects_a_short_hash() {
128        assert!(parse_line("dead beef A <a@b.x> 100 +0000\tmsg").is_none());
129    }
130
131    #[test]
132    fn rejects_a_non_numeric_timestamp() {
133        assert!(parse_line(
134            "0000000000000000000000000000000000000000 \
135             3abc579ce97f2484371fbe6e52d1fa43699479b5 A <a@b.x> nope +0000\tmsg"
136        )
137        .is_none());
138    }
139
140    #[test]
141    fn defaults_missing_tz_to_utc() {
142        let e = parse_line(
143            "0000000000000000000000000000000000000000 \
144             3abc579ce97f2484371fbe6e52d1fa43699479b5 A <a@b.x> 100\tmsg",
145        )
146        .unwrap();
147        assert_eq!(e.tz_offset, "+0000");
148    }
149}