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