Skip to main content

grit_lib/
reflog.rs

1//! Reflog reading and management.
2//!
3//! The reflog records updates to refs.  Each ref's log is stored at
4//! `<git-dir>/logs/<refname>` (e.g. `logs/HEAD`, `logs/refs/heads/main`).
5//! Each line has the format:
6//!
7//! ```text
8//! <old-sha> <new-sha> <name> <<email>> <timestamp> <timezone>\t<message>
9//! ```
10
11use std::fs;
12use std::io;
13use std::path::{Path, PathBuf};
14
15use crate::error::{Error, Result};
16use crate::objects::ObjectId;
17
18/// A single reflog entry.
19#[derive(Debug, Clone)]
20pub struct ReflogEntry {
21    /// Previous object ID.
22    pub old_oid: ObjectId,
23    /// New object ID.
24    pub new_oid: ObjectId,
25    /// Identity string: `"Name <email> timestamp tz"`.
26    pub identity: String,
27    /// The log message.
28    pub message: String,
29}
30
31/// Return the filesystem path for a ref's reflog.
32pub fn reflog_path(git_dir: &Path, refname: &str) -> PathBuf {
33    git_dir.join("logs").join(refname)
34}
35
36/// Check whether a reflog exists for the given ref.
37pub fn reflog_exists(git_dir: &Path, refname: &str) -> bool {
38    let path = reflog_path(git_dir, refname);
39    path.is_file()
40}
41
42/// Read all reflog entries for the given ref, in file order (oldest first).
43///
44/// Returns an empty vec if the reflog file does not exist.
45pub fn read_reflog(git_dir: &Path, refname: &str) -> Result<Vec<ReflogEntry>> {
46    let path = reflog_path(git_dir, refname);
47    let content = match fs::read_to_string(&path) {
48        Ok(c) => c,
49        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
50        Err(e) => return Err(Error::Io(e)),
51    };
52
53    let mut entries = Vec::new();
54    for line in content.lines() {
55        if line.is_empty() {
56            continue;
57        }
58        if let Some(entry) = parse_reflog_line(line) {
59            entries.push(entry);
60        }
61    }
62    Ok(entries)
63}
64
65/// Parse a single reflog line.
66///
67/// Format: `<old-hex> <new-hex> <identity>\t<message>`
68fn parse_reflog_line(line: &str) -> Option<ReflogEntry> {
69    // Split on tab first to separate identity from message
70    let (before_tab, message) = if let Some(pos) = line.find('\t') {
71        (&line[..pos], line[pos + 1..].to_string())
72    } else {
73        (line, String::new())
74    };
75
76    // The first 40 chars are old OID, then space, then 40 chars new OID, then space, then identity
77    if before_tab.len() < 83 {
78        // 40 + 1 + 40 + 1 + at least 1 char identity
79        return None;
80    }
81
82    let old_hex = &before_tab[..40];
83    let new_hex = &before_tab[41..81];
84    let identity = before_tab[82..].to_string();
85
86    let old_oid = old_hex.parse::<ObjectId>().ok()?;
87    let new_oid = new_hex.parse::<ObjectId>().ok()?;
88
89    Some(ReflogEntry {
90        old_oid,
91        new_oid,
92        identity,
93        message,
94    })
95}
96
97/// Delete specific reflog entries by index (0-based, newest-first order).
98///
99/// Rewrites the reflog file, omitting entries at the given indices.
100pub fn delete_reflog_entries(
101    git_dir: &Path,
102    refname: &str,
103    indices: &[usize],
104) -> Result<()> {
105    let mut entries = read_reflog(git_dir, refname)?;
106    if entries.is_empty() {
107        return Ok(());
108    }
109
110    // Indices are in newest-first order (like show), so reverse the entries
111    // to map indices correctly.
112    entries.reverse();
113
114    let indices_set: std::collections::HashSet<usize> =
115        indices.iter().copied().collect();
116
117    let path = reflog_path(git_dir, refname);
118    let remaining: Vec<&ReflogEntry> = entries
119        .iter()
120        .enumerate()
121        .filter(|(i, _)| !indices_set.contains(i))
122        .map(|(_, e)| e)
123        .collect();
124
125    // Write back in file order (oldest first), so reverse again
126    let mut lines = Vec::new();
127    for entry in remaining.iter().rev() {
128        lines.push(format_reflog_entry(entry));
129    }
130
131    fs::write(&path, lines.join(""))?;
132    Ok(())
133}
134
135/// Expire (prune) reflog entries older than a given timestamp (Unix seconds).
136///
137/// If `expire_time` is `None`, removes all entries.
138pub fn expire_reflog(
139    git_dir: &Path,
140    refname: &str,
141    expire_time: Option<i64>,
142) -> Result<usize> {
143    let entries = read_reflog(git_dir, refname)?;
144    if entries.is_empty() {
145        return Ok(0);
146    }
147
148    let path = reflog_path(git_dir, refname);
149    let mut kept = Vec::new();
150    let mut pruned = 0usize;
151
152    for entry in &entries {
153        let ts = parse_timestamp_from_identity(&entry.identity);
154        let dominated = match (expire_time, ts) {
155            (Some(cutoff), Some(t)) => t < cutoff,
156            (None, _) => true, // expire all
157            (Some(_), None) => false, // can't parse => keep
158        };
159        if dominated {
160            pruned += 1;
161        } else {
162            kept.push(format_reflog_entry(entry));
163        }
164    }
165
166    fs::write(&path, kept.join(""))?;
167    Ok(pruned)
168}
169
170/// Format a reflog entry back into the on-disk line format.
171fn format_reflog_entry(entry: &ReflogEntry) -> String {
172    format!(
173        "{} {} {}\t{}\n",
174        entry.old_oid, entry.new_oid, entry.identity, entry.message
175    )
176}
177
178/// Extract the Unix timestamp from an identity string.
179///
180/// Identity format: `Name <email> <timestamp> <tz>`
181fn parse_timestamp_from_identity(identity: &str) -> Option<i64> {
182    // Walk backwards: last token is tz (+0000), second-to-last is timestamp
183    let parts: Vec<&str> = identity.rsplitn(3, ' ').collect();
184    if parts.len() >= 2 {
185        parts[1].parse::<i64>().ok()
186    } else {
187        None
188    }
189}
190
191/// List all refs that have reflogs.
192pub fn list_reflog_refs(git_dir: &Path) -> Result<Vec<String>> {
193    let logs_dir = git_dir.join("logs");
194    let mut refs = Vec::new();
195
196    // Check HEAD
197    if logs_dir.join("HEAD").is_file() {
198        refs.push("HEAD".to_string());
199    }
200
201    // Walk logs/refs/
202    let refs_logs = logs_dir.join("refs");
203    if refs_logs.is_dir() {
204        collect_reflog_refs(&refs_logs, "refs", &mut refs)?;
205    }
206
207    Ok(refs)
208}
209
210fn collect_reflog_refs(dir: &Path, prefix: &str, out: &mut Vec<String>) -> Result<()> {
211    let read_dir = match fs::read_dir(dir) {
212        Ok(rd) => rd,
213        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
214        Err(e) => return Err(Error::Io(e)),
215    };
216
217    for entry in read_dir {
218        let entry = entry.map_err(Error::Io)?;
219        let name = entry.file_name().to_string_lossy().to_string();
220        let full_name = format!("{prefix}/{name}");
221        let ft = entry.file_type().map_err(Error::Io)?;
222        if ft.is_dir() {
223            collect_reflog_refs(&entry.path(), &full_name, out)?;
224        } else if ft.is_file() {
225            out.push(full_name);
226        }
227    }
228    Ok(())
229}