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