1use 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#[derive(Debug, Clone)]
27pub struct ReflogEntry {
28 pub old_oid: ObjectId,
30 pub new_oid: ObjectId,
32 pub identity: String,
34 pub message: String,
36}
37
38pub fn reflog_path(git_dir: &Path, refname: &str) -> PathBuf {
40 git_dir.join("logs").join(refname)
41}
42
43pub 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
52pub 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
78fn parse_reflog_line(line: &str) -> Option<ReflogEntry> {
82 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 if before_tab.len() < 83 {
91 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
110pub 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
150pub 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 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 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
183pub 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, (Some(_), None) => false, };
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
214pub 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
267fn 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
275fn parse_timestamp_from_identity(identity: &str) -> Option<i64> {
279 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
288pub 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
307pub 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 if logs_dir.join("HEAD").is_file() {
314 refs.push("HEAD".to_string());
315 }
316
317 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#[derive(Debug, Clone)]
351pub struct ReflogExpireParams {
352 pub stale_fix: bool,
354 pub dry_run: bool,
355 pub verbose: bool,
356}
357
358#[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 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
415fn 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 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
622pub 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#[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#[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
727pub 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}