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::collections::{HashMap, HashSet};
12use std::fs;
13use std::io;
14use std::path::{Path, PathBuf};
15
16use crate::config::ConfigSet;
17use crate::diff::zero_oid;
18use crate::error::{Error, Result};
19use crate::merge_base;
20use crate::objects::{parse_commit, parse_tree, ObjectId, ObjectKind};
21use crate::refs::{self, reflog_file_path};
22use crate::repo::Repository;
23use crate::wildmatch::{wildmatch, WM_PATHNAME};
24
25/// A single reflog entry.
26#[derive(Debug, Clone)]
27pub struct ReflogEntry {
28    /// Previous object ID.
29    pub old_oid: ObjectId,
30    /// New object ID.
31    pub new_oid: ObjectId,
32    /// Identity string: `"Name <email> timestamp tz"`.
33    pub identity: String,
34    /// The log message.
35    pub message: String,
36}
37
38/// Return the filesystem path for a ref's reflog.
39///
40/// Uses the same storage rules as [`refs::append_reflog`] (branch reflogs under the
41/// repository common directory for linked worktrees).
42pub fn reflog_path(git_dir: &Path, refname: &str) -> PathBuf {
43    reflog_file_path(git_dir, refname)
44}
45
46/// Check whether a reflog exists for the given ref.
47pub fn reflog_exists(git_dir: &Path, refname: &str) -> bool {
48    if crate::reftable::is_reftable_repo(git_dir) {
49        return crate::reftable::reftable_reflog_exists(git_dir, refname);
50    }
51    let path = reflog_path(git_dir, refname);
52    path.is_file()
53}
54
55/// Read a reflog using Git's loose ref DWIM rules when the direct path is missing.
56///
57/// Tries `refname`, then `refs/<refname>`, then `refs/heads/<refname>` (when `refname` is not
58/// already under `refs/`). Matches `read_complete_reflog` in Git's `reflog-walk.c`.
59pub fn read_reflog_dwim(git_dir: &Path, refname: &str) -> Result<Vec<ReflogEntry>> {
60    let mut entries = read_reflog(git_dir, refname)?;
61    if !entries.is_empty() {
62        return Ok(entries);
63    }
64    if !refname.starts_with("refs/") {
65        entries = read_reflog(git_dir, &format!("refs/{refname}"))?;
66        if !entries.is_empty() {
67            return Ok(entries);
68        }
69        entries = read_reflog(git_dir, &format!("refs/heads/{refname}"))?;
70    }
71    Ok(entries)
72}
73
74/// Read all reflog entries for the given ref, in file order (oldest first).
75///
76/// Returns an empty vec if the reflog file does not exist.
77pub fn read_reflog(git_dir: &Path, refname: &str) -> Result<Vec<ReflogEntry>> {
78    if crate::reftable::is_reftable_repo(git_dir) {
79        return crate::reftable::reftable_read_reflog(git_dir, refname);
80    }
81    let path = reflog_path(git_dir, refname);
82    let content = match fs::read_to_string(&path) {
83        Ok(c) => c,
84        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
85        Err(e) => return Err(Error::Io(e)),
86    };
87
88    let mut entries = Vec::new();
89    for line in content.lines() {
90        if line.is_empty() {
91            continue;
92        }
93        if let Some(entry) = parse_reflog_line(line) {
94            entries.push(entry);
95        }
96    }
97    Ok(entries)
98}
99
100/// Parse a single reflog line.
101///
102/// Format: `<old-hex> <new-hex> <identity>\t<message>`
103fn parse_reflog_line(line: &str) -> Option<ReflogEntry> {
104    // Split on tab first to separate identity from message
105    let (before_tab, message) = if let Some(pos) = line.find('\t') {
106        (&line[..pos], line[pos + 1..].to_string())
107    } else {
108        (line, String::new())
109    };
110
111    // The first 40 chars are old OID, then space, then 40 chars new OID, then space, then identity
112    if before_tab.len() < 83 {
113        // 40 + 1 + 40 + 1 + at least 1 char identity
114        return None;
115    }
116
117    let old_hex = &before_tab[..40];
118    let new_hex = &before_tab[41..81];
119    let identity = before_tab[82..].to_string();
120
121    let old_oid = old_hex.parse::<ObjectId>().ok()?;
122    let new_oid = new_hex.parse::<ObjectId>().ok()?;
123
124    Some(ReflogEntry {
125        old_oid,
126        new_oid,
127        identity,
128        message,
129    })
130}
131
132/// Collect every non-null object ID mentioned in any file under `logs/` (recursive).
133///
134/// Used by `fsck` to validate reflog entries. Skips reftable-backed repos (no file logs).
135pub fn all_reflog_oids(git_dir: &Path) -> Result<HashSet<ObjectId>> {
136    if crate::reftable::is_reftable_repo(git_dir) {
137        return Ok(HashSet::new());
138    }
139    let mut out = HashSet::new();
140    let logs = git_dir.join("logs");
141    if !logs.is_dir() {
142        return Ok(out);
143    }
144    let z = zero_oid();
145    walk_reflog_files(&logs, &mut out, &z)?;
146    Ok(out)
147}
148
149fn walk_reflog_files(dir: &Path, out: &mut HashSet<ObjectId>, zero: &ObjectId) -> Result<()> {
150    for entry in fs::read_dir(dir).map_err(Error::Io)? {
151        let entry = entry.map_err(Error::Io)?;
152        let path = entry.path();
153        if path.is_dir() {
154            walk_reflog_files(&path, out, zero)?;
155        } else if path.is_file() {
156            let content = fs::read_to_string(&path).map_err(Error::Io)?;
157            for line in content.lines() {
158                if let Some(e) = parse_reflog_line(line) {
159                    if e.old_oid != *zero {
160                        out.insert(e.old_oid);
161                    }
162                    if e.new_oid != *zero {
163                        out.insert(e.new_oid);
164                    }
165                }
166            }
167        }
168    }
169    Ok(())
170}
171
172/// Delete specific reflog entries by index (0-based, newest-first order).
173///
174/// Rewrites the reflog file, omitting entries at the given indices.
175pub fn delete_reflog_entries(git_dir: &Path, refname: &str, indices: &[usize]) -> Result<()> {
176    let mut entries = read_reflog(git_dir, refname)?;
177    if entries.is_empty() {
178        return Ok(());
179    }
180
181    // Indices are in newest-first order (like show), so reverse the entries
182    // to map indices correctly.
183    entries.reverse();
184
185    let indices_set: std::collections::HashSet<usize> = indices.iter().copied().collect();
186
187    let remaining: Vec<&ReflogEntry> = entries
188        .iter()
189        .enumerate()
190        .filter(|(i, _)| !indices_set.contains(i))
191        .map(|(_, e)| e)
192        .collect();
193
194    // Write back in file order (oldest first), so reverse again
195    let mut lines = Vec::new();
196    for entry in remaining.iter().rev() {
197        lines.push(format_reflog_entry(entry));
198    }
199
200    if crate::reftable::is_reftable_repo(git_dir) {
201        let kept: Vec<ReflogEntry> = remaining
202            .iter()
203            .rev()
204            .map(|entry| (*entry).clone())
205            .collect();
206        return crate::reftable::reftable_replace_reflog(git_dir, refname, &kept);
207    }
208
209    let path = reflog_path(git_dir, refname);
210    fs::write(&path, lines.join(""))?;
211    Ok(())
212}
213
214/// Expire (prune) reflog entries older than a given timestamp (Unix seconds).
215///
216/// If `expire_time` is `None`, removes all entries.
217pub fn expire_reflog(git_dir: &Path, refname: &str, expire_time: Option<i64>) -> Result<usize> {
218    let entries = read_reflog(git_dir, refname)?;
219    if entries.is_empty() {
220        return Ok(0);
221    }
222
223    let mut kept = Vec::new();
224    let mut kept_entries = Vec::new();
225    let mut pruned = 0usize;
226
227    for entry in &entries {
228        let ts = parse_timestamp_from_identity(&entry.identity);
229        let dominated = match (expire_time, ts) {
230            (Some(cutoff), Some(t)) => t < cutoff,
231            (None, _) => true,        // expire all
232            (Some(_), None) => false, // can't parse => keep
233        };
234        if dominated {
235            pruned += 1;
236        } else {
237            kept_entries.push(entry.clone());
238            kept.push(format_reflog_entry(entry));
239        }
240    }
241
242    if crate::reftable::is_reftable_repo(git_dir) {
243        crate::reftable::reftable_replace_reflog(git_dir, refname, &kept_entries)?;
244        return Ok(pruned);
245    }
246    let path = reflog_path(git_dir, refname);
247    fs::write(&path, kept.join(""))?;
248    Ok(pruned)
249}
250
251/// Expire reflog entries whose `new_oid` is not an ancestor of the current ref tip
252/// and whose identity timestamp is older than `cutoff` (Unix seconds).
253///
254/// Entries with an all-zero `new_oid` are never removed by this pass.
255///
256/// When `cutoff` is `None`, no entries are removed.
257///
258/// Reftable-backed repositories are skipped until reflog rewrite is implemented there.
259pub fn expire_reflog_unreachable(
260    repo: &Repository,
261    git_dir: &Path,
262    refname: &str,
263    cutoff: Option<i64>,
264) -> Result<usize> {
265    let Some(cutoff) = cutoff else {
266        return Ok(0);
267    };
268    if crate::reftable::is_reftable_repo(git_dir) {
269        return Ok(0);
270    }
271    let tip = match refs::resolve_ref(git_dir, refname) {
272        Ok(o) => o,
273        Err(_) => return Ok(0),
274    };
275    let ancestors = match merge_base::ancestor_closure(repo, tip) {
276        Ok(a) => a,
277        Err(_) => return Ok(0),
278    };
279
280    let entries = read_reflog(git_dir, refname)?;
281    if entries.is_empty() {
282        return Ok(0);
283    }
284
285    let path = reflog_path(git_dir, refname);
286    let mut kept = Vec::new();
287    let mut pruned = 0usize;
288
289    for entry in &entries {
290        let ts = parse_timestamp_from_identity(&entry.identity);
291        let unreachable = !entry.new_oid.is_zero() && !ancestors.contains(&entry.new_oid);
292        let should_prune = unreachable && matches!(ts, Some(t) if t < cutoff);
293        if should_prune {
294            pruned += 1;
295        } else {
296            kept.push(format_reflog_entry(entry));
297        }
298    }
299
300    fs::write(&path, kept.join(""))?;
301    Ok(pruned)
302}
303
304/// Format a reflog entry back into the on-disk line format.
305fn format_reflog_entry(entry: &ReflogEntry) -> String {
306    if entry.message.is_empty() {
307        format!("{} {} {}\n", entry.old_oid, entry.new_oid, entry.identity)
308    } else {
309        format!(
310            "{} {} {}\t{}\n",
311            entry.old_oid, entry.new_oid, entry.identity, entry.message
312        )
313    }
314}
315
316/// Extract the Unix timestamp from an identity string.
317///
318/// Identity format: `Name <email> <timestamp> <tz>`
319fn parse_timestamp_from_identity(identity: &str) -> Option<i64> {
320    // Walk backwards: last token is tz (+0000), second-to-last is timestamp
321    let parts: Vec<&str> = identity.rsplitn(3, ' ').collect();
322    if parts.len() >= 2 {
323        parts[1].parse::<i64>().ok()
324    } else {
325        None
326    }
327}
328
329/// Copy `logs/<branch_refname>` to `logs/HEAD` when keeping symbolic-HEAD reflogs aligned with
330/// the checked-out branch (matches Git).
331pub fn mirror_branch_reflog_to_head(git_dir: &Path, branch_refname: &str) -> Result<()> {
332    if crate::reftable::is_reftable_repo(git_dir) {
333        return Ok(());
334    }
335    let src = reflog_path(git_dir, branch_refname);
336    if !src.is_file() {
337        return Ok(());
338    }
339    let content = fs::read_to_string(&src).map_err(Error::Io)?;
340    let dst = reflog_path(git_dir, "HEAD");
341    if let Some(parent) = dst.parent() {
342        fs::create_dir_all(parent).map_err(Error::Io)?;
343    }
344    fs::write(&dst, content).map_err(Error::Io)?;
345    Ok(())
346}
347
348/// List all refs that have reflogs.
349pub fn list_reflog_refs(git_dir: &Path) -> Result<Vec<String>> {
350    if crate::reftable::is_reftable_repo(git_dir) {
351        return crate::reftable::reftable_list_reflog_refs(git_dir);
352    }
353    let mut refs = Vec::new();
354    let mut seen = HashSet::new();
355
356    fn collect_from_logs_root(
357        logs_dir: &Path,
358        out: &mut Vec<String>,
359        seen: &mut HashSet<String>,
360    ) -> Result<()> {
361        if logs_dir.join("HEAD").is_file() && seen.insert("HEAD".to_string()) {
362            out.push("HEAD".to_string());
363        }
364        let refs_logs = logs_dir.join("refs");
365        if refs_logs.is_dir() {
366            collect_reflog_refs(&refs_logs, "refs", out, seen)?;
367        }
368        Ok(())
369    }
370
371    collect_from_logs_root(&git_dir.join("logs"), &mut refs, &mut seen)?;
372    if let Some(common) = refs::common_dir(git_dir) {
373        if common != git_dir {
374            collect_from_logs_root(&common.join("logs"), &mut refs, &mut seen)?;
375        }
376    }
377
378    Ok(refs)
379}
380
381fn collect_reflog_refs(
382    dir: &Path,
383    prefix: &str,
384    out: &mut Vec<String>,
385    seen: &mut HashSet<String>,
386) -> Result<()> {
387    let read_dir = match fs::read_dir(dir) {
388        Ok(rd) => rd,
389        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
390        Err(e) => return Err(Error::Io(e)),
391    };
392
393    for entry in read_dir {
394        let entry = entry.map_err(Error::Io)?;
395        let name = entry.file_name().to_string_lossy().to_string();
396        let full_name = format!("{prefix}/{name}");
397        let ft = entry.file_type().map_err(Error::Io)?;
398        if ft.is_dir() {
399            collect_reflog_refs(&entry.path(), &full_name, out, seen)?;
400        } else if ft.is_file() && seen.insert(full_name.clone()) {
401            out.push(full_name);
402        }
403    }
404    Ok(())
405}
406
407// --- `git reflog expire` -----------------------------------------------------
408
409/// Options for [`expire_reflog_git`].
410#[derive(Debug, Clone)]
411pub struct ReflogExpireParams {
412    /// Prune entries whose commits fail a completeness walk (missing objects).
413    pub stale_fix: bool,
414    pub dry_run: bool,
415    pub verbose: bool,
416}
417
418/// Per-ref `gc.<pattern>.reflogExpire*` rule from config.
419#[derive(Debug, Clone)]
420pub struct GcReflogPattern {
421    pattern: String,
422    expire_total: i64,
423    expire_unreachable: i64,
424}
425
426fn collect_gc_reflog_patterns(config: &ConfigSet, now: i64) -> Vec<GcReflogPattern> {
427    let mut by_pattern: HashMap<String, GcReflogPattern> = HashMap::new();
428    for e in config.entries() {
429        let key = e.key.as_str();
430        let Some(rest) = key.strip_prefix("gc.") else {
431            continue;
432        };
433        // Per-ref: `gc.<wildmatch-pattern>.reflogExpire` (pattern may contain dots).
434        // Global `gc.reflogExpire` has no pattern segment — see [`global_gc_reflog_expiry`].
435        let Some((pat, suffix)) = rest.rsplit_once('.') else {
436            continue;
437        };
438        if !suffix.eq_ignore_ascii_case("reflogexpire")
439            && !suffix.eq_ignore_ascii_case("reflogexpireunreachable")
440        {
441            continue;
442        }
443        let Some(val) = e.value.as_deref() else {
444            continue;
445        };
446        let Ok(ts) = parse_gc_reflog_expiry(val, now) else {
447            continue;
448        };
449        let ent = by_pattern
450            .entry(pat.to_string())
451            .or_insert(GcReflogPattern {
452                pattern: pat.to_string(),
453                expire_total: i64::MAX,
454                expire_unreachable: i64::MAX,
455            });
456        if suffix.eq_ignore_ascii_case("reflogexpire") {
457            ent.expire_total = ts;
458        } else {
459            ent.expire_unreachable = ts;
460        }
461    }
462    by_pattern.into_values().collect()
463}
464
465fn global_gc_reflog_expiry(config: &ConfigSet, now: i64) -> (Option<i64>, Option<i64>) {
466    let total = config
467        .get("gc.reflogExpire")
468        .and_then(|v| parse_gc_reflog_expiry(&v, now).ok());
469    let unreach = config
470        .get("gc.reflogExpireUnreachable")
471        .and_then(|v| parse_gc_reflog_expiry(&v, now).ok());
472    (total, unreach)
473}
474
475/// Parse `gc.reflogExpire` values: `never` / `false` → keep forever (`0`), else days or epoch.
476fn parse_gc_reflog_expiry(raw: &str, now: i64) -> Result<i64> {
477    let s = raw.trim();
478    if s.eq_ignore_ascii_case("never") || s.eq_ignore_ascii_case("false") {
479        return Ok(0);
480    }
481    if let Ok(days) = s.parse::<u64>() {
482        if days == 0 {
483            return Ok(0);
484        }
485        return Ok(now - (days as i64 * 86400));
486    }
487    s.parse::<i64>()
488        .map_err(|_| Error::Message(format!("invalid reflog expiry: {raw:?}")))
489}
490
491fn default_expire_total(now: i64) -> i64 {
492    now - 30 * 86400
493}
494
495fn default_expire_unreachable(now: i64) -> i64 {
496    now - 90 * 86400
497}
498
499fn resolve_expire_for_ref(
500    refname: &str,
501    explicit_total: Option<i64>,
502    explicit_unreachable: Option<i64>,
503    patterns: &[GcReflogPattern],
504    default_total: i64,
505    default_unreachable: i64,
506) -> (i64, i64) {
507    let mut expire_total = explicit_total.unwrap_or(default_total);
508    let mut expire_unreachable = explicit_unreachable.unwrap_or(default_unreachable);
509    if explicit_total.is_some() && explicit_unreachable.is_some() {
510        return (expire_total, expire_unreachable);
511    }
512    for ent in patterns {
513        if wildmatch(ent.pattern.as_bytes(), refname.as_bytes(), WM_PATHNAME) {
514            // Partial per-pattern config only sets one key; the other stays `i64::MAX` as sentinel.
515            if explicit_total.is_none() && ent.expire_total != i64::MAX {
516                expire_total = ent.expire_total;
517            }
518            if explicit_unreachable.is_none() && ent.expire_unreachable != i64::MAX {
519                expire_unreachable = ent.expire_unreachable;
520            }
521            return (expire_total, expire_unreachable);
522        }
523    }
524    if refname == "refs/stash" {
525        if explicit_total.is_none() {
526            expire_total = 0;
527        }
528        if explicit_unreachable.is_none() {
529            expire_unreachable = 0;
530        }
531    }
532    (expire_total, expire_unreachable)
533}
534
535fn tree_fully_complete(repo: &Repository, oid: ObjectId, depth: usize) -> bool {
536    if depth > 65536 {
537        return false;
538    }
539    let Ok(obj) = repo.odb.read(&oid) else {
540        return false;
541    };
542    match obj.kind {
543        ObjectKind::Blob => true,
544        ObjectKind::Tree => {
545            let Ok(entries) = parse_tree(&obj.data) else {
546                return false;
547            };
548            for e in entries {
549                if !tree_fully_complete(repo, e.oid, depth + 1) {
550                    return false;
551                }
552            }
553            true
554        }
555        _ => false,
556    }
557}
558
559fn commit_chain_complete(repo: &Repository, oid: ObjectId, depth: usize) -> bool {
560    if oid.is_zero() {
561        return true;
562    }
563    if depth > 65536 {
564        return false;
565    }
566    let Ok(obj) = repo.odb.read(&oid) else {
567        return false;
568    };
569    if obj.kind != ObjectKind::Commit {
570        return false;
571    }
572    let Ok(c) = parse_commit(&obj.data) else {
573        return false;
574    };
575    if !tree_fully_complete(repo, c.tree, depth + 1) {
576        return false;
577    }
578    for p in &c.parents {
579        if !commit_chain_complete(repo, *p, depth + 1) {
580            return false;
581        }
582    }
583    true
584}
585
586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587enum UnreachableKind {
588    Always,
589    Normal,
590    Head,
591}
592
593fn is_head_ref(refname: &str) -> bool {
594    refname == "HEAD" || refname.ends_with("/HEAD")
595}
596
597fn tip_commits_for_reflog(repo: &Repository, git_dir: &Path, refname: &str) -> Vec<ObjectId> {
598    let mut tips = Vec::new();
599    if is_head_ref(refname) {
600        if let Ok(oid) = refs::resolve_ref(git_dir, "HEAD") {
601            tips.push(oid);
602        }
603        if let Ok(refs) = refs::list_refs(git_dir, "refs/") {
604            for (_, oid) in refs {
605                tips.push(oid);
606            }
607        }
608    } else if let Ok(oid) = refs::resolve_ref(git_dir, refname) {
609        tips.push(oid);
610    }
611    tips.sort();
612    tips.dedup();
613    tips.retain(|o| commit_chain_complete(repo, *o, 0));
614    tips
615}
616
617fn reachable_commit_set(repo: &Repository, tips: &[ObjectId]) -> HashSet<ObjectId> {
618    let mut acc = HashSet::new();
619    for t in tips {
620        if let Ok(cl) = merge_base::ancestor_closure(repo, *t) {
621            acc.extend(cl);
622        }
623    }
624    acc
625}
626
627fn is_unreachable_oid(
628    repo: &Repository,
629    reachable: &HashSet<ObjectId>,
630    kind: UnreachableKind,
631    oid: ObjectId,
632) -> bool {
633    if oid.is_zero() {
634        return false;
635    }
636    if reachable.contains(&oid) {
637        return false;
638    }
639    if kind == UnreachableKind::Always {
640        return true;
641    }
642    let Ok(obj) = repo.odb.read(&oid) else {
643        return true;
644    };
645    obj.kind == ObjectKind::Commit
646}
647
648fn should_drop_reflog_entry(
649    repo: &Repository,
650    entry: &ReflogEntry,
651    expire_total: i64,
652    expire_unreachable: i64,
653    unreachable_kind: UnreachableKind,
654    reachable: &HashSet<ObjectId>,
655    stale_fix: bool,
656) -> bool {
657    let ts = parse_timestamp_from_identity(&entry.identity).unwrap_or(i64::MAX);
658    if expire_total > 0 && ts < expire_total {
659        return true;
660    }
661    if stale_fix
662        && (!commit_chain_complete(repo, entry.old_oid, 0)
663            || !commit_chain_complete(repo, entry.new_oid, 0))
664    {
665        return true;
666    }
667    if expire_unreachable > 0 && ts < expire_unreachable {
668        match unreachable_kind {
669            UnreachableKind::Always => return true,
670            UnreachableKind::Normal | UnreachableKind::Head => {
671                if is_unreachable_oid(repo, reachable, unreachable_kind, entry.old_oid)
672                    || is_unreachable_oid(repo, reachable, unreachable_kind, entry.new_oid)
673                {
674                    return true;
675                }
676            }
677        }
678    }
679    false
680}
681
682/// Git-compatible reflog expiry for one ref.
683pub fn expire_reflog_git(
684    repo: &Repository,
685    git_dir: &Path,
686    refname: &str,
687    params: &ReflogExpireParams,
688    explicit_total: Option<i64>,
689    explicit_unreachable: Option<i64>,
690    gc_patterns: &[GcReflogPattern],
691    gc_global_total: Option<i64>,
692    gc_global_unreachable: Option<i64>,
693    now: i64,
694) -> Result<usize> {
695    let is_reftable = crate::reftable::is_reftable_repo(git_dir);
696    let base_total = gc_global_total.unwrap_or_else(|| default_expire_total(now));
697    let base_unreachable = gc_global_unreachable.unwrap_or_else(|| default_expire_unreachable(now));
698    let (expire_total, expire_unreachable) = resolve_expire_for_ref(
699        refname,
700        explicit_total,
701        explicit_unreachable,
702        gc_patterns,
703        base_total,
704        base_unreachable,
705    );
706
707    let unreachable_kind = if expire_unreachable <= expire_total {
708        UnreachableKind::Always
709    } else if expire_unreachable == 0 || is_head_ref(refname) {
710        UnreachableKind::Head
711    } else {
712        match refs::resolve_ref(git_dir, refname) {
713            Ok(t) if commit_chain_complete(repo, t, 0) => UnreachableKind::Normal,
714            _ => UnreachableKind::Always,
715        }
716    };
717
718    let tips = tip_commits_for_reflog(repo, git_dir, refname);
719    let reachable = if matches!(unreachable_kind, UnreachableKind::Always) {
720        HashSet::new()
721    } else {
722        reachable_commit_set(repo, &tips)
723    };
724
725    let entries = read_reflog(git_dir, refname)?;
726    if entries.is_empty() {
727        return Ok(0);
728    }
729    let mut kept = Vec::new();
730    let mut kept_entries = Vec::new();
731    let mut pruned = 0usize;
732
733    for entry in &entries {
734        let drop = should_drop_reflog_entry(
735            repo,
736            entry,
737            expire_total,
738            expire_unreachable,
739            unreachable_kind,
740            &reachable,
741            params.stale_fix,
742        );
743        if drop {
744            pruned += 1;
745            if params.verbose {
746                if params.dry_run {
747                    println!("would prune {}", entry.message);
748                } else {
749                    println!("prune {}", entry.message);
750                }
751            }
752        } else {
753            if params.verbose {
754                println!("keep {}", entry.message);
755            }
756            kept_entries.push(entry.clone());
757            kept.push(format_reflog_entry(entry));
758        }
759    }
760
761    if !params.dry_run && pruned > 0 {
762        if is_reftable {
763            crate::reftable::reftable_replace_reflog(git_dir, refname, &kept_entries)?;
764        } else if kept.is_empty() {
765            let path = reflog_path(git_dir, refname);
766            let _ = fs::remove_file(&path);
767        } else {
768            let path = reflog_path(git_dir, refname);
769            fs::write(&path, kept.join(""))?;
770        }
771    }
772    Ok(pruned)
773}
774
775/// Per-ref `gc.<pattern>.reflogExpire*` rules plus global `gc.reflogExpire` / `gc.reflogExpireUnreachable`.
776#[derive(Debug, Clone)]
777pub struct GcReflogExpireConfig {
778    pub patterns: Vec<GcReflogPattern>,
779    pub global_total: Option<i64>,
780    pub global_unreachable: Option<i64>,
781}
782
783/// Load gc reflog expiry rules from merged config (same layering as Git `reflog_expire_config`).
784#[must_use]
785pub fn load_gc_reflog_expire_config(config: &ConfigSet, now: i64) -> GcReflogExpireConfig {
786    let (global_total, global_unreachable) = global_gc_reflog_expiry(config, now);
787    GcReflogExpireConfig {
788        patterns: collect_gc_reflog_patterns(config, now),
789        global_total,
790        global_unreachable,
791    }
792}
793
794/// Best-effort object set for `--stale-fix` (refs + reflog mentions).
795pub fn mark_stalefix_reachable(repo: &Repository, git_dir: &Path) -> Result<HashSet<ObjectId>> {
796    let mut seeds: Vec<ObjectId> = Vec::new();
797    if let Ok(oid) = refs::resolve_ref(git_dir, "HEAD") {
798        seeds.push(oid);
799    }
800    if let Ok(refs) = refs::list_refs(git_dir, "refs/") {
801        for (_, oid) in refs {
802            seeds.push(oid);
803        }
804    }
805    if let Ok(names) = list_reflog_refs(git_dir) {
806        for r in names {
807            if let Ok(ent) = read_reflog(git_dir, &r) {
808                for e in ent {
809                    if !e.old_oid.is_zero() {
810                        seeds.push(e.old_oid);
811                    }
812                    if !e.new_oid.is_zero() {
813                        seeds.push(e.new_oid);
814                    }
815                }
816            }
817        }
818    }
819    seeds.sort();
820    seeds.dedup();
821
822    let mut seen = HashSet::new();
823    let mut queue: std::collections::VecDeque<ObjectId> = seeds.into_iter().collect();
824    while let Some(oid) = queue.pop_front() {
825        if oid.is_zero() || !seen.insert(oid) {
826            continue;
827        }
828        let Ok(obj) = repo.odb.read(&oid) else {
829            continue;
830        };
831        match obj.kind {
832            ObjectKind::Commit => {
833                if let Ok(c) = parse_commit(&obj.data) {
834                    queue.push_back(c.tree);
835                    for p in c.parents {
836                        queue.push_back(p);
837                    }
838                }
839            }
840            ObjectKind::Tree => {
841                if let Ok(entries) = parse_tree(&obj.data) {
842                    for te in entries {
843                        queue.push_back(te.oid);
844                    }
845                }
846            }
847            ObjectKind::Tag | ObjectKind::Blob => {}
848        }
849    }
850    Ok(seen)
851}