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