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::merge_base;
17use crate::objects::ObjectId;
18use crate::refs;
19use crate::repo::Repository;
20
21/// A single reflog entry.
22#[derive(Debug, Clone)]
23pub struct ReflogEntry {
24    /// Previous object ID.
25    pub old_oid: ObjectId,
26    /// New object ID.
27    pub new_oid: ObjectId,
28    /// Identity string: `"Name <email> timestamp tz"`.
29    pub identity: String,
30    /// The log message.
31    pub message: String,
32}
33
34/// Return the filesystem path for a ref's reflog.
35pub fn reflog_path(git_dir: &Path, refname: &str) -> PathBuf {
36    git_dir.join("logs").join(refname)
37}
38
39/// Check whether a reflog exists for the given ref.
40pub fn reflog_exists(git_dir: &Path, refname: &str) -> bool {
41    if crate::reftable::is_reftable_repo(git_dir) {
42        return crate::reftable::reftable_reflog_exists(git_dir, refname);
43    }
44    let path = reflog_path(git_dir, refname);
45    path.is_file()
46}
47
48/// Read all reflog entries for the given ref, in file order (oldest first).
49///
50/// Returns an empty vec if the reflog file does not exist.
51pub fn read_reflog(git_dir: &Path, refname: &str) -> Result<Vec<ReflogEntry>> {
52    if crate::reftable::is_reftable_repo(git_dir) {
53        return crate::reftable::reftable_read_reflog(git_dir, refname);
54    }
55    let path = reflog_path(git_dir, refname);
56    let content = match fs::read_to_string(&path) {
57        Ok(c) => c,
58        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
59        Err(e) => return Err(Error::Io(e)),
60    };
61
62    let mut entries = Vec::new();
63    for line in content.lines() {
64        if line.is_empty() {
65            continue;
66        }
67        if let Some(entry) = parse_reflog_line(line) {
68            entries.push(entry);
69        }
70    }
71    Ok(entries)
72}
73
74/// Parse a single reflog line.
75///
76/// Format: `<old-hex> <new-hex> <identity>\t<message>`
77fn parse_reflog_line(line: &str) -> Option<ReflogEntry> {
78    // Split on tab first to separate identity from message
79    let (before_tab, message) = if let Some(pos) = line.find('\t') {
80        (&line[..pos], line[pos + 1..].to_string())
81    } else {
82        (line, String::new())
83    };
84
85    // The first 40 chars are old OID, then space, then 40 chars new OID, then space, then identity
86    if before_tab.len() < 83 {
87        // 40 + 1 + 40 + 1 + at least 1 char identity
88        return None;
89    }
90
91    let old_hex = &before_tab[..40];
92    let new_hex = &before_tab[41..81];
93    let identity = before_tab[82..].to_string();
94
95    let old_oid = old_hex.parse::<ObjectId>().ok()?;
96    let new_oid = new_hex.parse::<ObjectId>().ok()?;
97
98    Some(ReflogEntry {
99        old_oid,
100        new_oid,
101        identity,
102        message,
103    })
104}
105
106/// Delete specific reflog entries by index (0-based, newest-first order).
107///
108/// Rewrites the reflog file, omitting entries at the given indices.
109pub fn delete_reflog_entries(git_dir: &Path, refname: &str, indices: &[usize]) -> Result<()> {
110    let mut entries = read_reflog(git_dir, refname)?;
111    if entries.is_empty() {
112        return Ok(());
113    }
114
115    // Indices are in newest-first order (like show), so reverse the entries
116    // to map indices correctly.
117    entries.reverse();
118
119    let indices_set: std::collections::HashSet<usize> = indices.iter().copied().collect();
120
121    let path = reflog_path(git_dir, refname);
122    let remaining: Vec<&ReflogEntry> = entries
123        .iter()
124        .enumerate()
125        .filter(|(i, _)| !indices_set.contains(i))
126        .map(|(_, e)| e)
127        .collect();
128
129    // Write back in file order (oldest first), so reverse again
130    let mut lines = Vec::new();
131    for entry in remaining.iter().rev() {
132        lines.push(format_reflog_entry(entry));
133    }
134
135    fs::write(&path, lines.join(""))?;
136    Ok(())
137}
138
139/// Expire (prune) reflog entries older than a given timestamp (Unix seconds).
140///
141/// If `expire_time` is `None`, removes all entries.
142pub fn expire_reflog(git_dir: &Path, refname: &str, expire_time: Option<i64>) -> 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/// Expire reflog entries whose `new_oid` is not an ancestor of the current ref tip
171/// and whose identity timestamp is older than `cutoff` (Unix seconds).
172///
173/// Entries with an all-zero `new_oid` are never removed by this pass.
174///
175/// When `cutoff` is `None`, no entries are removed.
176///
177/// Reftable-backed repositories are skipped until reflog rewrite is implemented there.
178pub fn expire_reflog_unreachable(
179    repo: &Repository,
180    git_dir: &Path,
181    refname: &str,
182    cutoff: Option<i64>,
183) -> Result<usize> {
184    let Some(cutoff) = cutoff else {
185        return Ok(0);
186    };
187    if crate::reftable::is_reftable_repo(git_dir) {
188        return Ok(0);
189    }
190    let tip = match refs::resolve_ref(git_dir, refname) {
191        Ok(o) => o,
192        Err(_) => return Ok(0),
193    };
194    let ancestors = match merge_base::ancestor_closure(repo, tip) {
195        Ok(a) => a,
196        Err(_) => return Ok(0),
197    };
198
199    let entries = read_reflog(git_dir, refname)?;
200    if entries.is_empty() {
201        return Ok(0);
202    }
203
204    let path = reflog_path(git_dir, refname);
205    let mut kept = Vec::new();
206    let mut pruned = 0usize;
207
208    for entry in &entries {
209        let ts = parse_timestamp_from_identity(&entry.identity);
210        let unreachable = !entry.new_oid.is_zero() && !ancestors.contains(&entry.new_oid);
211        let should_prune = unreachable && matches!(ts, Some(t) if t < cutoff);
212        if should_prune {
213            pruned += 1;
214        } else {
215            kept.push(format_reflog_entry(entry));
216        }
217    }
218
219    fs::write(&path, kept.join(""))?;
220    Ok(pruned)
221}
222
223/// Format a reflog entry back into the on-disk line format.
224fn format_reflog_entry(entry: &ReflogEntry) -> String {
225    format!(
226        "{} {} {}\t{}\n",
227        entry.old_oid, entry.new_oid, entry.identity, entry.message
228    )
229}
230
231/// Extract the Unix timestamp from an identity string.
232///
233/// Identity format: `Name <email> <timestamp> <tz>`
234fn parse_timestamp_from_identity(identity: &str) -> Option<i64> {
235    // Walk backwards: last token is tz (+0000), second-to-last is timestamp
236    let parts: Vec<&str> = identity.rsplitn(3, ' ').collect();
237    if parts.len() >= 2 {
238        parts[1].parse::<i64>().ok()
239    } else {
240        None
241    }
242}
243
244/// List all refs that have reflogs.
245pub fn list_reflog_refs(git_dir: &Path) -> Result<Vec<String>> {
246    let logs_dir = git_dir.join("logs");
247    let mut refs = Vec::new();
248
249    // Check HEAD
250    if logs_dir.join("HEAD").is_file() {
251        refs.push("HEAD".to_string());
252    }
253
254    // Walk logs/refs/
255    let refs_logs = logs_dir.join("refs");
256    if refs_logs.is_dir() {
257        collect_reflog_refs(&refs_logs, "refs", &mut refs)?;
258    }
259
260    Ok(refs)
261}
262
263fn collect_reflog_refs(dir: &Path, prefix: &str, out: &mut Vec<String>) -> Result<()> {
264    let read_dir = match fs::read_dir(dir) {
265        Ok(rd) => rd,
266        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
267        Err(e) => return Err(Error::Io(e)),
268    };
269
270    for entry in read_dir {
271        let entry = entry.map_err(Error::Io)?;
272        let name = entry.file_name().to_string_lossy().to_string();
273        let full_name = format!("{prefix}/{name}");
274        let ft = entry.file_type().map_err(Error::Io)?;
275        if ft.is_dir() {
276            collect_reflog_refs(&entry.path(), &full_name, out)?;
277        } else if ft.is_file() {
278            out.push(full_name);
279        }
280    }
281    Ok(())
282}