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::cell::{OnceCell, RefCell};
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::fs::File;
15use std::io;
16use std::io::{Read, Seek, SeekFrom};
17use std::ops::ControlFlow;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use crate::commit_graph_file::CommitGraphChain;
22use crate::config::ConfigSet;
23use crate::error::{Error, Result};
24use crate::ident::Signature;
25use crate::index::MODE_TREE;
26use crate::merge_base;
27use crate::objects::{parse_commit, parse_tree, CommitRef, ObjectId, ObjectKind, OidHashMap};
28use crate::refs::store::{RefStore as _, RefStoreError};
29use crate::refs::{self, reflog_file_path};
30use crate::repo::Repository;
31use crate::wildmatch::{wildmatch, WM_PATHNAME};
32
33mod selector;
34pub use selector::{parse_reflog_ref, ReflogSelector};
35
36/// A single reflog entry.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ReflogEntry {
39    /// Previous object ID.
40    pub old_oid: ObjectId,
41    /// New object ID.
42    pub new_oid: ObjectId,
43    /// Identity string: `"Name <email> timestamp tz"`.
44    pub identity: String,
45    /// The log message.
46    pub message: String,
47}
48
49impl ReflogEntry {
50    /// Parsed committer identity and timestamp from [`Self::identity`].
51    ///
52    /// Returns [`None`] when the identity line is malformed (no sentinel timestamp).
53    #[must_use]
54    pub fn signature(&self) -> Option<Signature> {
55        crate::ident::parse_signature(&self.identity)
56    }
57
58    /// Best-effort action prefix parsed from [`Self::message`].
59    ///
60    /// Returns the substring before the first `": "` when present (for example `commit`,
61    /// `commit (amend)`, `checkout`, `reset`, `rebase (finish)`, `merge branch`).
62    /// Reflog messages are free-form text; callers must not rely on this for correctness.
63    #[must_use]
64    pub fn action(&self) -> Option<&str> {
65        self.message.split_once(": ").map(|(prefix, _)| prefix)
66    }
67}
68
69/// Case-sensitive substring filter for reflog message text (`--grep`).
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct MessageMatcher {
72    needle: String,
73}
74
75impl MessageMatcher {
76    /// Build a matcher that succeeds when the message contains `pattern` verbatim.
77    #[must_use]
78    pub fn new(pattern: impl Into<String>) -> Self {
79        Self {
80            needle: pattern.into(),
81        }
82    }
83
84    /// Returns whether `message` contains the configured pattern.
85    #[must_use]
86    pub fn matches(&self, message: &str) -> bool {
87        message.contains(&self.needle)
88    }
89}
90
91/// Return the filesystem path for a ref's reflog.
92///
93/// Uses the same storage rules as [`refs::append_reflog`] (branch reflogs under the
94/// repository common directory for linked worktrees).
95pub fn reflog_path(git_dir: &Path, refname: &str) -> PathBuf {
96    reflog_file_path(git_dir, refname)
97}
98
99/// Remove the last line from a loose-ref reflog file, if present.
100///
101/// Used when rolling back a branch update after a failed `HEAD` reflog append.
102///
103/// # Errors
104///
105/// Returns I/O errors from reading or writing the reflog file.
106pub fn truncate_last_reflog_line(git_dir: &Path, refname: &str) -> Result<()> {
107    if crate::refs::store::open_ref_store(git_dir)?.format()
108        != crate::refs::store::RefStorageFormat::Files
109    {
110        return Ok(());
111    }
112    let path = reflog_path(git_dir, refname);
113    let Ok(mut content) = fs::read_to_string(&path) else {
114        return Ok(());
115    };
116    if content.is_empty() {
117        return Ok(());
118    }
119    if content.ends_with('\n') {
120        content.pop();
121        if content.ends_with('\r') {
122            content.pop();
123        }
124    }
125    if let Some(pos) = content.rfind('\n') {
126        content.truncate(pos + 1);
127    } else {
128        content.clear();
129    }
130    if content.is_empty() {
131        let _ = fs::remove_file(&path);
132    } else {
133        fs::write(&path, content)?;
134    }
135    Ok(())
136}
137
138/// Apply `core.sharedRepository` permissions to a rewritten reflog file, matching Git's
139/// `adjust_shared_perm` call in `files_reflog_expire`. Best-effort: ignores config and FS errors.
140fn adjust_reflog_shared_perm(git_dir: &Path, path: &Path) {
141    let Ok(config) = ConfigSet::load(
142        &crate::environment::Environment::empty(),
143        Some(git_dir),
144        true,
145    ) else {
146        return;
147    };
148    let raw = config.get("core.sharedRepository");
149    let Ok(perm) = crate::shared_repo::shared_repository_from_config_value(raw.as_deref()) else {
150        return;
151    };
152    if perm != 0 {
153        let _ = crate::shared_repo::adjust_shared_perm_path(perm, path);
154    }
155}
156
157/// Check whether a reflog exists for the given ref.
158pub fn reflog_exists(git_dir: &Path, refname: &str) -> bool {
159    if let Ok(store) = crate::refs::store::open_ref_store(git_dir) {
160        if store.format() != crate::refs::store::RefStorageFormat::Files {
161            return store.reflog_exists(refname).unwrap_or(false);
162        }
163    }
164    let path = reflog_path(git_dir, refname);
165    path.is_file()
166}
167
168/// Read a reflog using Git's loose ref DWIM rules when the direct path is missing.
169///
170/// Tries `refname`, then `refs/<refname>`, then `refs/heads/<refname>` (when `refname` is not
171/// already under `refs/`). Matches `read_complete_reflog` in Git's `reflog-walk.c`.
172pub fn read_reflog_dwim(git_dir: &Path, refname: &str) -> Result<Vec<ReflogEntry>> {
173    let mut entries = read_reflog(git_dir, refname)?;
174    if !entries.is_empty() {
175        return Ok(entries);
176    }
177    if !refname.starts_with("refs/") {
178        entries = read_reflog(git_dir, &format!("refs/{refname}"))?;
179        if !entries.is_empty() {
180            return Ok(entries);
181        }
182        entries = read_reflog(git_dir, &format!("refs/heads/{refname}"))?;
183    }
184    Ok(entries)
185}
186
187/// Read all reflog entries for the given ref, in file order (oldest first).
188///
189/// Returns an empty vec if the reflog file does not exist.
190pub fn read_reflog(git_dir: &Path, refname: &str) -> Result<Vec<ReflogEntry>> {
191    if crate::refs::store::open_ref_store(git_dir)?.format()
192        != crate::refs::store::RefStorageFormat::Files
193    {
194        let store = crate::refs::store::ReftableRefStore::open(git_dir.to_path_buf())?;
195        let mut entries = Vec::new();
196        store.for_each_reflog_entry(refname, false, &mut |entry| {
197            entries.push(entry.clone());
198            std::ops::ControlFlow::Continue(())
199        })?;
200        return Ok(entries);
201    }
202    let path = reflog_path(git_dir, refname);
203    let content = match fs::read_to_string(&path) {
204        Ok(c) => c,
205        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
206        Err(e) => return Err(Error::Io(e)),
207    };
208
209    let mut entries = Vec::new();
210    for line in content.lines() {
211        if line.is_empty() {
212            continue;
213        }
214        if let Some(entry) = parse_reflog_line(line) {
215            entries.push(entry);
216        }
217    }
218    Ok(entries)
219}
220
221/// Count reflog entries for `refname` without parsing or retaining them.
222///
223/// Returns `0` when the reflog file is missing.
224pub(crate) fn reflog_entry_count(git_dir: &Path, refname: &str) -> Result<usize> {
225    if crate::refs::store::open_ref_store(git_dir)?.format()
226        != crate::refs::store::RefStorageFormat::Files
227    {
228        let store = crate::refs::store::ReftableRefStore::open(git_dir.to_path_buf())?;
229        let mut count = 0usize;
230        store.for_each_reflog_entry(refname, false, &mut |_| {
231            count += 1;
232            std::ops::ControlFlow::Continue(())
233        })?;
234        return Ok(count);
235    }
236    let path = reflog_path(git_dir, refname);
237    let content = match fs::read_to_string(&path) {
238        Ok(c) => c,
239        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
240        Err(e) => return Err(Error::Io(e)),
241    };
242    Ok(content.lines().filter(|line| !line.is_empty()).count())
243}
244
245const REFLOG_REVERSE_READ_CHUNK: usize = 64 * 1024;
246
247/// Reads a loose reflog file from the end in fixed-size chunks (newest line first).
248pub(crate) struct ReverseReflogLineReader {
249    file: File,
250    loaded_from: u64,
251    buffer: Vec<u8>,
252}
253
254impl ReverseReflogLineReader {
255    /// Opens an existing non-empty reflog file for reverse line iteration.
256    ///
257    /// # Errors
258    ///
259    /// Propagates I/O errors other than a missing log (a missing path, or a directory in
260    /// the way, yields `Ok(None)`).
261    pub fn open(path: &Path) -> Result<Option<Self>> {
262        let file = match File::open(path) {
263            Ok(f) => f,
264            Err(e)
265                if matches!(
266                    e.kind(),
267                    io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
268                ) =>
269            {
270                return Ok(None)
271            }
272            Err(e) => return Err(Error::Io(e)),
273        };
274        let meta = file.metadata().map_err(Error::Io)?;
275        let len = meta.len();
276        if !meta.is_file() || len == 0 {
277            return Ok(None);
278        }
279        Ok(Some(Self {
280            file,
281            loaded_from: len,
282            buffer: Vec::new(),
283        }))
284    }
285
286    /// Returns the next non-empty line's raw bytes, newest first.
287    ///
288    /// # Errors
289    ///
290    /// Propagates I/O failures while reading.
291    pub fn next_nonempty_line(&mut self) -> Result<Option<Vec<u8>>> {
292        loop {
293            if let Some(line) = take_newest_line(&mut self.buffer)? {
294                if line.is_empty() || line == b"\r" {
295                    continue;
296                }
297                return Ok(Some(line));
298            }
299            if self.loaded_from == 0 {
300                if self.buffer.is_empty() {
301                    return Ok(None);
302                }
303                let mut line = std::mem::take(&mut self.buffer);
304                while line.last() == Some(&b'\r') {
305                    line.pop();
306                }
307                if line.is_empty() {
308                    return Ok(None);
309                }
310                return Ok(Some(line));
311            }
312            let read_end = self.loaded_from;
313            let read_start = read_end.saturating_sub(REFLOG_REVERSE_READ_CHUNK as u64);
314            let read_len = (read_end - read_start) as usize;
315            self.file
316                .seek(SeekFrom::Start(read_start))
317                .map_err(Error::Io)?;
318            let mut chunk = vec![0u8; read_len];
319            self.file.read_exact(&mut chunk).map_err(Error::Io)?;
320            self.loaded_from = read_start;
321            let mut merged = chunk;
322            merged.append(&mut self.buffer);
323            self.buffer = merged;
324        }
325    }
326}
327
328fn take_newest_line(buffer: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
329    loop {
330        if buffer.is_empty() {
331            return Ok(None);
332        }
333        let Some(newline_at) = buffer.iter().rposition(|&b| b == b'\n') else {
334            return Ok(None);
335        };
336        let mut line = buffer.split_off(newline_at + 1);
337        buffer.truncate(newline_at);
338        while line.last() == Some(&b'\r') {
339            line.pop();
340        }
341        if line.is_empty() {
342            continue;
343        }
344        return Ok(Some(line));
345    }
346}
347
348/// Parse a single reflog line.
349///
350/// Format: `<old-hex> <new-hex> <identity>\t<message>`
351/// Parse one reflog line (for embedders that read log files directly).
352pub(crate) fn parse_reflog_line_for_store(line: &str) -> Option<ReflogEntry> {
353    parse_reflog_line(line)
354}
355
356fn parse_reflog_line(line: &str) -> Option<ReflogEntry> {
357    // Split on tab first to separate identity from message
358    let (before_tab, message) = if let Some(pos) = line.find('\t') {
359        (&line[..pos], line[pos + 1..].to_string())
360    } else {
361        (line, String::new())
362    };
363
364    // Format: `<old-hex> <new-hex> <identity>`. The two OIDs are whitespace-
365    // delimited; their hex width (40 for SHA-1, 64 for SHA-256) is inferred by
366    // the parser, so split on the first two spaces rather than fixed offsets.
367    let mut parts = before_tab.splitn(3, ' ');
368    let old_hex = parts.next()?;
369    let new_hex = parts.next()?;
370    let identity = parts.next()?.to_string();
371
372    let old_oid = old_hex.parse::<ObjectId>().ok()?;
373    let new_oid = new_hex.parse::<ObjectId>().ok()?;
374
375    Some(ReflogEntry {
376        old_oid,
377        new_oid,
378        identity,
379        message,
380    })
381}
382
383/// Collect every non-null object ID mentioned in any reflog.
384///
385/// Used by `fsck` to validate reflog entries across loose-file and reftable backends.
386pub fn all_reflog_oids(git_dir: &Path) -> Result<HashSet<ObjectId>> {
387    if crate::refs::store::open_ref_store(git_dir)?.format()
388        != crate::refs::store::RefStorageFormat::Files
389    {
390        return Ok(all_reflog_oids_ordered(git_dir)?.into_iter().collect());
391    }
392    let mut out = HashSet::new();
393    let logs = git_dir.join("logs");
394    if !logs.is_dir() {
395        return Ok(out);
396    }
397    walk_reflog_files(&logs, &mut out)?;
398    Ok(out)
399}
400
401/// Like [`all_reflog_oids`], but returns the OIDs in Git's `add_reflogs_to_pending`
402/// insertion order so an equal-date priority-queue walk breaks ties the same way Git does.
403///
404/// Git iterates every reflog in sorted ref-name order (HEAD first, then `refs/...`), reads each
405/// reflog file oldest-first, and inserts `old_oid` then `new_oid` per entry. The first occurrence
406/// of each OID wins (later duplicates are dropped). `--reflog` on `git log`/`git rev-list` relies
407/// on this order so that commits sharing a committer timestamp are emitted in a stable,
408/// Git-compatible sequence.
409pub fn all_reflog_oids_ordered(git_dir: &Path) -> Result<Vec<ObjectId>> {
410    if crate::refs::store::open_ref_store(git_dir)?.format()
411        != crate::refs::store::RefStorageFormat::Files
412    {
413        // Reftable repos: fall back to the ref-name-ordered reflog walk for a deterministic order.
414        let mut out = Vec::new();
415        let mut seen = HashSet::new();
416        let mut names = list_reflog_refs(git_dir).unwrap_or_default();
417        names.sort();
418        for refname in names {
419            for entry in read_reflog(git_dir, &refname).unwrap_or_default() {
420                for oid in [entry.old_oid, entry.new_oid] {
421                    if !oid.is_zero() && seen.insert(oid) {
422                        out.push(oid);
423                    }
424                }
425            }
426        }
427        return Ok(out);
428    }
429    let mut out = Vec::new();
430    let mut seen = HashSet::new();
431    let logs = git_dir.join("logs");
432    if !logs.is_dir() {
433        return Ok(out);
434    }
435    let mut names = list_reflog_refs(git_dir).unwrap_or_default();
436    names.sort();
437    for refname in names {
438        let path = reflog_path(git_dir, &refname);
439        let Ok(content) = fs::read_to_string(&path) else {
440            continue;
441        };
442        for line in content.lines() {
443            let Some(e) = parse_reflog_line(line) else {
444                continue;
445            };
446            for oid in [e.old_oid, e.new_oid] {
447                if !oid.is_zero() && seen.insert(oid) {
448                    out.push(oid);
449                }
450            }
451        }
452    }
453    Ok(out)
454}
455
456fn walk_reflog_files(dir: &Path, out: &mut HashSet<ObjectId>) -> Result<()> {
457    for entry in fs::read_dir(dir).map_err(Error::Io)? {
458        let entry = entry.map_err(Error::Io)?;
459        let path = entry.path();
460        if path.is_dir() {
461            walk_reflog_files(&path, out)?;
462        } else if path.is_file() {
463            let content = fs::read_to_string(&path).map_err(Error::Io)?;
464            for line in content.lines() {
465                if let Some(e) = parse_reflog_line(line) {
466                    if !e.old_oid.is_zero() {
467                        out.insert(e.old_oid);
468                    }
469                    if !e.new_oid.is_zero() {
470                        out.insert(e.new_oid);
471                    }
472                }
473            }
474        }
475    }
476    Ok(())
477}
478
479/// Delete specific reflog entries by index (0-based, newest-first order).
480///
481/// Rewrites the reflog through the ref store for `git_dir`, omitting entries at the given
482/// indices.
483pub fn delete_reflog_entries(git_dir: &Path, refname: &str, indices: &[usize]) -> Result<()> {
484    let store = crate::refs::store::open_ref_store(git_dir)?;
485    delete_reflog_entries_on_store(store.as_ref(), refname, indices)
486}
487
488/// Oldest-first `entries` without those at the newest-first `indices`, plus whether the
489/// oldest entry was among the removed ones. `None` when `entries` is empty.
490fn filter_reflog_without_indices(
491    entries: Vec<ReflogEntry>,
492    indices: &[usize],
493) -> Option<(Vec<ReflogEntry>, bool)> {
494    if entries.is_empty() {
495        return None;
496    }
497    let len = entries.len();
498    let indices_set: HashSet<usize> = indices.iter().copied().collect();
499    let removed_oldest = indices_set.contains(&(len - 1));
500    let kept = entries
501        .into_iter()
502        .enumerate()
503        .filter(|(oldest_first, _)| !indices_set.contains(&(len - 1 - oldest_first)))
504        .map(|(_, e)| e)
505        .collect();
506    Some((kept, removed_oldest))
507}
508
509fn rechain_reflog_after_delete(
510    mut kept: Vec<ReflogEntry>,
511    removed_oldest: bool,
512) -> Vec<ReflogEntry> {
513    if removed_oldest {
514        if let Some(first) = kept.first_mut() {
515            first.old_oid = ObjectId::zero();
516        }
517    }
518    for i in 1..kept.len() {
519        kept[i].old_oid = kept[i - 1].new_oid;
520    }
521    kept
522}
523
524/// [`delete_reflog_entries`] through an already-selected ref store (for example
525/// [`crate::repo::Repository::refs`], which may be an injected store).
526pub(crate) fn delete_reflog_entries_on_store(
527    store: &dyn crate::refs::store::RefStore,
528    refname: &str,
529    indices: &[usize],
530) -> Result<()> {
531    let indices: Vec<usize> = indices.to_vec();
532    store.rewrite_reflog(refname, &mut move |current| {
533        Ok(filter_reflog_without_indices(current, &indices)
534            .map(|(kept, _)| kept)
535            .unwrap_or_default())
536    })
537}
538
539/// Like [`delete_reflog_entries_on_store`], but reconnects surviving `old_oid` values after
540/// a gap (Git stash drop / `EXPIRE_REFLOGS_REWRITE`).
541pub(crate) fn delete_reflog_entries_rechain_on_store(
542    store: &dyn crate::refs::store::RefStore,
543    refname: &str,
544    indices: &[usize],
545) -> Result<()> {
546    let indices: Vec<usize> = indices.to_vec();
547    store.rewrite_reflog(refname, &mut move |current| {
548        Ok(filter_reflog_without_indices(current, &indices)
549            .map(|(kept, removed_oldest)| rechain_reflog_after_delete(kept, removed_oldest))
550            .unwrap_or_default())
551    })
552}
553
554/// Read every reflog entry for `refname` from `store`, oldest first; empty when it has none.
555///
556/// # Errors
557///
558/// Propagates backend read and parse failures.
559pub(crate) fn read_reflog_on_store(
560    store: &dyn crate::refs::store::RefStore,
561    refname: &str,
562) -> Result<Vec<ReflogEntry>> {
563    if let Some(files) = store.as_files() {
564        return files.read_reflog_entries(refname);
565    }
566    let mut entries = Vec::new();
567    store.for_each_reflog_entry(refname, false, &mut |entry| {
568        entries.push(entry.clone());
569        std::ops::ControlFlow::Continue(())
570    })?;
571    Ok(entries)
572}
573
574/// Expire (prune) reflog entries older than a given timestamp (Unix seconds).
575///
576/// If `expire_time` is `None`, removes all entries.
577pub fn expire_reflog(git_dir: &Path, refname: &str, expire_time: Option<i64>) -> Result<usize> {
578    let store = crate::refs::store::open_ref_store(git_dir)?;
579    let mut pruned = 0usize;
580    store.rewrite_reflog(refname, &mut |current| {
581        if current.is_empty() {
582            return Ok(current);
583        }
584        let mut kept = Vec::with_capacity(current.len());
585        for entry in current {
586            let ts = parse_timestamp_from_identity(&entry.identity);
587            let dominated = match (expire_time, ts) {
588                (Some(cutoff), Some(t)) => t < cutoff,
589                (None, _) => true,
590                (Some(_), None) => false,
591            };
592            if dominated {
593                pruned += 1;
594            } else {
595                kept.push(entry);
596            }
597        }
598        Ok(kept)
599    })?;
600    Ok(pruned)
601}
602
603/// Expire reflog entries whose `new_oid` is not an ancestor of the current ref tip
604/// and whose identity timestamp is older than `cutoff` (Unix seconds).
605///
606/// Entries with an all-zero `new_oid` are never removed by this pass.
607///
608/// When `cutoff` is `None`, no entries are removed.
609///
610pub fn expire_reflog_unreachable(
611    repo: &Repository,
612    _git_dir: &Path,
613    refname: &str,
614    cutoff: Option<i64>,
615) -> Result<usize> {
616    let Some(cutoff) = cutoff else {
617        return Ok(0);
618    };
619    let tip = match refs::resolve_ref_in(repo, refname) {
620        Ok(o) => o,
621        Err(_) => return Ok(0),
622    };
623    let ancestors = match merge_base::ancestor_closure(repo, tip) {
624        Ok(a) => a,
625        Err(_) => return Ok(0),
626    };
627
628    let store = repo.refs();
629    let snapshot = read_reflog_on_store(store, refname)?;
630    if snapshot.is_empty() {
631        return Ok(0);
632    }
633
634    let drop_flags: Vec<bool> = snapshot
635        .iter()
636        .map(|entry| {
637            let ts = parse_timestamp_from_identity(&entry.identity);
638            let unreachable = !entry.new_oid.is_zero() && !ancestors.contains(&entry.new_oid);
639            unreachable && matches!(ts, Some(t) if t < cutoff)
640        })
641        .collect();
642    let pruned = drop_flags.iter().filter(|&&drop| drop).count();
643    if pruned == 0 {
644        return Ok(0);
645    }
646    rewrite_reflog_from_snapshot(store, refname, &snapshot, &drop_flags)?;
647    Ok(pruned)
648}
649
650/// Format a reflog entry back into the on-disk line format.
651#[cfg_attr(not(test), allow(dead_code))]
652fn format_reflog_entry(entry: &ReflogEntry) -> String {
653    if entry.message.is_empty() {
654        format!("{} {} {}\n", entry.old_oid, entry.new_oid, entry.identity)
655    } else {
656        format!(
657            "{} {} {}\t{}\n",
658            entry.old_oid, entry.new_oid, entry.identity, entry.message
659        )
660    }
661}
662
663/// Extract the Unix timestamp from an identity string.
664///
665/// Identity format: `Name <email> <timestamp> <tz>`
666/// Unix timestamp from a reflog identity line (`Name <email> <ts> <tz>`).
667pub fn reflog_identity_timestamp(identity: &str) -> Option<i64> {
668    parse_timestamp_from_identity(identity)
669}
670
671fn parse_timestamp_from_identity(identity: &str) -> Option<i64> {
672    // Walk backwards: last token is tz (+0000), second-to-last is timestamp
673    let parts: Vec<&str> = identity.rsplitn(3, ' ').collect();
674    if parts.len() >= 2 {
675        parts[1].parse::<i64>().ok()
676    } else {
677        None
678    }
679}
680
681/// Copy `logs/<branch_refname>` to `logs/HEAD` when keeping symbolic-HEAD reflogs aligned with
682/// the checked-out branch (matches Git).
683pub fn mirror_branch_reflog_to_head(git_dir: &Path, branch_refname: &str) -> Result<()> {
684    if crate::refs::store::open_ref_store(git_dir)?.format()
685        != crate::refs::store::RefStorageFormat::Files
686    {
687        return Ok(());
688    }
689    let src = reflog_path(git_dir, branch_refname);
690    if !src.is_file() {
691        return Ok(());
692    }
693    let content = fs::read_to_string(&src).map_err(Error::Io)?;
694    let dst = reflog_path(git_dir, "HEAD");
695    if let Some(parent) = dst.parent() {
696        fs::create_dir_all(parent).map_err(Error::Io)?;
697    }
698    fs::write(&dst, content).map_err(Error::Io)?;
699    Ok(())
700}
701
702/// Names of every ref with a reflog in `store`, sorted.
703///
704/// # Errors
705///
706/// Propagates backend read failures.
707pub(crate) fn reflog_refs_on_store(
708    store: &dyn crate::refs::store::RefStore,
709) -> Result<Vec<String>> {
710    let mut names = Vec::new();
711    store.for_each_reflog_ref(&mut |name| {
712        names.push(name.to_owned());
713        ControlFlow::Continue(())
714    })?;
715    names.sort();
716    names.dedup();
717    Ok(names)
718}
719
720/// List all refs that have reflogs.
721pub fn list_reflog_refs(git_dir: &Path) -> Result<Vec<String>> {
722    if crate::refs::store::open_ref_store(git_dir)?.format()
723        != crate::refs::store::RefStorageFormat::Files
724    {
725        let store = crate::refs::store::ReftableRefStore::open(git_dir.to_path_buf())?;
726        let mut refs = Vec::new();
727        store.for_each_reflog_ref(&mut |name| {
728            refs.push(name.to_owned());
729            std::ops::ControlFlow::Continue(())
730        })?;
731        refs.sort();
732        return Ok(refs);
733    }
734    let mut refs = Vec::new();
735    let mut seen = HashSet::new();
736
737    fn collect_from_logs_root(
738        logs_dir: &Path,
739        out: &mut Vec<String>,
740        seen: &mut HashSet<String>,
741        skip_per_worktree_refs: bool,
742    ) -> Result<()> {
743        if logs_dir.join("HEAD").is_file() && seen.insert("HEAD".to_string()) {
744            out.push("HEAD".to_string());
745        }
746        let refs_logs = crate::refs::store::paths::logs_refs_from_logs_root(logs_dir);
747        if refs_logs.is_dir() {
748            collect_reflog_refs(&refs_logs, "refs", out, seen, skip_per_worktree_refs)?;
749        }
750        Ok(())
751    }
752
753    collect_from_logs_root(
754        &crate::refs::store::paths::logs_dir_path(git_dir),
755        &mut refs,
756        &mut seen,
757        false,
758    )?;
759    if let Some(common) = refs::common_dir(git_dir) {
760        if common != git_dir {
761            collect_from_logs_root(
762                &crate::refs::store::paths::logs_dir_path(&common),
763                &mut refs,
764                &mut seen,
765                true,
766            )?;
767        }
768    }
769
770    Ok(refs)
771}
772
773fn collect_reflog_refs(
774    dir: &Path,
775    prefix: &str,
776    out: &mut Vec<String>,
777    seen: &mut HashSet<String>,
778    skip_per_worktree_refs: bool,
779) -> Result<()> {
780    let read_dir = match fs::read_dir(dir) {
781        Ok(rd) => rd,
782        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
783        Err(e) => return Err(Error::Io(e)),
784    };
785
786    for entry in read_dir {
787        let entry = entry.map_err(Error::Io)?;
788        let name = entry.file_name().to_string_lossy().to_string();
789        let full_name = format!("{prefix}/{name}");
790        if skip_per_worktree_refs && crate::worktree_ref::is_per_worktree_ref(&full_name) {
791            continue;
792        }
793        let ft = entry.file_type().map_err(Error::Io)?;
794        if ft.is_dir() {
795            collect_reflog_refs(&entry.path(), &full_name, out, seen, skip_per_worktree_refs)?;
796        } else if ft.is_file() && seen.insert(full_name.clone()) {
797            out.push(full_name);
798        }
799    }
800    Ok(())
801}
802
803// --- `git reflog expire` -----------------------------------------------------
804
805/// Options for [`expire_reflog_git`].
806#[derive(Debug, Clone)]
807pub struct ReflogExpireParams {
808    /// Also prune entries whose commits, or any commit, tree or blob reachable from them, are
809    /// missing. This walks full history once per expiry; ordinary expiry never does.
810    pub stale_fix: bool,
811    /// `dry_run` field.
812    pub dry_run: bool,
813    /// `verbose` field.
814    pub verbose: bool,
815}
816
817/// What happened to one reflog entry during [`expire_reflog_git`].
818#[derive(Debug, Clone, Copy, PartialEq, Eq)]
819pub enum ReflogExpireActionKind {
820    /// Entry was kept in the reflog.
821    Keep,
822    /// Entry was removed from the reflog.
823    Prune,
824    /// Entry would be removed when not in dry-run mode.
825    WouldPrune,
826}
827
828/// Per-entry report from [`expire_reflog_git`].
829#[derive(Debug, Clone, PartialEq, Eq)]
830pub struct ReflogExpireAction {
831    /// Ref whose reflog was processed.
832    pub refname: String,
833    /// The reflog entry that was considered.
834    pub entry: ReflogEntry,
835    /// Keep vs prune (or dry-run would-prune).
836    pub action: ReflogExpireActionKind,
837}
838
839/// Result of [`expire_reflog_git`]: prune count plus a per-entry action list.
840#[derive(Debug, Clone, PartialEq, Eq)]
841pub struct ReflogExpireResult {
842    /// Number of entries pruned (or that would be pruned in dry-run mode).
843    pub pruned: usize,
844    /// One action per reflog entry that was scanned.
845    pub actions: Vec<ReflogExpireAction>,
846}
847
848/// Per-ref `gc.<pattern>.reflogExpire*` rule from config.
849#[derive(Debug, Clone)]
850pub struct GcReflogPattern {
851    pattern: String,
852    expire_total: Option<i64>,
853    expire_unreachable: Option<i64>,
854}
855
856fn collect_gc_reflog_patterns(config: &ConfigSet, now: i64) -> Vec<GcReflogPattern> {
857    let mut by_pattern: HashMap<String, GcReflogPattern> = HashMap::new();
858    let mut pattern_order: Vec<String> = Vec::new();
859    for e in config.entries() {
860        let key = e.key.as_str();
861        let Some(rest) = key.strip_prefix("gc.") else {
862            continue;
863        };
864        // Per-ref: `gc.<wildmatch-pattern>.reflogExpire` (pattern may contain dots).
865        // Global `gc.reflogExpire` has no pattern segment — see [`global_gc_reflog_expiry`].
866        let lower = rest.to_ascii_lowercase();
867        let (pat, is_total) = if lower.ends_with(".reflogexpireunreachable") {
868            (
869                &rest[..rest.len() - ".reflogexpireunreachable".len()],
870                false,
871            )
872        } else if lower.ends_with(".reflogexpire") {
873            (&rest[..rest.len() - ".reflogexpire".len()], true)
874        } else {
875            continue;
876        };
877        if pat.is_empty() {
878            continue;
879        }
880        let Some(val) = e.value.as_deref() else {
881            continue;
882        };
883        let Ok(ts) = parse_reflog_expire_spec(val, now) else {
884            continue;
885        };
886        let pat_owned = pat.to_string();
887        if !by_pattern.contains_key(&pat_owned) {
888            pattern_order.push(pat_owned.clone());
889        }
890        let ent = by_pattern.entry(pat_owned).or_insert(GcReflogPattern {
891            pattern: pat.to_string(),
892            expire_total: None,
893            expire_unreachable: None,
894        });
895        if is_total {
896            ent.expire_total = Some(ts);
897        } else {
898            ent.expire_unreachable = Some(ts);
899        }
900    }
901    pattern_order
902        .into_iter()
903        .filter_map(|p| by_pattern.remove(&p))
904        .collect()
905}
906
907fn global_gc_reflog_expiry(config: &ConfigSet, now: i64) -> (Option<i64>, Option<i64>) {
908    let total = config
909        .get("gc.reflogExpire")
910        .and_then(|v| parse_reflog_expire_spec(&v, now).ok());
911    let unreach = config
912        .get("gc.reflogExpireUnreachable")
913        .and_then(|v| parse_reflog_expire_spec(&v, now).ok());
914    (total, unreach)
915}
916
917/// Parse `gc.reflogExpire` / `--expire` values: `never` / `false` → keep forever (`0`), else days or epoch.
918pub fn parse_reflog_expire_spec(raw: &str, now: i64) -> Result<i64> {
919    let s = raw.trim();
920    if s.eq_ignore_ascii_case("never") || s.eq_ignore_ascii_case("false") {
921        return Ok(0);
922    }
923    if s.eq_ignore_ascii_case("now") || s.eq_ignore_ascii_case("all") {
924        return Ok(i64::MAX);
925    }
926    crate::git_date::parse_date_cutoff(s, now)
927        .map_err(|_| Error::Message(format!("invalid reflog expiry: {raw:?}")))
928}
929
930/// Default `gc.reflogExpire`: reachable reflog entries older than this are dropped.
931const REFLOG_DEFAULT_EXPIRE_REACHABLE_DAYS: i64 = 90;
932/// Default `gc.reflogExpireUnreachable`: unreachable entries older than this are dropped.
933const REFLOG_DEFAULT_EXPIRE_UNREACHABLE_DAYS: i64 = 30;
934
935fn default_expire_cutoff(now: i64, days: i64) -> i64 {
936    now.saturating_sub(time::Duration::days(days).whole_seconds())
937}
938
939fn default_expire_total(now: i64) -> i64 {
940    default_expire_cutoff(now, REFLOG_DEFAULT_EXPIRE_REACHABLE_DAYS)
941}
942
943fn default_expire_unreachable(now: i64) -> i64 {
944    default_expire_cutoff(now, REFLOG_DEFAULT_EXPIRE_UNREACHABLE_DAYS)
945}
946
947fn resolve_expire_for_ref(
948    refname: &str,
949    explicit_total: Option<i64>,
950    explicit_unreachable: Option<i64>,
951    patterns: &[GcReflogPattern],
952    default_total: i64,
953    default_unreachable: i64,
954) -> (i64, i64) {
955    let mut expire_total = explicit_total.unwrap_or(default_total);
956    let mut expire_unreachable = explicit_unreachable.unwrap_or(default_unreachable);
957    if explicit_total.is_some() && explicit_unreachable.is_some() {
958        return (expire_total, expire_unreachable);
959    }
960    for ent in patterns {
961        if wildmatch(ent.pattern.as_bytes(), refname.as_bytes(), WM_PATHNAME)
962            || wildmatch(ent.pattern.as_bytes(), refname.as_bytes(), 0)
963        {
964            // Partial per-pattern config only sets one key; the other keeps the global/default.
965            if explicit_total.is_none() {
966                if let Some(total) = ent.expire_total {
967                    expire_total = total;
968                }
969            }
970            if explicit_unreachable.is_none() {
971                if let Some(unreachable) = ent.expire_unreachable {
972                    expire_unreachable = unreachable;
973                }
974            }
975            return (expire_total, expire_unreachable);
976        }
977    }
978    if refname == "refs/stash" {
979        if explicit_total.is_none() {
980            expire_total = 0;
981        }
982        if explicit_unreachable.is_none() {
983            expire_unreachable = 0;
984        }
985    }
986    (expire_total, expire_unreachable)
987}
988
989/// Lazily computed state shared by the reflog expiry of several refs in one pass.
990///
991/// Every member is only filled when an expiry needs it: the promise boundary when a tip has
992/// to be classified or `--stale-fix` runs, commit linkage when old entries are judged by
993/// reachability, and the completeness answers only for `--stale-fix`.
994#[derive(Debug, Default)]
995pub(crate) struct ReflogExpiryCache {
996    promised: OnceCell<HashSet<ObjectId>>,
997    /// Every commit a reachability walk has reached, with its parents once read.
998    commits: RefCell<CommitLinks>,
999    complete: RefCell<HashMap<ObjectId, bool>>,
1000}
1001
1002/// Commits interned to dense indices, so the reachability walk of each ref marks a bit per
1003/// commit instead of hashing object ids, and later refs in the pass reuse earlier reads.
1004#[derive(Debug, Default)]
1005struct CommitLinks {
1006    index: OidHashMap<u32>,
1007    nodes: Vec<Linkage>,
1008}
1009
1010#[derive(Debug)]
1011enum Linkage {
1012    Unread(ObjectId),
1013    /// Missing, or not a commit.
1014    Absent,
1015    Read {
1016        time: i64,
1017        parents: Box<[u32]>,
1018    },
1019}
1020
1021impl CommitLinks {
1022    fn intern(&mut self, oid: ObjectId) -> u32 {
1023        let next = u32::try_from(self.nodes.len()).unwrap_or(u32::MAX);
1024        let node = *self.index.entry(oid).or_insert(next);
1025        if node == next {
1026            self.nodes.push(Linkage::Unread(oid));
1027        }
1028        node
1029    }
1030
1031    fn lookup(&self, oid: &ObjectId) -> Option<u32> {
1032        self.index.get(oid).copied()
1033    }
1034
1035    fn len(&self) -> usize {
1036        self.nodes.len()
1037    }
1038
1039    /// Committer time and parents of `node`, calling `read` for the parents and committer time
1040    /// of a commit the pass has not read yet. `None` when the commit is missing or not a commit.
1041    fn linkage(
1042        &mut self,
1043        node: u32,
1044        read: impl FnOnce(ObjectId) -> Option<(Vec<ObjectId>, i64)>,
1045    ) -> Option<(i64, &[u32])> {
1046        let idx = node as usize;
1047        if let Linkage::Unread(oid) = self.nodes[idx] {
1048            self.nodes[idx] = match read(oid) {
1049                Some((parents, time)) => Linkage::Read {
1050                    time,
1051                    parents: parents.into_iter().map(|p| self.intern(p)).collect(),
1052                },
1053                None => Linkage::Absent,
1054            };
1055        }
1056        match &self.nodes[idx] {
1057            Linkage::Read { time, parents } => Some((*time, parents)),
1058            Linkage::Unread(_) | Linkage::Absent => None,
1059        }
1060    }
1061}
1062
1063impl ReflogExpiryCache {
1064    /// Partial-clone promises, read without fetching; missing promised objects count as
1065    /// present everywhere expiry looks at objects.
1066    fn promised(&self, repo: &Repository) -> Result<&HashSet<ObjectId>> {
1067        if let Some(promised) = self.promised.get() {
1068            return Ok(promised);
1069        }
1070        let promised = crate::promisor::maintenance_promise_boundary(repo.odb())?;
1071        Ok(self.promised.get_or_init(|| promised))
1072    }
1073
1074    /// Whether `root` and every commit, tree and blob reachable from it is present (or
1075    /// promised).
1076    ///
1077    /// One iterative walk memoises every commit and tree it finishes, so history shared
1078    /// between reflog entries and refs is read once per expiry pass, merge diamonds are not
1079    /// re-walked, and deep history cannot overflow the stack.
1080    fn commit_complete(
1081        &self,
1082        repo: &Repository,
1083        promised: &HashSet<ObjectId>,
1084        root: ObjectId,
1085    ) -> bool {
1086        enum Step {
1087            Enter(ObjectId, ObjectKind),
1088            Finish(ObjectId, Vec<ObjectId>),
1089        }
1090        let is_boundary = |oid: &ObjectId| oid.is_zero() || promised.contains(oid);
1091        if is_boundary(&root) {
1092            return true;
1093        }
1094        let mut memo = self.complete.borrow_mut();
1095        let mut stack = vec![Step::Enter(root, ObjectKind::Commit)];
1096        while let Some(step) = stack.pop() {
1097            match step {
1098                Step::Enter(oid, kind) => {
1099                    if is_boundary(&oid) || memo.contains_key(&oid) {
1100                        continue;
1101                    }
1102                    // Provisionally incomplete: an object reached again before it finishes
1103                    // would be its own ancestor, which only a corrupt store can serve.
1104                    memo.insert(oid, false);
1105                    let Some(children) = object_children(repo, promised, oid, kind) else {
1106                        continue;
1107                    };
1108                    let next = children
1109                        .iter()
1110                        .map(|&(child, kind)| Step::Enter(child, kind))
1111                        .collect::<Vec<_>>();
1112                    stack.push(Step::Finish(
1113                        oid,
1114                        children.into_iter().map(|(child, _)| child).collect(),
1115                    ));
1116                    stack.extend(next);
1117                }
1118                Step::Finish(oid, children) => {
1119                    let complete = children
1120                        .iter()
1121                        .all(|child| is_boundary(child) || memo.get(child) == Some(&true));
1122                    memo.insert(oid, complete);
1123                }
1124            }
1125        }
1126        memo.get(&root) == Some(&true)
1127    }
1128}
1129
1130/// The commits and trees `oid` (expected to be a `kind`) points to, or `None` when `oid` is
1131/// missing, of another kind, unparsable, or a tree naming a missing non-tree entry.
1132///
1133/// Non-tree entries are only checked for existence, so blobs are never inflated.
1134fn object_children(
1135    repo: &Repository,
1136    promised: &HashSet<ObjectId>,
1137    oid: ObjectId,
1138    kind: ObjectKind,
1139) -> Option<Vec<(ObjectId, ObjectKind)>> {
1140    let obj = repo.odb().try_read_once(&oid).ok()??;
1141    if obj.kind != kind {
1142        return None;
1143    }
1144    match kind {
1145        ObjectKind::Commit => {
1146            let commit = parse_commit(&obj.data).ok()?;
1147            Some(
1148                std::iter::once((commit.tree, ObjectKind::Tree))
1149                    .chain(commit.parents.into_iter().map(|p| (p, ObjectKind::Commit)))
1150                    .collect(),
1151            )
1152        }
1153        ObjectKind::Tree => {
1154            let mut subtrees = Vec::new();
1155            for entry in parse_tree(&obj.data).ok()? {
1156                if entry.mode == MODE_TREE {
1157                    subtrees.push((entry.oid, ObjectKind::Tree));
1158                } else if !promised.contains(&entry.oid) && !repo.odb().exists(&entry.oid) {
1159                    return None;
1160                }
1161            }
1162            Some(subtrees)
1163        }
1164        ObjectKind::Blob | ObjectKind::Tag => None,
1165    }
1166}
1167
1168/// The commit a ref tip names after peeling annotated tags, read without lazy fetching.
1169///
1170/// Returns `None` for a zero id, a missing object or a tip that is not a commit; promised
1171/// objects count as present commits so partial clones stay offline.
1172fn peel_tip_to_commit(
1173    repo: &Repository,
1174    promised: &HashSet<ObjectId>,
1175    oid: ObjectId,
1176) -> Option<ObjectId> {
1177    if oid.is_zero() {
1178        return None;
1179    }
1180    if promised.contains(&oid) {
1181        return Some(oid);
1182    }
1183    let target = repo
1184        .odb()
1185        .peel_stored_tag_target(oid)
1186        .ok()
1187        .flatten()
1188        .unwrap_or(oid);
1189    if promised.contains(&target) {
1190        return Some(target);
1191    }
1192    match repo.odb().try_read_once(&target) {
1193        Ok(Some(obj)) if obj.kind == ObjectKind::Commit => Some(target),
1194        _ => None,
1195    }
1196}
1197
1198fn is_head_ref(refname: &str) -> bool {
1199    refname == "HEAD" || refname.ends_with("/HEAD")
1200}
1201
1202/// Every ref tip that peels to a commit: the starting points for the reachability check of
1203/// a `HEAD` reflog.
1204fn head_reflog_tips(repo: &Repository, promised: &HashSet<ObjectId>) -> Vec<ObjectId> {
1205    let store = repo.refs();
1206    let mut tips = Vec::new();
1207    if let Ok(oid) = store.resolve("HEAD") {
1208        tips.push(oid);
1209    }
1210    if let Ok(refs) = refs::collect_resolved_refs(store, "refs/") {
1211        tips.extend(refs.into_iter().map(|(_, oid)| oid));
1212    }
1213    tips.sort();
1214    tips.dedup();
1215    tips.into_iter()
1216        .filter_map(|oid| peel_tip_to_commit(repo, promised, oid))
1217        .collect()
1218}
1219
1220/// Commits reachable from a set of tips, discovered lazily.
1221///
1222/// The first walk stops below commits older than a cutoff (the total-expiry cutoff, which
1223/// old reflog entries rarely reach past); only a lookup that misses walks the rest of
1224/// history, once. Answers never depend on the cutoff, only the amount of history read.
1225/// Shallow boundaries are not walked through. Parents come from the commit-graph when it
1226/// covers a commit, and are kept in the [`ReflogExpiryCache`] so the walks of later refs in
1227/// the same pass do not read them again; each walk only marks the interned commits it visits.
1228struct ReachableCommits<'r> {
1229    repo: &'r Repository,
1230    cache: &'r ReflogExpiryCache,
1231    graph: Option<Arc<CommitGraphChain>>,
1232    /// Interned shallow boundary commits, sorted.
1233    shallow: Vec<u32>,
1234    /// Indexed by interned commit; grows as the walk interns new commits.
1235    seen: Vec<bool>,
1236    seen_count: usize,
1237    pending: Vec<u32>,
1238    /// Parents of commits older than the cutoff, walked on the first miss.
1239    deferred: Vec<u32>,
1240}
1241
1242impl<'r> ReachableCommits<'r> {
1243    /// Walk from `tips` down to commits older than `cutoff` (`0` walks everything).
1244    fn new(
1245        repo: &'r Repository,
1246        cache: &'r ReflogExpiryCache,
1247        tips: Vec<ObjectId>,
1248        cutoff: i64,
1249    ) -> Self {
1250        let mut reachable = Self {
1251            repo,
1252            cache,
1253            graph: None,
1254            shallow: Vec::new(),
1255            seen: Vec::new(),
1256            seen_count: 0,
1257            pending: Vec::new(),
1258            deferred: Vec::new(),
1259        };
1260        if tips.is_empty() {
1261            return reachable;
1262        }
1263        reachable.graph = repo.caches().commit_graph_chain(repo.odb().objects_dir());
1264        {
1265            let mut commits = cache.commits.borrow_mut();
1266            let mut shallow = crate::shallow::load_shallow_boundaries(repo.git_dir())
1267                .into_iter()
1268                .map(|oid| commits.intern(oid))
1269                .collect::<Vec<_>>();
1270            shallow.sort_unstable();
1271            reachable.shallow = shallow;
1272            reachable.pending = tips.into_iter().map(|oid| commits.intern(oid)).collect();
1273        }
1274        reachable.walk(cutoff);
1275        reachable
1276    }
1277
1278    fn walk(&mut self, cutoff: i64) {
1279        let repo = self.repo;
1280        let graph = self.graph.as_deref();
1281        let mut commits = self.cache.commits.borrow_mut();
1282        while let Some(node) = self.pending.pop() {
1283            let idx = node as usize;
1284            if idx >= self.seen.len() {
1285                self.seen.resize(commits.len(), false);
1286            }
1287            if std::mem::replace(&mut self.seen[idx], true) {
1288                continue;
1289            }
1290            self.seen_count += 1;
1291            if !self.shallow.is_empty() && self.shallow.binary_search(&node).is_ok() {
1292                continue;
1293            }
1294            let Some((time, parents)) =
1295                commits.linkage(node, |oid| read_commit_linkage(repo, graph, oid))
1296            else {
1297                continue;
1298            };
1299            if cutoff > 0 && time < cutoff {
1300                self.deferred.extend_from_slice(parents);
1301            } else {
1302                self.pending.extend_from_slice(parents);
1303            }
1304        }
1305    }
1306
1307    fn is_seen(&self, oid: &ObjectId) -> bool {
1308        self.cache
1309            .commits
1310            .borrow()
1311            .lookup(oid)
1312            .is_some_and(|node| self.seen.get(node as usize) == Some(&true))
1313    }
1314
1315    fn contains(&mut self, oid: &ObjectId) -> bool {
1316        if !self.is_seen(oid) && !self.deferred.is_empty() {
1317            self.pending.append(&mut self.deferred);
1318            self.walk(0);
1319        }
1320        self.is_seen(oid)
1321    }
1322}
1323
1324/// Parents and committer time of commit `oid`, from the commit-graph when it covers `oid`, or
1325/// `None` when it is missing or not a commit.
1326fn read_commit_linkage(
1327    repo: &Repository,
1328    graph: Option<&CommitGraphChain>,
1329    oid: ObjectId,
1330) -> Option<(Vec<ObjectId>, i64)> {
1331    if let Some(linkage) = graph.and_then(|g| g.graph_commit(&oid)) {
1332        return Some(linkage);
1333    }
1334    let obj = repo.odb().try_read_once(&oid).ok()??;
1335    if obj.kind != ObjectKind::Commit {
1336        return None;
1337    }
1338    let commit = CommitRef::parse(&obj.data).ok()?;
1339    Some((commit.parents().to_vec(), commit.committer_timestamp()))
1340}
1341
1342/// Whether a reflog entry side names a commit that is not reachable. Missing objects count
1343/// as unreachable, and objects that are not commits as reachable.
1344fn is_unreachable_oid(
1345    repo: &Repository,
1346    reachable: &mut ReachableCommits<'_>,
1347    oid: ObjectId,
1348) -> bool {
1349    if oid.is_zero() || reachable.is_seen(&oid) {
1350        return false;
1351    }
1352    match repo.odb().try_read_once(&oid) {
1353        Ok(Some(obj)) if obj.kind == ObjectKind::Commit => !reachable.contains(&oid),
1354        Ok(Some(_)) => false,
1355        _ => true,
1356    }
1357}
1358
1359fn reflog_entry_timestamp(entry: &ReflogEntry) -> i64 {
1360    parse_timestamp_from_identity(&entry.identity).unwrap_or(i64::MAX)
1361}
1362
1363/// The `--stale-fix` completeness check, present only when `--stale-fix` was requested.
1364struct StaleFix<'a> {
1365    cache: &'a ReflogExpiryCache,
1366    promised: &'a HashSet<ObjectId>,
1367}
1368
1369impl StaleFix<'_> {
1370    fn entry_is_stale(&self, repo: &Repository, entry: &ReflogEntry) -> bool {
1371        !self
1372            .cache
1373            .commit_complete(repo, self.promised, entry.old_oid)
1374            || !self
1375                .cache
1376                .commit_complete(repo, self.promised, entry.new_oid)
1377    }
1378}
1379
1380fn should_drop_reflog_entry(
1381    repo: &Repository,
1382    entry: &ReflogEntry,
1383    expire_total: i64,
1384    expire_unreachable: i64,
1385    reachable: Option<&mut ReachableCommits<'_>>,
1386    stale_fix: Option<&StaleFix<'_>>,
1387) -> bool {
1388    let ts = reflog_entry_timestamp(entry);
1389    if expire_total > 0 && ts < expire_total {
1390        return true;
1391    }
1392    if stale_fix.is_some_and(|fix| fix.entry_is_stale(repo, entry)) {
1393        return true;
1394    }
1395    if expire_unreachable == 0 || ts >= expire_unreachable {
1396        return false;
1397    }
1398    // Without a reachability set (no commit tip to judge by) every old entry expires.
1399    let Some(reachable) = reachable else {
1400        return true;
1401    };
1402    is_unreachable_oid(repo, reachable, entry.old_oid)
1403        || is_unreachable_oid(repo, reachable, entry.new_oid)
1404}
1405
1406/// Reflog expiry for one ref.
1407///
1408/// Entries older than the total cutoff are dropped. Entries older than the unreachable
1409/// cutoff are dropped when their old or new commit is not reachable from the ref's tip (from
1410/// any ref for a `HEAD` reflog); that check is one commit-only walk per ref, and when the tip
1411/// does not peel to a commit every such entry is dropped, as in Git. Only
1412/// [`ReflogExpireParams::stale_fix`] additionally checks that the full history and trees of
1413/// each entry are present, in one memoised walk. Neither check fetches missing objects:
1414/// partial-clone promises count as present.
1415///
1416/// The reflog is read from and rewritten through [`Repository::refs`], and ref tips for the
1417/// reachability check are resolved there too, so an injected ref store is expired like the
1418/// built-in backends. A dry run writes nothing. On the files backend a fully pruned log is
1419/// kept as an empty file and gets `core.sharedRepository` permissions, as Git does.
1420///
1421/// # Errors
1422///
1423/// Propagates ref-store read and write failures and promisor-boundary lookup failures.
1424#[expect(clippy::too_many_arguments)]
1425pub fn expire_reflog_git(
1426    repo: &Repository,
1427    refname: &str,
1428    params: &ReflogExpireParams,
1429    explicit_total: Option<i64>,
1430    explicit_unreachable: Option<i64>,
1431    gc_patterns: &[GcReflogPattern],
1432    gc_global_total: Option<i64>,
1433    gc_global_unreachable: Option<i64>,
1434    now: i64,
1435) -> Result<ReflogExpireResult> {
1436    expire_reflog_git_with_cache(
1437        repo,
1438        refname,
1439        params,
1440        explicit_total,
1441        explicit_unreachable,
1442        gc_patterns,
1443        gc_global_total,
1444        gc_global_unreachable,
1445        now,
1446        &ReflogExpiryCache::default(),
1447    )
1448}
1449
1450/// [`expire_reflog_git`] sharing promise classification and stale-fix completeness answers
1451/// across all refs of one expiry pass.
1452#[expect(clippy::too_many_arguments)]
1453pub(crate) fn expire_reflog_git_with_cache(
1454    repo: &Repository,
1455    refname: &str,
1456    params: &ReflogExpireParams,
1457    explicit_total: Option<i64>,
1458    explicit_unreachable: Option<i64>,
1459    gc_patterns: &[GcReflogPattern],
1460    gc_global_total: Option<i64>,
1461    gc_global_unreachable: Option<i64>,
1462    now: i64,
1463    cache: &ReflogExpiryCache,
1464) -> Result<ReflogExpireResult> {
1465    let store = repo.refs();
1466    let base_total = gc_global_total.unwrap_or_else(|| default_expire_total(now));
1467    let base_unreachable = gc_global_unreachable.unwrap_or_else(|| default_expire_unreachable(now));
1468    let (expire_total, expire_unreachable) = resolve_expire_for_ref(
1469        refname,
1470        explicit_total,
1471        explicit_unreachable,
1472        gc_patterns,
1473        base_total,
1474        base_unreachable,
1475    );
1476
1477    let entries = read_reflog_on_store(store, refname)?;
1478    if entries.is_empty() {
1479        return Ok(ReflogExpireResult {
1480            pruned: 0,
1481            actions: Vec::new(),
1482        });
1483    }
1484
1485    let may_drop_total = expire_total > 0
1486        && entries
1487            .iter()
1488            .any(|e| reflog_entry_timestamp(e) < expire_total);
1489    let may_drop_unreachable = expire_unreachable > 0
1490        && entries
1491            .iter()
1492            .any(|e| reflog_entry_timestamp(e) < expire_unreachable);
1493    let stale_fix = if params.stale_fix {
1494        Some(StaleFix {
1495            cache,
1496            promised: cache.promised(repo)?,
1497        })
1498    } else {
1499        None
1500    };
1501    let may_drop_stale = stale_fix
1502        .as_ref()
1503        .is_some_and(|fix| entries.iter().any(|e| fix.entry_is_stale(repo, e)));
1504
1505    if !may_drop_total && !may_drop_stale && !may_drop_unreachable {
1506        return Ok(ReflogExpireResult {
1507            pruned: 0,
1508            actions: entries
1509                .iter()
1510                .map(|entry| ReflogExpireAction {
1511                    refname: refname.to_string(),
1512                    entry: entry.clone(),
1513                    action: ReflogExpireActionKind::Keep,
1514                })
1515                .collect(),
1516        });
1517    }
1518
1519    // Old entries are judged against the commits reachable from the ref's own tip (every ref
1520    // for HEAD), as Git does; a tip that does not name a commit expires them all.
1521    let tips = if !may_drop_unreachable || expire_unreachable <= expire_total {
1522        None
1523    } else if is_head_ref(refname) {
1524        Some(head_reflog_tips(repo, cache.promised(repo)?))
1525    } else {
1526        let promised = cache.promised(repo)?;
1527        store
1528            .resolve(refname)
1529            .ok()
1530            .and_then(|tip| peel_tip_to_commit(repo, promised, tip))
1531            .map(|tip| vec![tip])
1532    };
1533    let mut reachable = tips.map(|tips| ReachableCommits::new(repo, cache, tips, expire_total));
1534    let snapshot = entries;
1535    let mut drop_flags = Vec::with_capacity(snapshot.len());
1536    let mut pruned = 0usize;
1537    let mut actions = Vec::with_capacity(snapshot.len());
1538
1539    for entry in &snapshot {
1540        let drop = should_drop_reflog_entry(
1541            repo,
1542            entry,
1543            expire_total,
1544            expire_unreachable,
1545            reachable.as_mut(),
1546            stale_fix.as_ref(),
1547        );
1548        drop_flags.push(drop);
1549        let action = if drop {
1550            pruned += 1;
1551            if params.dry_run {
1552                ReflogExpireActionKind::WouldPrune
1553            } else {
1554                ReflogExpireActionKind::Prune
1555            }
1556        } else {
1557            ReflogExpireActionKind::Keep
1558        };
1559        actions.push(ReflogExpireAction {
1560            refname: refname.to_string(),
1561            entry: entry.clone(),
1562            action,
1563        });
1564    }
1565
1566    if !params.dry_run && pruned > 0 {
1567        rewrite_reflog_from_snapshot(store, refname, &snapshot, &drop_flags)?;
1568        // Git applies `core.sharedRepository` to the rewritten log file.
1569        if let Some(files) = store.as_files() {
1570            adjust_reflog_shared_perm(repo.git_dir(), &files.reflog_path(refname));
1571        }
1572    }
1573    Ok(ReflogExpireResult { pruned, actions })
1574}
1575
1576const REFLOG_REWRITE_RETRIES: u32 = 128;
1577
1578fn rewrite_reflog_from_snapshot(
1579    store: &dyn crate::refs::store::RefStore,
1580    refname: &str,
1581    snapshot: &[ReflogEntry],
1582    drop_flags: &[bool],
1583) -> Result<()> {
1584    debug_assert_eq!(snapshot.len(), drop_flags.len());
1585    for attempt in 0..REFLOG_REWRITE_RETRIES {
1586        let snap = snapshot.to_vec();
1587        let drops = drop_flags.to_vec();
1588        let name = refname.to_owned();
1589        match store.rewrite_reflog(refname, &mut move |current| {
1590            if current.len() < snap.len() || current[..snap.len()] != snap[..] {
1591                return Err(Error::RefStore(RefStoreError::ReflogRewriteStale {
1592                    name: name.clone(),
1593                }));
1594            }
1595            let mut kept = Vec::new();
1596            for (i, entry) in current.into_iter().enumerate() {
1597                if i < drops.len() {
1598                    if !drops[i] {
1599                        kept.push(entry);
1600                    }
1601                } else {
1602                    kept.push(entry);
1603                }
1604            }
1605            Ok(kept)
1606        }) {
1607            Ok(()) => return Ok(()),
1608            Err(Error::RefStore(RefStoreError::ReflogRewriteStale { .. }))
1609                if attempt + 1 < REFLOG_REWRITE_RETRIES =>
1610            {
1611                continue;
1612            }
1613            Err(err) => return Err(err),
1614        }
1615    }
1616    Err(Error::RefStore(RefStoreError::Corrupt(format!(
1617        "reflog '{refname}' kept changing during rewrite"
1618    ))))
1619}
1620
1621/// Per-ref `gc.<pattern>.reflogExpire*` rules plus global `gc.reflogExpire` / `gc.reflogExpireUnreachable`.
1622#[derive(Debug, Clone)]
1623pub struct GcReflogExpireConfig {
1624    /// `patterns` field.
1625    pub patterns: Vec<GcReflogPattern>,
1626    /// `global_total` field.
1627    pub global_total: Option<i64>,
1628    /// `global_unreachable` field.
1629    pub global_unreachable: Option<i64>,
1630}
1631
1632/// Load gc reflog expiry rules from merged config (same layering as Git `reflog_expire_config`).
1633#[must_use]
1634pub fn load_gc_reflog_expire_config(config: &ConfigSet, now: i64) -> GcReflogExpireConfig {
1635    let (global_total, global_unreachable) = global_gc_reflog_expiry(config, now);
1636    GcReflogExpireConfig {
1637        patterns: collect_gc_reflog_patterns(config, now),
1638        global_total,
1639        global_unreachable,
1640    }
1641}
1642
1643/// Best-effort object set for `--stale-fix` (refs + reflog mentions).
1644pub fn mark_stalefix_reachable(repo: &Repository, git_dir: &Path) -> Result<HashSet<ObjectId>> {
1645    let mut seeds: Vec<ObjectId> = Vec::new();
1646    if let Ok(oid) = refs::resolve_ref_in(repo, "HEAD") {
1647        seeds.push(oid);
1648    }
1649    if let Ok(refs) = refs::list_refs_for_repository(repo, "refs/") {
1650        for (_, oid) in refs {
1651            seeds.push(oid);
1652        }
1653    }
1654    if let Ok(names) = list_reflog_refs(git_dir) {
1655        for r in names {
1656            if let Ok(ent) = read_reflog(git_dir, &r) {
1657                for e in ent {
1658                    if !e.old_oid.is_zero() {
1659                        seeds.push(e.old_oid);
1660                    }
1661                    if !e.new_oid.is_zero() {
1662                        seeds.push(e.new_oid);
1663                    }
1664                }
1665            }
1666        }
1667    }
1668    seeds.sort();
1669    seeds.dedup();
1670
1671    let mut seen = HashSet::new();
1672    let mut queue: std::collections::VecDeque<ObjectId> = seeds.into_iter().collect();
1673    while let Some(oid) = queue.pop_front() {
1674        if oid.is_zero() || !seen.insert(oid) {
1675            continue;
1676        }
1677        let Ok(Some(obj)) = repo.odb().try_read_once(&oid) else {
1678            continue;
1679        };
1680        match obj.kind {
1681            ObjectKind::Commit => {
1682                if let Ok(c) = parse_commit(&obj.data) {
1683                    queue.push_back(c.tree);
1684                    for p in c.parents {
1685                        queue.push_back(p);
1686                    }
1687                }
1688            }
1689            ObjectKind::Tree => {
1690                if let Ok(entries) = parse_tree(&obj.data) {
1691                    for te in entries {
1692                        queue.push_back(te.oid);
1693                    }
1694                }
1695            }
1696            ObjectKind::Tag | ObjectKind::Blob => {}
1697        }
1698    }
1699    Ok(seen)
1700}
1701
1702#[cfg(test)]
1703mod reverse_scan_tests {
1704    use std::fs;
1705    use std::io::Write;
1706
1707    use super::{reflog_path, ReverseReflogLineReader, REFLOG_REVERSE_READ_CHUNK};
1708    use crate::objects::ObjectId;
1709
1710    fn sample_line(n: usize) -> String {
1711        let oid = ObjectId::zero();
1712        format!(
1713            "{oid} {oid} T <t@example.com> {ts} +0000\tcommit: line{n}\n",
1714            ts = 1_700_000_000 + n as i64
1715        )
1716    }
1717
1718    #[test]
1719    fn reverse_reader_returns_newest_first() {
1720        let dir = tempfile::tempdir().expect("tempdir");
1721        let git_dir = dir.path().join(".git");
1722        let path = reflog_path(&git_dir, "refs/heads/main");
1723        fs::create_dir_all(path.parent().expect("reflog parent")).expect("mkdir");
1724        let mut f = fs::File::create(&path).expect("create");
1725        write!(f, "{}", sample_line(1)).expect("w");
1726        write!(f, "{}", sample_line(2)).expect("w");
1727        let mut reader = ReverseReflogLineReader::open(&path)
1728            .expect("open")
1729            .expect("nonempty");
1730        let first = reader.next_nonempty_line().expect("read").expect("line");
1731        assert!(first.ends_with(b"line2"));
1732        let second = reader.next_nonempty_line().expect("read").expect("line");
1733        assert!(second.ends_with(b"line1"));
1734        assert!(reader.next_nonempty_line().expect("read").is_none());
1735    }
1736
1737    #[test]
1738    fn reverse_reader_spans_chunk_boundaries() {
1739        let dir = tempfile::tempdir().expect("tempdir");
1740        let git_dir = dir.path().join(".git");
1741        fs::create_dir_all(git_dir.join("logs")).expect("mkdir");
1742        let path = reflog_path(&git_dir, "HEAD");
1743        let mut f = fs::File::create(&path).expect("create");
1744        let pad = "x".repeat(REFLOG_REVERSE_READ_CHUNK + 128);
1745        write!(f, "{pad}\n").expect("pad");
1746        write!(f, "{}", sample_line(99)).expect("newest");
1747        let mut reader = ReverseReflogLineReader::open(&path)
1748            .expect("open")
1749            .expect("nonempty");
1750        let line = reader.next_nonempty_line().expect("read").expect("line");
1751        assert!(line.ends_with(b"line99"));
1752    }
1753}
1754
1755#[cfg(test)]
1756mod default_expire_tests {
1757    use std::fs;
1758    use std::path::Path;
1759
1760    use tempfile::TempDir;
1761
1762    use crate::config::ConfigSet;
1763    use crate::environment::Environment;
1764    use crate::objects::ObjectId;
1765    use crate::refs::{append_reflog, write_ref};
1766    use crate::repo::{init_repository, Repository};
1767
1768    use super::{
1769        all_reflog_oids, all_reflog_oids_ordered, default_expire_total, default_expire_unreachable,
1770        delete_reflog_entries, expire_reflog, expire_reflog_git, list_reflog_refs,
1771        load_gc_reflog_expire_config, mark_stalefix_reachable, read_reflog, read_reflog_dwim,
1772        truncate_last_reflog_line, ReflogExpireActionKind, ReflogExpireParams,
1773    };
1774    use super::{parse_reflog_expire_spec, parse_reflog_line};
1775
1776    #[test]
1777    fn gc_main_suffix_pattern_does_not_match_main_ref() {
1778        let now = 1_700_000_000_i64;
1779        let (tmp, _repo) = seed_repo();
1780        let git_dir = tmp.path().join(".git");
1781        std::fs::write(
1782            git_dir.join("config"),
1783            "[core]\nrepositoryformatversion = 0\n[gc \"refs/heads/main*suffix\"]\n\treflogExpire = now\n",
1784        )
1785        .expect("write config");
1786        let cfg = ConfigSet::load(&Environment::empty(), Some(&git_dir), true).expect("cfg");
1787        let gc = load_gc_reflog_expire_config(&cfg, now);
1788        assert_eq!(gc.patterns.len(), 1);
1789        assert_eq!(gc.patterns[0].pattern, "refs/heads/main*suffix");
1790        let (total, _) = super::resolve_expire_for_ref(
1791            "refs/heads/main",
1792            None,
1793            None,
1794            &gc.patterns,
1795            super::default_expire_total(now),
1796            super::default_expire_unreachable(now),
1797        );
1798        assert_ne!(
1799            total,
1800            i64::MAX,
1801            "refs/heads/main must not pick up main*suffix reflogExpire=now"
1802        );
1803        assert_eq!(total, super::default_expire_total(now));
1804    }
1805
1806    #[test]
1807    fn gc_reflog_expiry_uses_git_approxidate_threshold() {
1808        let now = 1_700_000_000_i64;
1809        assert_eq!(parse_reflog_expire_spec("never", now).expect("never"), 0);
1810        assert_eq!(parse_reflog_expire_spec("now", now).expect("now"), i64::MAX);
1811        // Git `match_digit`: bare integers >= 100_000_000 are Unix timestamps.
1812        assert_eq!(
1813            parse_reflog_expire_spec("100000000", now).expect("100M"),
1814            100_000_000
1815        );
1816        // 86_401..99_999_999 are not literal epoch seconds (e.g. 90000 → approxidate, not 90000).
1817        assert_ne!(
1818            parse_reflog_expire_spec("90000", now).expect("90000"),
1819            90_000
1820        );
1821    }
1822
1823    #[test]
1824    fn default_expire_total_is_ninety_days_before_now() {
1825        let now = 1_700_000_000_i64;
1826        let cutoff = default_expire_total(now);
1827        assert_eq!(cutoff, now - 90 * 86_400);
1828    }
1829
1830    #[test]
1831    fn default_expire_unreachable_is_thirty_days_before_now() {
1832        let now = 1_700_000_000_i64;
1833        let cutoff = default_expire_unreachable(now);
1834        assert_eq!(cutoff, now - 30 * 86_400);
1835    }
1836
1837    fn seed_repo() -> (TempDir, Repository) {
1838        let tmp = TempDir::new().expect("tempdir");
1839        let repo = init_repository(
1840            tmp.path(),
1841            false,
1842            "main",
1843            None,
1844            crate::RefStorageFormat::Files,
1845        )
1846        .expect("init");
1847        (tmp, repo)
1848    }
1849
1850    fn write_loose_reflog(git_dir: &Path, refname: &str, body: &str) {
1851        let path = super::reflog_path(git_dir, refname);
1852        if let Some(p) = path.parent() {
1853            fs::create_dir_all(p).expect("mkdir logs");
1854        }
1855        fs::write(path, body).expect("write reflog");
1856    }
1857
1858    #[test]
1859    fn parse_and_dwim_read_reflog_lines() {
1860        let (tmp, repo) = seed_repo();
1861        let git_dir = &repo.git_dir();
1862        let oid = crate::refs::test_commit(git_dir, "aaaaaaaa");
1863        write_ref(git_dir, "refs/heads/dwim", &oid).expect("write ref");
1864        let line = format!(
1865            "0000000000000000000000000000000000000000 {oid} Tester <t@e.com> 100 +0000\tmsg\n"
1866        );
1867        write_loose_reflog(git_dir, "refs/heads/dwim", &line);
1868        let parsed = parse_reflog_line(line.trim_end()).expect("parse");
1869        assert_eq!(parsed.message, "msg");
1870        let dwim = read_reflog_dwim(git_dir, "dwim").expect("dwim");
1871        assert_eq!(dwim.len(), 1);
1872        assert_eq!(dwim[0].message, "msg");
1873        assert!(read_reflog(git_dir, "missing").expect("read").is_empty());
1874        let _ = tmp;
1875    }
1876
1877    #[test]
1878    fn truncate_delete_and_expire_loose_reflog() {
1879        let (tmp, repo) = seed_repo();
1880        let git_dir = &repo.git_dir();
1881        let refname = "refs/heads/main";
1882        let o1 = ObjectId::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").expect("o1");
1883        let o2 = crate::refs::test_commit(git_dir, "cccccccc");
1884        write_ref(git_dir, refname, &o2).expect("tip");
1885        append_reflog(
1886            git_dir,
1887            refname,
1888            &ObjectId::zero(),
1889            &o1,
1890            "T <t@e.com> 1000 +0000",
1891            "a",
1892            true,
1893        )
1894        .expect("log1");
1895        append_reflog(
1896            git_dir,
1897            refname,
1898            &o1,
1899            &o2,
1900            "T <t@e.com> 2000 +0000",
1901            "b",
1902            false,
1903        )
1904        .expect("log2");
1905
1906        delete_reflog_entries(git_dir, refname, &[0]).expect("delete newest");
1907        assert_eq!(read_reflog(git_dir, refname).expect("read").len(), 1);
1908
1909        truncate_last_reflog_line(git_dir, refname).expect("truncate");
1910        assert!(read_reflog(git_dir, refname).expect("read2").is_empty());
1911
1912        append_reflog(
1913            git_dir,
1914            refname,
1915            &ObjectId::zero(),
1916            &o1,
1917            "T <t@e.com> 500 +0000",
1918            "old",
1919            true,
1920        )
1921        .expect("log old");
1922        let pruned = expire_reflog(git_dir, refname, Some(1_000)).expect("expire");
1923        assert_eq!(pruned, 1);
1924        let _ = tmp;
1925    }
1926
1927    #[test]
1928    fn expire_reflog_git_dry_run_and_stash_gc_never() {
1929        let (tmp, repo) = seed_repo();
1930        let git_dir = &repo.git_dir();
1931        let now = 1_700_000_000_i64;
1932        let refname = "refs/stash";
1933        let oid = crate::refs::test_commit(git_dir, "dddddddd");
1934        write_ref(git_dir, refname, &oid).expect("stash ref");
1935        append_reflog(
1936            git_dir,
1937            refname,
1938            &ObjectId::zero(),
1939            &oid,
1940            &format!("T <t@e.com> {} +0000", now - 86_400),
1941            "stash",
1942            true,
1943        )
1944        .expect("stash log");
1945
1946        let cfg = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("cfg");
1947        let gc = load_gc_reflog_expire_config(&cfg, now);
1948        let dry = expire_reflog_git(
1949            &repo,
1950            refname,
1951            &ReflogExpireParams {
1952                stale_fix: false,
1953                dry_run: true,
1954                verbose: true,
1955            },
1956            None,
1957            None,
1958            &gc.patterns,
1959            gc.global_total,
1960            gc.global_unreachable,
1961            now,
1962        )
1963        .expect("dry");
1964        assert!(!dry.actions.is_empty());
1965        assert!(read_reflog(git_dir, refname).expect("stash kept").len() == 1);
1966
1967        let real = expire_reflog_git(
1968            &repo,
1969            refname,
1970            &ReflogExpireParams {
1971                stale_fix: false,
1972                dry_run: false,
1973                verbose: false,
1974            },
1975            None,
1976            None,
1977            &gc.patterns,
1978            gc.global_total,
1979            gc.global_unreachable,
1980            now,
1981        )
1982        .expect("real");
1983        assert_eq!(real.pruned, 0, "refs/stash uses gc never by default");
1984        assert_eq!(read_reflog(git_dir, refname).expect("stash still").len(), 1);
1985        let _ = tmp;
1986    }
1987
1988    #[test]
1989    fn mark_stalefix_reachable_walks_reflog_mentions() {
1990        let (tmp, repo) = seed_repo();
1991        let git_dir = &repo.git_dir();
1992        let oid = crate::refs::test_commit(git_dir, "eeeeeeee");
1993        write_ref(git_dir, "refs/heads/main", &oid).expect("write");
1994        append_reflog(
1995            git_dir,
1996            "refs/heads/main",
1997            &ObjectId::zero(),
1998            &oid,
1999            "T <t@e.com> 1000 +0000",
2000            "m",
2001            true,
2002        )
2003        .expect("log");
2004        let seen = mark_stalefix_reachable(&repo, git_dir).expect("stalefix");
2005        assert!(seen.contains(&oid));
2006        let _ = tmp;
2007    }
2008
2009    #[test]
2010    fn all_reflog_oids_and_list_includes_head() {
2011        let (tmp, repo) = seed_repo();
2012        let git_dir = &repo.git_dir();
2013        let oid = crate::refs::test_commit(git_dir, "12121212");
2014        write_ref(git_dir, "refs/heads/main", &oid).expect("write");
2015        append_reflog(
2016            git_dir,
2017            "HEAD",
2018            &ObjectId::zero(),
2019            &oid,
2020            "T <t@e.com> 1000 +0000",
2021            "head",
2022            true,
2023        )
2024        .expect("head log");
2025        let refs = list_reflog_refs(git_dir).expect("list");
2026        assert!(refs.iter().any(|r| r == "HEAD"));
2027        let oids = all_reflog_oids(git_dir).expect("all oids");
2028        assert!(oids.contains(&oid));
2029        let ordered = all_reflog_oids_ordered(git_dir).expect("ordered");
2030        assert!(ordered.contains(&oid));
2031        let _ = tmp;
2032    }
2033
2034    #[test]
2035    fn mirror_branch_reflog_to_head_copies_bytes() {
2036        let (tmp, repo) = seed_repo();
2037        let git_dir = &repo.git_dir();
2038        let branch = "refs/heads/main";
2039        let oid = crate::refs::test_commit(git_dir, "13131313");
2040        write_ref(git_dir, branch, &oid).expect("write");
2041        append_reflog(
2042            git_dir,
2043            branch,
2044            &ObjectId::zero(),
2045            &oid,
2046            "T <t@e.com> 1000 +0000",
2047            "branch",
2048            true,
2049        )
2050        .expect("branch log");
2051        super::mirror_branch_reflog_to_head(git_dir, branch).expect("mirror");
2052        let head = read_reflog(git_dir, "HEAD").expect("head log");
2053        assert_eq!(head.len(), 1);
2054        assert_eq!(head[0].message, "branch");
2055        let _ = tmp;
2056    }
2057
2058    #[test]
2059    fn read_reflog_skips_blank_lines_and_no_message_tab() {
2060        let (tmp, repo) = seed_repo();
2061        let git_dir = &repo.git_dir();
2062        let oid = ObjectId::from_hex("1414141414141414141414141414141414141414").expect("oid");
2063        let body =
2064            format!("\n0000000000000000000000000000000000000000 {oid} T <t@e.com> 1000 +0000\n");
2065        write_loose_reflog(git_dir, "refs/heads/x", &body);
2066        let entries = read_reflog(git_dir, "refs/heads/x").expect("read");
2067        assert_eq!(entries.len(), 1);
2068        assert!(entries[0].message.is_empty());
2069        let _ = tmp;
2070    }
2071
2072    #[test]
2073    fn reflog_entry_action_prefixes() {
2074        let cases = [
2075            ("commit: init", Some("commit")),
2076            ("commit (initial): init", Some("commit (initial)")),
2077            ("commit (amend): tweak", Some("commit (amend)")),
2078            ("checkout: moving from main to topic", Some("checkout")),
2079            ("reset: moving to HEAD", Some("reset")),
2080            (
2081                "rebase (finish): refs/heads/main onto abc",
2082                Some("rebase (finish)"),
2083            ),
2084            ("branch: Created from HEAD", Some("branch")),
2085            ("branch: Reset to origin/main", Some("branch")),
2086            ("merge topic: Merge branch 'topic'", Some("merge topic")),
2087            ("pull: Fast-forward", Some("pull")),
2088            ("pull: rebase onto origin/main", Some("pull")),
2089            ("cherry-pick: subject", Some("cherry-pick")),
2090            ("revert: subject", Some("revert")),
2091            ("history undo", None),
2092            ("rewrite", None),
2093            ("prune-me", None),
2094        ];
2095        for (message, want) in cases {
2096            let entry = super::ReflogEntry {
2097                old_oid: ObjectId::zero(),
2098                new_oid: ObjectId::zero(),
2099                identity: String::new(),
2100                message: message.into(),
2101            };
2102            assert_eq!(entry.action(), want, "message={message:?}");
2103        }
2104    }
2105
2106    #[test]
2107    fn expire_git_prunes_old_entry_and_reports_action() {
2108        let (tmp, repo) = seed_repo();
2109        let git_dir = &repo.git_dir();
2110        let refname = "refs/heads/main";
2111        let oid = crate::refs::test_commit(git_dir, "ffffffff");
2112        write_ref(git_dir, refname, &oid).expect("write");
2113        append_reflog(
2114            git_dir,
2115            refname,
2116            &ObjectId::zero(),
2117            &oid,
2118            "T <t@e.com> 1000 +0000",
2119            "prune-me",
2120            true,
2121        )
2122        .expect("log");
2123        let now = 2_000_000_i64;
2124        let cfg = ConfigSet::load(&Environment::empty(), Some(git_dir), true).expect("cfg");
2125        let gc = load_gc_reflog_expire_config(&cfg, now);
2126        let result = expire_reflog_git(
2127            &repo,
2128            refname,
2129            &ReflogExpireParams {
2130                stale_fix: false,
2131                dry_run: false,
2132                verbose: false,
2133            },
2134            Some(1_500_000),
2135            None,
2136            &gc.patterns,
2137            gc.global_total,
2138            gc.global_unreachable,
2139            now,
2140        )
2141        .expect("expire");
2142        assert_eq!(result.pruned, 1);
2143        assert!(result
2144            .actions
2145            .iter()
2146            .any(|a| a.action == ReflogExpireActionKind::Prune && a.entry.message == "prune-me"));
2147        assert!(read_reflog(git_dir, refname).expect("empty").is_empty());
2148        let _ = tmp;
2149    }
2150}
2151
2152#[cfg(test)]
2153mod rewrite_concurrency_tests {
2154    use std::sync::{Arc, Barrier};
2155    use std::thread;
2156
2157    use crate::lockfile::LockWait;
2158    use crate::objects::ObjectId;
2159    use crate::refs::prepare_with_lock_wait;
2160    use crate::refs::store::{
2161        Expected, MemoryRefStore, RawRef, RefStore, RefTransaction, RefUpdate, RefUpdateFlags,
2162        ReflogUpdate,
2163    };
2164
2165    use super::{
2166        delete_reflog_entries_on_store, delete_reflog_entries_rechain_on_store, ReflogEntry,
2167    };
2168
2169    fn oid(byte: u8) -> ObjectId {
2170        let mut bytes = [0u8; 20];
2171        bytes[19] = byte;
2172        ObjectId::from_bytes(&bytes).expect("oid")
2173    }
2174
2175    fn append(
2176        store: &dyn RefStore,
2177        refname: &str,
2178        oid: ObjectId,
2179        ts: i64,
2180    ) -> crate::error::Result<()> {
2181        let txn = RefTransaction::new().update(RefUpdate {
2182            name: refname.to_owned(),
2183            new_value: Some(RawRef::Direct(oid)),
2184            expected: Expected::Any,
2185            reflog: Some(ReflogUpdate {
2186                identity: format!("T <t@example.com> {ts} +0000"),
2187                message: "x".into(),
2188                time: time::OffsetDateTime::from_unix_timestamp(ts).expect("ts"),
2189                old_oid: None,
2190                new_oid: Some(oid),
2191            }),
2192            flags: RefUpdateFlags::default(),
2193        })?;
2194        prepare_with_lock_wait(store, txn, LockWait::Indefinitely)?.commit()?;
2195        Ok(())
2196    }
2197
2198    fn read_entries(store: &dyn RefStore, refname: &str) -> Vec<ReflogEntry> {
2199        let mut out = Vec::new();
2200        store
2201            .for_each_reflog_entry(refname, false, &mut |e| {
2202                out.push(e.clone());
2203                std::ops::ControlFlow::Continue(())
2204            })
2205            .expect("read");
2206        out
2207    }
2208
2209    #[test]
2210    fn delete_on_store_uses_locked_entries_not_stale_snapshot() {
2211        let store = Arc::new(MemoryRefStore::new());
2212        let refname = "refs/heads/r";
2213        let o1 = oid(1);
2214        let o2 = oid(2);
2215        let o3 = oid(3);
2216        store
2217            .replace_reflog(
2218                refname,
2219                vec![
2220                    ReflogEntry {
2221                        old_oid: ObjectId::zero(),
2222                        new_oid: o1,
2223                        identity: "T <t@e.com> 100 +0000".into(),
2224                        message: String::new(),
2225                    },
2226                    ReflogEntry {
2227                        old_oid: o1,
2228                        new_oid: o2,
2229                        identity: "T <t@e.com> 200 +0000".into(),
2230                        message: String::new(),
2231                    },
2232                ],
2233            )
2234            .expect("seed");
2235        let barrier = Arc::new(Barrier::new(2));
2236        let s_delete = store.clone();
2237        let b_delete = barrier.clone();
2238        let deleter = thread::spawn(move || {
2239            b_delete.wait();
2240            delete_reflog_entries_on_store(s_delete.as_ref(), refname, &[1]).expect("delete");
2241        });
2242        let b_append = barrier.clone();
2243        let s_append = store.clone();
2244        let appender = thread::spawn(move || {
2245            b_append.wait();
2246            append(s_append.as_ref(), refname, o3, 9_000_000_000).expect("append");
2247        });
2248        deleter.join().expect("deleter");
2249        appender.join().expect("appender");
2250        let entries = read_entries(store.as_ref(), refname);
2251        assert!(
2252            entries.iter().any(|e| e.new_oid == o3),
2253            "concurrent append missing after delete rewrite"
2254        );
2255        assert_eq!(entries.len(), 2, "expected oldest removed and append kept");
2256    }
2257
2258    #[test]
2259    fn delete_rechain_on_store_uses_locked_entries_not_stale_snapshot() {
2260        let store = Arc::new(MemoryRefStore::new());
2261        let refname = "refs/heads/r";
2262        let o1 = oid(1);
2263        let o2 = oid(2);
2264        let o3 = oid(3);
2265        store
2266            .replace_reflog(
2267                refname,
2268                vec![
2269                    ReflogEntry {
2270                        old_oid: ObjectId::zero(),
2271                        new_oid: o1,
2272                        identity: "T <t@e.com> 100 +0000".into(),
2273                        message: String::new(),
2274                    },
2275                    ReflogEntry {
2276                        old_oid: o1,
2277                        new_oid: o2,
2278                        identity: "T <t@e.com> 200 +0000".into(),
2279                        message: String::new(),
2280                    },
2281                ],
2282            )
2283            .expect("seed");
2284        let barrier = Arc::new(Barrier::new(2));
2285        let s_delete = store.clone();
2286        let b_delete = barrier.clone();
2287        let deleter = thread::spawn(move || {
2288            b_delete.wait();
2289            delete_reflog_entries_rechain_on_store(s_delete.as_ref(), refname, &[1])
2290                .expect("rechain delete");
2291        });
2292        let b_append = barrier.clone();
2293        let s_append = store.clone();
2294        let appender = thread::spawn(move || {
2295            b_append.wait();
2296            append(s_append.as_ref(), refname, o3, 9_000_000_000).expect("append");
2297        });
2298        deleter.join().expect("deleter");
2299        appender.join().expect("appender");
2300        let entries = read_entries(store.as_ref(), refname);
2301        assert!(entries.iter().any(|e| e.new_oid == o3));
2302    }
2303}
2304
2305#[cfg(test)]
2306mod completeness_tests {
2307    use std::collections::HashSet;
2308
2309    use tempfile::TempDir;
2310
2311    use crate::objects::{serialize_tree, ObjectId, ObjectKind, TreeEntry};
2312    use crate::repo::{init_repository, Repository};
2313
2314    use super::{Linkage, ReachableCommits, ReflogExpiryCache};
2315
2316    fn seed_repo() -> (TempDir, Repository) {
2317        let tmp = TempDir::new().expect("tempdir");
2318        let repo = init_repository(
2319            tmp.path(),
2320            false,
2321            "main",
2322            None,
2323            crate::RefStorageFormat::Files,
2324        )
2325        .expect("init");
2326        (tmp, repo)
2327    }
2328
2329    fn tree(repo: &Repository, entries: &[(u32, &str, ObjectId)]) -> ObjectId {
2330        let entries = entries
2331            .iter()
2332            .map(|&(mode, name, oid)| TreeEntry {
2333                mode,
2334                name: name.as_bytes().to_vec(),
2335                oid,
2336            })
2337            .collect::<Vec<_>>();
2338        repo.odb()
2339            .write(ObjectKind::Tree, &serialize_tree(&entries))
2340            .expect("write tree")
2341    }
2342
2343    fn commit(repo: &Repository, tree: ObjectId, parents: &[ObjectId]) -> ObjectId {
2344        commit_at(repo, tree, parents, 1)
2345    }
2346
2347    fn commit_at(repo: &Repository, tree: ObjectId, parents: &[ObjectId], when: i64) -> ObjectId {
2348        let mut body = format!("tree {tree}\n");
2349        for p in parents {
2350            body.push_str(&format!("parent {p}\n"));
2351        }
2352        body.push_str(&format!(
2353            "author A <a@example.com> {when} +0000\ncommitter A <a@example.com> {when} +0000\n\nc\n"
2354        ));
2355        repo.odb()
2356            .write(ObjectKind::Commit, body.as_bytes())
2357            .expect("write commit")
2358    }
2359
2360    #[test]
2361    fn completeness_walk_is_memoised_and_stops_at_missing_objects_and_promises() {
2362        let (_tmp, repo) = seed_repo();
2363        let blob = repo.odb().write(ObjectKind::Blob, b"a\n").expect("blob");
2364        let sub = tree(&repo, &[(0o100644, "b", blob)]);
2365        let root = tree(&repo, &[(0o100644, "a", blob), (0o040000, "sub", sub)]);
2366        let base = commit(&repo, root, &[]);
2367        let left = commit(&repo, root, &[base]);
2368        let right = commit(&repo, sub, &[base]);
2369        let merge = commit(&repo, root, &[left, right]);
2370
2371        let none = HashSet::new();
2372        let cache = ReflogExpiryCache::default();
2373        assert!(cache.commit_complete(&repo, &none, ObjectId::zero()));
2374        let probes = repo.odb().exists_probe_count();
2375        assert!(cache.commit_complete(&repo, &none, merge));
2376        assert_eq!(
2377            repo.odb().exists_probe_count() - probes,
2378            2,
2379            "each blob entry of each distinct tree is probed once"
2380        );
2381        let probes = repo.odb().exists_probe_count();
2382        assert!(cache.commit_complete(&repo, &none, left));
2383        assert!(cache.commit_complete(&repo, &none, merge));
2384        assert_eq!(
2385            repo.odb().exists_probe_count(),
2386            probes,
2387            "answers are reused"
2388        );
2389
2390        let missing = ObjectId::from_hex("1111111111111111111111111111111111111111").expect("oid");
2391        let broken_tree = tree(&repo, &[(0o100644, "gone", missing)]);
2392        let broken = commit(&repo, broken_tree, &[merge]);
2393        let child = commit(&repo, root, &[broken]);
2394        assert!(!cache.commit_complete(&repo, &none, broken));
2395        assert!(!cache.commit_complete(&repo, &none, child));
2396        assert!(!cache.commit_complete(&repo, &none, missing));
2397        assert!(!cache.commit_complete(&repo, &none, blob), "not a commit");
2398
2399        let promised = HashSet::from([missing]);
2400        let fresh = ReflogExpiryCache::default();
2401        assert!(fresh.commit_complete(&repo, &promised, child));
2402        assert!(fresh.commit_complete(&repo, &promised, missing));
2403    }
2404
2405    #[test]
2406    fn reachability_walk_stops_at_the_cutoff_until_a_lookup_misses() {
2407        let (_tmp, repo) = seed_repo();
2408        let tree = tree(&repo, &[]);
2409        let mut chain = Vec::new();
2410        for when in 1..=10 {
2411            let parents = chain.last().copied().into_iter().collect::<Vec<_>>();
2412            chain.push(commit_at(&repo, tree, &parents, when));
2413        }
2414        let unrelated = commit_at(&repo, tree, &[], 5);
2415
2416        let cache = ReflogExpiryCache::default();
2417        let mut reachable = ReachableCommits::new(&repo, &cache, vec![chain[9]], 8);
2418        assert_eq!(
2419            reachable.seen_count, 4,
2420            "commits from time 10 down to the first one older than the cutoff"
2421        );
2422        assert!(reachable.contains(&chain[7]));
2423        assert_eq!(reachable.seen_count, 4, "a hit does not walk further");
2424        assert!(reachable.contains(&chain[0]));
2425        assert_eq!(reachable.seen_count, 10, "a miss walks the rest once");
2426        assert!(!reachable.contains(&unrelated));
2427
2428        let mut everything = ReachableCommits::new(&repo, &cache, vec![chain[9]], 0);
2429        assert_eq!(everything.seen_count, 10);
2430        assert!(!everything.contains(&unrelated));
2431        assert_eq!(
2432            cache.commits.borrow().len(),
2433            10,
2434            "both walks share one interned commit per commit"
2435        );
2436
2437        {
2438            let mut commits = cache.commits.borrow_mut();
2439            let tip = commits.intern(chain[9]) as usize;
2440            let parent = commits.intern(unrelated);
2441            commits.nodes[tip] = Linkage::Read {
2442                time: 10,
2443                parents: Box::new([parent]),
2444            };
2445        }
2446        let mut reused = ReachableCommits::new(&repo, &cache, vec![chain[9]], 0);
2447        assert!(
2448            reused.contains(&unrelated),
2449            "a later walk in the same pass takes linkage from the cache"
2450        );
2451        assert_eq!(
2452            ReachableCommits::new(&repo, &cache, Vec::new(), 0).seen_count,
2453            0
2454        );
2455    }
2456
2457    #[test]
2458    fn reachability_walk_counts_shallow_boundaries_as_reachable_without_walking_past_them() {
2459        let (_tmp, repo) = seed_repo();
2460        let tree = tree(&repo, &[]);
2461        let root = commit_at(&repo, tree, &[], 1);
2462        let boundary = commit_at(&repo, tree, &[root], 2);
2463        let tip = commit_at(&repo, tree, &[boundary], 3);
2464        std::fs::write(repo.git_dir().join("shallow"), format!("{boundary}\n")).expect("shallow");
2465
2466        let cache = ReflogExpiryCache::default();
2467        let mut reachable = ReachableCommits::new(&repo, &cache, vec![tip], 0);
2468        assert!(reachable.contains(&boundary));
2469        assert!(!reachable.contains(&root));
2470        assert_eq!(reachable.seen_count, 2);
2471    }
2472}