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