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 path = reflog_path(git_dir, refname);
188    let remaining: Vec<&ReflogEntry> = entries
189        .iter()
190        .enumerate()
191        .filter(|(i, _)| !indices_set.contains(i))
192        .map(|(_, e)| e)
193        .collect();
194
195    // Write back in file order (oldest first), so reverse again
196    let mut lines = Vec::new();
197    for entry in remaining.iter().rev() {
198        lines.push(format_reflog_entry(entry));
199    }
200
201    fs::write(&path, lines.join(""))?;
202    Ok(())
203}
204
205/// Expire (prune) reflog entries older than a given timestamp (Unix seconds).
206///
207/// If `expire_time` is `None`, removes all entries.
208pub fn expire_reflog(git_dir: &Path, refname: &str, expire_time: Option<i64>) -> Result<usize> {
209    let entries = read_reflog(git_dir, refname)?;
210    if entries.is_empty() {
211        return Ok(0);
212    }
213
214    let path = reflog_path(git_dir, refname);
215    let mut kept = Vec::new();
216    let mut pruned = 0usize;
217
218    for entry in &entries {
219        let ts = parse_timestamp_from_identity(&entry.identity);
220        let dominated = match (expire_time, ts) {
221            (Some(cutoff), Some(t)) => t < cutoff,
222            (None, _) => true,        // expire all
223            (Some(_), None) => false, // can't parse => keep
224        };
225        if dominated {
226            pruned += 1;
227        } else {
228            kept.push(format_reflog_entry(entry));
229        }
230    }
231
232    fs::write(&path, kept.join(""))?;
233    Ok(pruned)
234}
235
236/// Expire reflog entries whose `new_oid` is not an ancestor of the current ref tip
237/// and whose identity timestamp is older than `cutoff` (Unix seconds).
238///
239/// Entries with an all-zero `new_oid` are never removed by this pass.
240///
241/// When `cutoff` is `None`, no entries are removed.
242///
243/// Reftable-backed repositories are skipped until reflog rewrite is implemented there.
244pub fn expire_reflog_unreachable(
245    repo: &Repository,
246    git_dir: &Path,
247    refname: &str,
248    cutoff: Option<i64>,
249) -> Result<usize> {
250    let Some(cutoff) = cutoff else {
251        return Ok(0);
252    };
253    if crate::reftable::is_reftable_repo(git_dir) {
254        return Ok(0);
255    }
256    let tip = match refs::resolve_ref(git_dir, refname) {
257        Ok(o) => o,
258        Err(_) => return Ok(0),
259    };
260    let ancestors = match merge_base::ancestor_closure(repo, tip) {
261        Ok(a) => a,
262        Err(_) => return Ok(0),
263    };
264
265    let entries = read_reflog(git_dir, refname)?;
266    if entries.is_empty() {
267        return Ok(0);
268    }
269
270    let path = reflog_path(git_dir, refname);
271    let mut kept = Vec::new();
272    let mut pruned = 0usize;
273
274    for entry in &entries {
275        let ts = parse_timestamp_from_identity(&entry.identity);
276        let unreachable = !entry.new_oid.is_zero() && !ancestors.contains(&entry.new_oid);
277        let should_prune = unreachable && matches!(ts, Some(t) if t < cutoff);
278        if should_prune {
279            pruned += 1;
280        } else {
281            kept.push(format_reflog_entry(entry));
282        }
283    }
284
285    fs::write(&path, kept.join(""))?;
286    Ok(pruned)
287}
288
289/// Format a reflog entry back into the on-disk line format.
290fn format_reflog_entry(entry: &ReflogEntry) -> String {
291    if entry.message.is_empty() {
292        format!("{} {} {}\n", entry.old_oid, entry.new_oid, entry.identity)
293    } else {
294        format!(
295            "{} {} {}\t{}\n",
296            entry.old_oid, entry.new_oid, entry.identity, entry.message
297        )
298    }
299}
300
301/// Extract the Unix timestamp from an identity string.
302///
303/// Identity format: `Name <email> <timestamp> <tz>`
304fn parse_timestamp_from_identity(identity: &str) -> Option<i64> {
305    // Walk backwards: last token is tz (+0000), second-to-last is timestamp
306    let parts: Vec<&str> = identity.rsplitn(3, ' ').collect();
307    if parts.len() >= 2 {
308        parts[1].parse::<i64>().ok()
309    } else {
310        None
311    }
312}
313
314/// Copy `logs/<branch_refname>` to `logs/HEAD` when keeping symbolic-HEAD reflogs aligned with
315/// the checked-out branch (matches Git).
316pub fn mirror_branch_reflog_to_head(git_dir: &Path, branch_refname: &str) -> Result<()> {
317    if crate::reftable::is_reftable_repo(git_dir) {
318        return Ok(());
319    }
320    let src = reflog_path(git_dir, branch_refname);
321    if !src.is_file() {
322        return Ok(());
323    }
324    let content = fs::read_to_string(&src).map_err(Error::Io)?;
325    let dst = reflog_path(git_dir, "HEAD");
326    if let Some(parent) = dst.parent() {
327        fs::create_dir_all(parent).map_err(Error::Io)?;
328    }
329    fs::write(&dst, content).map_err(Error::Io)?;
330    Ok(())
331}
332
333/// List all refs that have reflogs.
334pub fn list_reflog_refs(git_dir: &Path) -> Result<Vec<String>> {
335    let mut refs = Vec::new();
336    let mut seen = HashSet::new();
337
338    fn collect_from_logs_root(
339        logs_dir: &Path,
340        out: &mut Vec<String>,
341        seen: &mut HashSet<String>,
342    ) -> Result<()> {
343        if logs_dir.join("HEAD").is_file() && seen.insert("HEAD".to_string()) {
344            out.push("HEAD".to_string());
345        }
346        let refs_logs = logs_dir.join("refs");
347        if refs_logs.is_dir() {
348            collect_reflog_refs(&refs_logs, "refs", out, seen)?;
349        }
350        Ok(())
351    }
352
353    collect_from_logs_root(&git_dir.join("logs"), &mut refs, &mut seen)?;
354    if let Some(common) = refs::common_dir(git_dir) {
355        if common != git_dir {
356            collect_from_logs_root(&common.join("logs"), &mut refs, &mut seen)?;
357        }
358    }
359
360    Ok(refs)
361}
362
363fn collect_reflog_refs(
364    dir: &Path,
365    prefix: &str,
366    out: &mut Vec<String>,
367    seen: &mut HashSet<String>,
368) -> Result<()> {
369    let read_dir = match fs::read_dir(dir) {
370        Ok(rd) => rd,
371        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
372        Err(e) => return Err(Error::Io(e)),
373    };
374
375    for entry in read_dir {
376        let entry = entry.map_err(Error::Io)?;
377        let name = entry.file_name().to_string_lossy().to_string();
378        let full_name = format!("{prefix}/{name}");
379        let ft = entry.file_type().map_err(Error::Io)?;
380        if ft.is_dir() {
381            collect_reflog_refs(&entry.path(), &full_name, out, seen)?;
382        } else if ft.is_file() && seen.insert(full_name.clone()) {
383            out.push(full_name);
384        }
385    }
386    Ok(())
387}
388
389// --- `git reflog expire` -----------------------------------------------------
390
391/// Options for [`expire_reflog_git`].
392#[derive(Debug, Clone)]
393pub struct ReflogExpireParams {
394    /// Prune entries whose commits fail a completeness walk (missing objects).
395    pub stale_fix: bool,
396    pub dry_run: bool,
397    pub verbose: bool,
398}
399
400/// Per-ref `gc.<pattern>.reflogExpire*` rule from config.
401#[derive(Debug, Clone)]
402pub struct GcReflogPattern {
403    pattern: String,
404    expire_total: i64,
405    expire_unreachable: i64,
406}
407
408fn collect_gc_reflog_patterns(config: &ConfigSet, now: i64) -> Vec<GcReflogPattern> {
409    let mut by_pattern: HashMap<String, GcReflogPattern> = HashMap::new();
410    for e in config.entries() {
411        let key = e.key.as_str();
412        let Some(rest) = key.strip_prefix("gc.") else {
413            continue;
414        };
415        // Per-ref: `gc.<wildmatch-pattern>.reflogExpire` (pattern may contain dots).
416        // Global `gc.reflogExpire` has no pattern segment — see [`global_gc_reflog_expiry`].
417        let Some((pat, suffix)) = rest.rsplit_once('.') else {
418            continue;
419        };
420        if !suffix.eq_ignore_ascii_case("reflogexpire")
421            && !suffix.eq_ignore_ascii_case("reflogexpireunreachable")
422        {
423            continue;
424        }
425        let Some(val) = e.value.as_deref() else {
426            continue;
427        };
428        let Ok(ts) = parse_gc_reflog_expiry(val, now) else {
429            continue;
430        };
431        let ent = by_pattern
432            .entry(pat.to_string())
433            .or_insert(GcReflogPattern {
434                pattern: pat.to_string(),
435                expire_total: i64::MAX,
436                expire_unreachable: i64::MAX,
437            });
438        if suffix.eq_ignore_ascii_case("reflogexpire") {
439            ent.expire_total = ts;
440        } else {
441            ent.expire_unreachable = ts;
442        }
443    }
444    by_pattern.into_values().collect()
445}
446
447fn global_gc_reflog_expiry(config: &ConfigSet, now: i64) -> (Option<i64>, Option<i64>) {
448    let total = config
449        .get("gc.reflogExpire")
450        .and_then(|v| parse_gc_reflog_expiry(&v, now).ok());
451    let unreach = config
452        .get("gc.reflogExpireUnreachable")
453        .and_then(|v| parse_gc_reflog_expiry(&v, now).ok());
454    (total, unreach)
455}
456
457/// Parse `gc.reflogExpire` values: `never` / `false` → keep forever (`0`), else days or epoch.
458fn parse_gc_reflog_expiry(raw: &str, now: i64) -> Result<i64> {
459    let s = raw.trim();
460    if s.eq_ignore_ascii_case("never") || s.eq_ignore_ascii_case("false") {
461        return Ok(0);
462    }
463    if let Ok(days) = s.parse::<u64>() {
464        if days == 0 {
465            return Ok(0);
466        }
467        return Ok(now - (days as i64 * 86400));
468    }
469    s.parse::<i64>()
470        .map_err(|_| Error::Message(format!("invalid reflog expiry: {raw:?}")))
471}
472
473fn default_expire_total(now: i64) -> i64 {
474    now - 30 * 86400
475}
476
477fn default_expire_unreachable(now: i64) -> i64 {
478    now - 90 * 86400
479}
480
481fn resolve_expire_for_ref(
482    refname: &str,
483    explicit_total: Option<i64>,
484    explicit_unreachable: Option<i64>,
485    patterns: &[GcReflogPattern],
486    default_total: i64,
487    default_unreachable: i64,
488) -> (i64, i64) {
489    let mut expire_total = explicit_total.unwrap_or(default_total);
490    let mut expire_unreachable = explicit_unreachable.unwrap_or(default_unreachable);
491    if explicit_total.is_some() && explicit_unreachable.is_some() {
492        return (expire_total, expire_unreachable);
493    }
494    for ent in patterns {
495        if wildmatch(ent.pattern.as_bytes(), refname.as_bytes(), WM_PATHNAME) {
496            // Partial per-pattern config only sets one key; the other stays `i64::MAX` as sentinel.
497            if explicit_total.is_none() && ent.expire_total != i64::MAX {
498                expire_total = ent.expire_total;
499            }
500            if explicit_unreachable.is_none() && ent.expire_unreachable != i64::MAX {
501                expire_unreachable = ent.expire_unreachable;
502            }
503            return (expire_total, expire_unreachable);
504        }
505    }
506    if refname == "refs/stash" {
507        if explicit_total.is_none() {
508            expire_total = 0;
509        }
510        if explicit_unreachable.is_none() {
511            expire_unreachable = 0;
512        }
513    }
514    (expire_total, expire_unreachable)
515}
516
517fn tree_fully_complete(repo: &Repository, oid: ObjectId, depth: usize) -> bool {
518    if depth > 65536 {
519        return false;
520    }
521    let Ok(obj) = repo.odb.read(&oid) else {
522        return false;
523    };
524    match obj.kind {
525        ObjectKind::Blob => true,
526        ObjectKind::Tree => {
527            let Ok(entries) = parse_tree(&obj.data) else {
528                return false;
529            };
530            for e in entries {
531                if !tree_fully_complete(repo, e.oid, depth + 1) {
532                    return false;
533                }
534            }
535            true
536        }
537        _ => false,
538    }
539}
540
541fn commit_chain_complete(repo: &Repository, oid: ObjectId, depth: usize) -> bool {
542    if oid.is_zero() {
543        return true;
544    }
545    if depth > 65536 {
546        return false;
547    }
548    let Ok(obj) = repo.odb.read(&oid) else {
549        return false;
550    };
551    if obj.kind != ObjectKind::Commit {
552        return false;
553    }
554    let Ok(c) = parse_commit(&obj.data) else {
555        return false;
556    };
557    if !tree_fully_complete(repo, c.tree, depth + 1) {
558        return false;
559    }
560    for p in &c.parents {
561        if !commit_chain_complete(repo, *p, depth + 1) {
562            return false;
563        }
564    }
565    true
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569enum UnreachableKind {
570    Always,
571    Normal,
572    Head,
573}
574
575fn is_head_ref(refname: &str) -> bool {
576    refname == "HEAD" || refname.ends_with("/HEAD")
577}
578
579fn tip_commits_for_reflog(repo: &Repository, git_dir: &Path, refname: &str) -> Vec<ObjectId> {
580    let mut tips = Vec::new();
581    if is_head_ref(refname) {
582        if let Ok(oid) = refs::resolve_ref(git_dir, "HEAD") {
583            tips.push(oid);
584        }
585        if let Ok(refs) = refs::list_refs(git_dir, "refs/") {
586            for (_, oid) in refs {
587                tips.push(oid);
588            }
589        }
590    } else if let Ok(oid) = refs::resolve_ref(git_dir, refname) {
591        tips.push(oid);
592    }
593    tips.sort();
594    tips.dedup();
595    tips.retain(|o| commit_chain_complete(repo, *o, 0));
596    tips
597}
598
599fn reachable_commit_set(repo: &Repository, tips: &[ObjectId]) -> HashSet<ObjectId> {
600    let mut acc = HashSet::new();
601    for t in tips {
602        if let Ok(cl) = merge_base::ancestor_closure(repo, *t) {
603            acc.extend(cl);
604        }
605    }
606    acc
607}
608
609fn is_unreachable_oid(
610    repo: &Repository,
611    reachable: &HashSet<ObjectId>,
612    kind: UnreachableKind,
613    oid: ObjectId,
614) -> bool {
615    if oid.is_zero() {
616        return false;
617    }
618    if reachable.contains(&oid) {
619        return false;
620    }
621    if kind == UnreachableKind::Always {
622        return true;
623    }
624    let Ok(obj) = repo.odb.read(&oid) else {
625        return true;
626    };
627    obj.kind == ObjectKind::Commit
628}
629
630fn should_drop_reflog_entry(
631    repo: &Repository,
632    entry: &ReflogEntry,
633    expire_total: i64,
634    expire_unreachable: i64,
635    unreachable_kind: UnreachableKind,
636    reachable: &HashSet<ObjectId>,
637    stale_fix: bool,
638) -> bool {
639    let ts = parse_timestamp_from_identity(&entry.identity).unwrap_or(i64::MAX);
640    if expire_total > 0 && ts < expire_total {
641        return true;
642    }
643    if stale_fix
644        && (!commit_chain_complete(repo, entry.old_oid, 0)
645            || !commit_chain_complete(repo, entry.new_oid, 0))
646    {
647        return true;
648    }
649    if expire_unreachable > 0 && ts < expire_unreachable {
650        match unreachable_kind {
651            UnreachableKind::Always => return true,
652            UnreachableKind::Normal | UnreachableKind::Head => {
653                if is_unreachable_oid(repo, reachable, unreachable_kind, entry.old_oid)
654                    || is_unreachable_oid(repo, reachable, unreachable_kind, entry.new_oid)
655                {
656                    return true;
657                }
658            }
659        }
660    }
661    false
662}
663
664/// Git-compatible reflog expiry for one ref.
665pub fn expire_reflog_git(
666    repo: &Repository,
667    git_dir: &Path,
668    refname: &str,
669    params: &ReflogExpireParams,
670    explicit_total: Option<i64>,
671    explicit_unreachable: Option<i64>,
672    gc_patterns: &[GcReflogPattern],
673    gc_global_total: Option<i64>,
674    gc_global_unreachable: Option<i64>,
675    now: i64,
676) -> Result<usize> {
677    if crate::reftable::is_reftable_repo(git_dir) {
678        return Ok(0);
679    }
680    let base_total = gc_global_total.unwrap_or_else(|| default_expire_total(now));
681    let base_unreachable = gc_global_unreachable.unwrap_or_else(|| default_expire_unreachable(now));
682    let (expire_total, expire_unreachable) = resolve_expire_for_ref(
683        refname,
684        explicit_total,
685        explicit_unreachable,
686        gc_patterns,
687        base_total,
688        base_unreachable,
689    );
690
691    let unreachable_kind = if expire_unreachable <= expire_total {
692        UnreachableKind::Always
693    } else if expire_unreachable == 0 || is_head_ref(refname) {
694        UnreachableKind::Head
695    } else {
696        match refs::resolve_ref(git_dir, refname) {
697            Ok(t) if commit_chain_complete(repo, t, 0) => UnreachableKind::Normal,
698            _ => UnreachableKind::Always,
699        }
700    };
701
702    let tips = tip_commits_for_reflog(repo, git_dir, refname);
703    let reachable = if matches!(unreachable_kind, UnreachableKind::Always) {
704        HashSet::new()
705    } else {
706        reachable_commit_set(repo, &tips)
707    };
708
709    let entries = read_reflog(git_dir, refname)?;
710    if entries.is_empty() {
711        return Ok(0);
712    }
713    let path = reflog_path(git_dir, refname);
714    let mut kept = Vec::new();
715    let mut pruned = 0usize;
716
717    for entry in &entries {
718        let drop = should_drop_reflog_entry(
719            repo,
720            entry,
721            expire_total,
722            expire_unreachable,
723            unreachable_kind,
724            &reachable,
725            params.stale_fix,
726        );
727        if drop {
728            pruned += 1;
729            if params.verbose {
730                if params.dry_run {
731                    println!("would prune {}", entry.message);
732                } else {
733                    println!("prune {}", entry.message);
734                }
735            }
736        } else {
737            if params.verbose {
738                println!("keep {}", entry.message);
739            }
740            kept.push(format_reflog_entry(entry));
741        }
742    }
743
744    if !params.dry_run && pruned > 0 {
745        if kept.is_empty() {
746            let _ = fs::remove_file(&path);
747        } else {
748            fs::write(&path, kept.join(""))?;
749        }
750    }
751    Ok(pruned)
752}
753
754/// Per-ref `gc.<pattern>.reflogExpire*` rules plus global `gc.reflogExpire` / `gc.reflogExpireUnreachable`.
755#[derive(Debug, Clone)]
756pub struct GcReflogExpireConfig {
757    pub patterns: Vec<GcReflogPattern>,
758    pub global_total: Option<i64>,
759    pub global_unreachable: Option<i64>,
760}
761
762/// Load gc reflog expiry rules from merged config (same layering as Git `reflog_expire_config`).
763#[must_use]
764pub fn load_gc_reflog_expire_config(config: &ConfigSet, now: i64) -> GcReflogExpireConfig {
765    let (global_total, global_unreachable) = global_gc_reflog_expiry(config, now);
766    GcReflogExpireConfig {
767        patterns: collect_gc_reflog_patterns(config, now),
768        global_total,
769        global_unreachable,
770    }
771}
772
773/// Best-effort object set for `--stale-fix` (refs + reflog mentions).
774pub fn mark_stalefix_reachable(repo: &Repository, git_dir: &Path) -> Result<HashSet<ObjectId>> {
775    let mut seeds: Vec<ObjectId> = Vec::new();
776    if let Ok(oid) = refs::resolve_ref(git_dir, "HEAD") {
777        seeds.push(oid);
778    }
779    if let Ok(refs) = refs::list_refs(git_dir, "refs/") {
780        for (_, oid) in refs {
781            seeds.push(oid);
782        }
783    }
784    if let Ok(names) = list_reflog_refs(git_dir) {
785        for r in names {
786            if let Ok(ent) = read_reflog(git_dir, &r) {
787                for e in ent {
788                    if !e.old_oid.is_zero() {
789                        seeds.push(e.old_oid);
790                    }
791                    if !e.new_oid.is_zero() {
792                        seeds.push(e.new_oid);
793                    }
794                }
795            }
796        }
797    }
798    seeds.sort();
799    seeds.dedup();
800
801    let mut seen = HashSet::new();
802    let mut queue: std::collections::VecDeque<ObjectId> = seeds.into_iter().collect();
803    while let Some(oid) = queue.pop_front() {
804        if oid.is_zero() || !seen.insert(oid) {
805            continue;
806        }
807        let Ok(obj) = repo.odb.read(&oid) else {
808            continue;
809        };
810        match obj.kind {
811            ObjectKind::Commit => {
812                if let Ok(c) = parse_commit(&obj.data) {
813                    queue.push_back(c.tree);
814                    for p in c.parents {
815                        queue.push_back(p);
816                    }
817                }
818            }
819            ObjectKind::Tree => {
820                if let Ok(entries) = parse_tree(&obj.data) {
821                    for te in entries {
822                        queue.push_back(te.oid);
823                    }
824                }
825            }
826            ObjectKind::Tag | ObjectKind::Blob => {}
827        }
828    }
829    Ok(seen)
830}