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::{self, reflog_file_path};
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 {
43 reflog_file_path(git_dir, refname)
44}
45
46pub fn reflog_exists(git_dir: &Path, refname: &str) -> bool {
48 if crate::reftable::is_reftable_repo(git_dir) {
49 return crate::reftable::reftable_reflog_exists(git_dir, refname);
50 }
51 let path = reflog_path(git_dir, refname);
52 path.is_file()
53}
54
55pub fn read_reflog_dwim(git_dir: &Path, refname: &str) -> Result<Vec<ReflogEntry>> {
60 let mut entries = read_reflog(git_dir, refname)?;
61 if !entries.is_empty() {
62 return Ok(entries);
63 }
64 if !refname.starts_with("refs/") {
65 entries = read_reflog(git_dir, &format!("refs/{refname}"))?;
66 if !entries.is_empty() {
67 return Ok(entries);
68 }
69 entries = read_reflog(git_dir, &format!("refs/heads/{refname}"))?;
70 }
71 Ok(entries)
72}
73
74pub fn read_reflog(git_dir: &Path, refname: &str) -> Result<Vec<ReflogEntry>> {
78 if crate::reftable::is_reftable_repo(git_dir) {
79 return crate::reftable::reftable_read_reflog(git_dir, refname);
80 }
81 let path = reflog_path(git_dir, refname);
82 let content = match fs::read_to_string(&path) {
83 Ok(c) => c,
84 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
85 Err(e) => return Err(Error::Io(e)),
86 };
87
88 let mut entries = Vec::new();
89 for line in content.lines() {
90 if line.is_empty() {
91 continue;
92 }
93 if let Some(entry) = parse_reflog_line(line) {
94 entries.push(entry);
95 }
96 }
97 Ok(entries)
98}
99
100fn parse_reflog_line(line: &str) -> Option<ReflogEntry> {
104 let (before_tab, message) = if let Some(pos) = line.find('\t') {
106 (&line[..pos], line[pos + 1..].to_string())
107 } else {
108 (line, String::new())
109 };
110
111 if before_tab.len() < 83 {
113 return None;
115 }
116
117 let old_hex = &before_tab[..40];
118 let new_hex = &before_tab[41..81];
119 let identity = before_tab[82..].to_string();
120
121 let old_oid = old_hex.parse::<ObjectId>().ok()?;
122 let new_oid = new_hex.parse::<ObjectId>().ok()?;
123
124 Some(ReflogEntry {
125 old_oid,
126 new_oid,
127 identity,
128 message,
129 })
130}
131
132pub fn all_reflog_oids(git_dir: &Path) -> Result<HashSet<ObjectId>> {
136 if crate::reftable::is_reftable_repo(git_dir) {
137 return Ok(HashSet::new());
138 }
139 let mut out = HashSet::new();
140 let logs = git_dir.join("logs");
141 if !logs.is_dir() {
142 return Ok(out);
143 }
144 let z = zero_oid();
145 walk_reflog_files(&logs, &mut out, &z)?;
146 Ok(out)
147}
148
149fn walk_reflog_files(dir: &Path, out: &mut HashSet<ObjectId>, zero: &ObjectId) -> Result<()> {
150 for entry in fs::read_dir(dir).map_err(Error::Io)? {
151 let entry = entry.map_err(Error::Io)?;
152 let path = entry.path();
153 if path.is_dir() {
154 walk_reflog_files(&path, out, zero)?;
155 } else if path.is_file() {
156 let content = fs::read_to_string(&path).map_err(Error::Io)?;
157 for line in content.lines() {
158 if let Some(e) = parse_reflog_line(line) {
159 if e.old_oid != *zero {
160 out.insert(e.old_oid);
161 }
162 if e.new_oid != *zero {
163 out.insert(e.new_oid);
164 }
165 }
166 }
167 }
168 }
169 Ok(())
170}
171
172pub fn delete_reflog_entries(git_dir: &Path, refname: &str, indices: &[usize]) -> Result<()> {
176 let mut entries = read_reflog(git_dir, refname)?;
177 if entries.is_empty() {
178 return Ok(());
179 }
180
181 entries.reverse();
184
185 let indices_set: std::collections::HashSet<usize> = indices.iter().copied().collect();
186
187 let path = reflog_path(git_dir, refname);
188 let remaining: Vec<&ReflogEntry> = entries
189 .iter()
190 .enumerate()
191 .filter(|(i, _)| !indices_set.contains(i))
192 .map(|(_, e)| e)
193 .collect();
194
195 let mut lines = Vec::new();
197 for entry in remaining.iter().rev() {
198 lines.push(format_reflog_entry(entry));
199 }
200
201 fs::write(&path, lines.join(""))?;
202 Ok(())
203}
204
205pub fn expire_reflog(git_dir: &Path, refname: &str, expire_time: Option<i64>) -> Result<usize> {
209 let entries = read_reflog(git_dir, refname)?;
210 if entries.is_empty() {
211 return Ok(0);
212 }
213
214 let path = reflog_path(git_dir, refname);
215 let mut kept = Vec::new();
216 let mut pruned = 0usize;
217
218 for entry in &entries {
219 let ts = parse_timestamp_from_identity(&entry.identity);
220 let dominated = match (expire_time, ts) {
221 (Some(cutoff), Some(t)) => t < cutoff,
222 (None, _) => true, (Some(_), None) => false, };
225 if dominated {
226 pruned += 1;
227 } else {
228 kept.push(format_reflog_entry(entry));
229 }
230 }
231
232 fs::write(&path, kept.join(""))?;
233 Ok(pruned)
234}
235
236pub fn expire_reflog_unreachable(
245 repo: &Repository,
246 git_dir: &Path,
247 refname: &str,
248 cutoff: Option<i64>,
249) -> Result<usize> {
250 let Some(cutoff) = cutoff else {
251 return Ok(0);
252 };
253 if crate::reftable::is_reftable_repo(git_dir) {
254 return Ok(0);
255 }
256 let tip = match refs::resolve_ref(git_dir, refname) {
257 Ok(o) => o,
258 Err(_) => return Ok(0),
259 };
260 let ancestors = match merge_base::ancestor_closure(repo, tip) {
261 Ok(a) => a,
262 Err(_) => return Ok(0),
263 };
264
265 let entries = read_reflog(git_dir, refname)?;
266 if entries.is_empty() {
267 return Ok(0);
268 }
269
270 let path = reflog_path(git_dir, refname);
271 let mut kept = Vec::new();
272 let mut pruned = 0usize;
273
274 for entry in &entries {
275 let ts = parse_timestamp_from_identity(&entry.identity);
276 let unreachable = !entry.new_oid.is_zero() && !ancestors.contains(&entry.new_oid);
277 let should_prune = unreachable && matches!(ts, Some(t) if t < cutoff);
278 if should_prune {
279 pruned += 1;
280 } else {
281 kept.push(format_reflog_entry(entry));
282 }
283 }
284
285 fs::write(&path, kept.join(""))?;
286 Ok(pruned)
287}
288
289fn format_reflog_entry(entry: &ReflogEntry) -> String {
291 if entry.message.is_empty() {
292 format!("{} {} {}\n", entry.old_oid, entry.new_oid, entry.identity)
293 } else {
294 format!(
295 "{} {} {}\t{}\n",
296 entry.old_oid, entry.new_oid, entry.identity, entry.message
297 )
298 }
299}
300
301fn parse_timestamp_from_identity(identity: &str) -> Option<i64> {
305 let parts: Vec<&str> = identity.rsplitn(3, ' ').collect();
307 if parts.len() >= 2 {
308 parts[1].parse::<i64>().ok()
309 } else {
310 None
311 }
312}
313
314pub fn mirror_branch_reflog_to_head(git_dir: &Path, branch_refname: &str) -> Result<()> {
317 if crate::reftable::is_reftable_repo(git_dir) {
318 return Ok(());
319 }
320 let src = reflog_path(git_dir, branch_refname);
321 if !src.is_file() {
322 return Ok(());
323 }
324 let content = fs::read_to_string(&src).map_err(Error::Io)?;
325 let dst = reflog_path(git_dir, "HEAD");
326 if let Some(parent) = dst.parent() {
327 fs::create_dir_all(parent).map_err(Error::Io)?;
328 }
329 fs::write(&dst, content).map_err(Error::Io)?;
330 Ok(())
331}
332
333pub fn list_reflog_refs(git_dir: &Path) -> Result<Vec<String>> {
335 let mut refs = Vec::new();
336 let mut seen = HashSet::new();
337
338 fn collect_from_logs_root(
339 logs_dir: &Path,
340 out: &mut Vec<String>,
341 seen: &mut HashSet<String>,
342 ) -> Result<()> {
343 if logs_dir.join("HEAD").is_file() && seen.insert("HEAD".to_string()) {
344 out.push("HEAD".to_string());
345 }
346 let refs_logs = logs_dir.join("refs");
347 if refs_logs.is_dir() {
348 collect_reflog_refs(&refs_logs, "refs", out, seen)?;
349 }
350 Ok(())
351 }
352
353 collect_from_logs_root(&git_dir.join("logs"), &mut refs, &mut seen)?;
354 if let Some(common) = refs::common_dir(git_dir) {
355 if common != git_dir {
356 collect_from_logs_root(&common.join("logs"), &mut refs, &mut seen)?;
357 }
358 }
359
360 Ok(refs)
361}
362
363fn collect_reflog_refs(
364 dir: &Path,
365 prefix: &str,
366 out: &mut Vec<String>,
367 seen: &mut HashSet<String>,
368) -> Result<()> {
369 let read_dir = match fs::read_dir(dir) {
370 Ok(rd) => rd,
371 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
372 Err(e) => return Err(Error::Io(e)),
373 };
374
375 for entry in read_dir {
376 let entry = entry.map_err(Error::Io)?;
377 let name = entry.file_name().to_string_lossy().to_string();
378 let full_name = format!("{prefix}/{name}");
379 let ft = entry.file_type().map_err(Error::Io)?;
380 if ft.is_dir() {
381 collect_reflog_refs(&entry.path(), &full_name, out, seen)?;
382 } else if ft.is_file() && seen.insert(full_name.clone()) {
383 out.push(full_name);
384 }
385 }
386 Ok(())
387}
388
389#[derive(Debug, Clone)]
393pub struct ReflogExpireParams {
394 pub stale_fix: bool,
396 pub dry_run: bool,
397 pub verbose: bool,
398}
399
400#[derive(Debug, Clone)]
402pub struct GcReflogPattern {
403 pattern: String,
404 expire_total: i64,
405 expire_unreachable: i64,
406}
407
408fn collect_gc_reflog_patterns(config: &ConfigSet, now: i64) -> Vec<GcReflogPattern> {
409 let mut by_pattern: HashMap<String, GcReflogPattern> = HashMap::new();
410 for e in config.entries() {
411 let key = e.key.as_str();
412 let Some(rest) = key.strip_prefix("gc.") else {
413 continue;
414 };
415 let Some((pat, suffix)) = rest.rsplit_once('.') else {
418 continue;
419 };
420 if !suffix.eq_ignore_ascii_case("reflogexpire")
421 && !suffix.eq_ignore_ascii_case("reflogexpireunreachable")
422 {
423 continue;
424 }
425 let Some(val) = e.value.as_deref() else {
426 continue;
427 };
428 let Ok(ts) = parse_gc_reflog_expiry(val, now) else {
429 continue;
430 };
431 let ent = by_pattern
432 .entry(pat.to_string())
433 .or_insert(GcReflogPattern {
434 pattern: pat.to_string(),
435 expire_total: i64::MAX,
436 expire_unreachable: i64::MAX,
437 });
438 if suffix.eq_ignore_ascii_case("reflogexpire") {
439 ent.expire_total = ts;
440 } else {
441 ent.expire_unreachable = ts;
442 }
443 }
444 by_pattern.into_values().collect()
445}
446
447fn global_gc_reflog_expiry(config: &ConfigSet, now: i64) -> (Option<i64>, Option<i64>) {
448 let total = config
449 .get("gc.reflogExpire")
450 .and_then(|v| parse_gc_reflog_expiry(&v, now).ok());
451 let unreach = config
452 .get("gc.reflogExpireUnreachable")
453 .and_then(|v| parse_gc_reflog_expiry(&v, now).ok());
454 (total, unreach)
455}
456
457fn parse_gc_reflog_expiry(raw: &str, now: i64) -> Result<i64> {
459 let s = raw.trim();
460 if s.eq_ignore_ascii_case("never") || s.eq_ignore_ascii_case("false") {
461 return Ok(0);
462 }
463 if let Ok(days) = s.parse::<u64>() {
464 if days == 0 {
465 return Ok(0);
466 }
467 return Ok(now - (days as i64 * 86400));
468 }
469 s.parse::<i64>()
470 .map_err(|_| Error::Message(format!("invalid reflog expiry: {raw:?}")))
471}
472
473fn default_expire_total(now: i64) -> i64 {
474 now - 30 * 86400
475}
476
477fn default_expire_unreachable(now: i64) -> i64 {
478 now - 90 * 86400
479}
480
481fn resolve_expire_for_ref(
482 refname: &str,
483 explicit_total: Option<i64>,
484 explicit_unreachable: Option<i64>,
485 patterns: &[GcReflogPattern],
486 default_total: i64,
487 default_unreachable: i64,
488) -> (i64, i64) {
489 let mut expire_total = explicit_total.unwrap_or(default_total);
490 let mut expire_unreachable = explicit_unreachable.unwrap_or(default_unreachable);
491 if explicit_total.is_some() && explicit_unreachable.is_some() {
492 return (expire_total, expire_unreachable);
493 }
494 for ent in patterns {
495 if wildmatch(ent.pattern.as_bytes(), refname.as_bytes(), WM_PATHNAME) {
496 if explicit_total.is_none() && ent.expire_total != i64::MAX {
498 expire_total = ent.expire_total;
499 }
500 if explicit_unreachable.is_none() && ent.expire_unreachable != i64::MAX {
501 expire_unreachable = ent.expire_unreachable;
502 }
503 return (expire_total, expire_unreachable);
504 }
505 }
506 if refname == "refs/stash" {
507 if explicit_total.is_none() {
508 expire_total = 0;
509 }
510 if explicit_unreachable.is_none() {
511 expire_unreachable = 0;
512 }
513 }
514 (expire_total, expire_unreachable)
515}
516
517fn tree_fully_complete(repo: &Repository, oid: ObjectId, depth: usize) -> bool {
518 if depth > 65536 {
519 return false;
520 }
521 let Ok(obj) = repo.odb.read(&oid) else {
522 return false;
523 };
524 match obj.kind {
525 ObjectKind::Blob => true,
526 ObjectKind::Tree => {
527 let Ok(entries) = parse_tree(&obj.data) else {
528 return false;
529 };
530 for e in entries {
531 if !tree_fully_complete(repo, e.oid, depth + 1) {
532 return false;
533 }
534 }
535 true
536 }
537 _ => false,
538 }
539}
540
541fn commit_chain_complete(repo: &Repository, oid: ObjectId, depth: usize) -> bool {
542 if oid.is_zero() {
543 return true;
544 }
545 if depth > 65536 {
546 return false;
547 }
548 let Ok(obj) = repo.odb.read(&oid) else {
549 return false;
550 };
551 if obj.kind != ObjectKind::Commit {
552 return false;
553 }
554 let Ok(c) = parse_commit(&obj.data) else {
555 return false;
556 };
557 if !tree_fully_complete(repo, c.tree, depth + 1) {
558 return false;
559 }
560 for p in &c.parents {
561 if !commit_chain_complete(repo, *p, depth + 1) {
562 return false;
563 }
564 }
565 true
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
569enum UnreachableKind {
570 Always,
571 Normal,
572 Head,
573}
574
575fn is_head_ref(refname: &str) -> bool {
576 refname == "HEAD" || refname.ends_with("/HEAD")
577}
578
579fn tip_commits_for_reflog(repo: &Repository, git_dir: &Path, refname: &str) -> Vec<ObjectId> {
580 let mut tips = Vec::new();
581 if is_head_ref(refname) {
582 if let Ok(oid) = refs::resolve_ref(git_dir, "HEAD") {
583 tips.push(oid);
584 }
585 if let Ok(refs) = refs::list_refs(git_dir, "refs/") {
586 for (_, oid) in refs {
587 tips.push(oid);
588 }
589 }
590 } else if let Ok(oid) = refs::resolve_ref(git_dir, refname) {
591 tips.push(oid);
592 }
593 tips.sort();
594 tips.dedup();
595 tips.retain(|o| commit_chain_complete(repo, *o, 0));
596 tips
597}
598
599fn reachable_commit_set(repo: &Repository, tips: &[ObjectId]) -> HashSet<ObjectId> {
600 let mut acc = HashSet::new();
601 for t in tips {
602 if let Ok(cl) = merge_base::ancestor_closure(repo, *t) {
603 acc.extend(cl);
604 }
605 }
606 acc
607}
608
609fn is_unreachable_oid(
610 repo: &Repository,
611 reachable: &HashSet<ObjectId>,
612 kind: UnreachableKind,
613 oid: ObjectId,
614) -> bool {
615 if oid.is_zero() {
616 return false;
617 }
618 if reachable.contains(&oid) {
619 return false;
620 }
621 if kind == UnreachableKind::Always {
622 return true;
623 }
624 let Ok(obj) = repo.odb.read(&oid) else {
625 return true;
626 };
627 obj.kind == ObjectKind::Commit
628}
629
630fn should_drop_reflog_entry(
631 repo: &Repository,
632 entry: &ReflogEntry,
633 expire_total: i64,
634 expire_unreachable: i64,
635 unreachable_kind: UnreachableKind,
636 reachable: &HashSet<ObjectId>,
637 stale_fix: bool,
638) -> bool {
639 let ts = parse_timestamp_from_identity(&entry.identity).unwrap_or(i64::MAX);
640 if expire_total > 0 && ts < expire_total {
641 return true;
642 }
643 if stale_fix
644 && (!commit_chain_complete(repo, entry.old_oid, 0)
645 || !commit_chain_complete(repo, entry.new_oid, 0))
646 {
647 return true;
648 }
649 if expire_unreachable > 0 && ts < expire_unreachable {
650 match unreachable_kind {
651 UnreachableKind::Always => return true,
652 UnreachableKind::Normal | UnreachableKind::Head => {
653 if is_unreachable_oid(repo, reachable, unreachable_kind, entry.old_oid)
654 || is_unreachable_oid(repo, reachable, unreachable_kind, entry.new_oid)
655 {
656 return true;
657 }
658 }
659 }
660 }
661 false
662}
663
664pub fn expire_reflog_git(
666 repo: &Repository,
667 git_dir: &Path,
668 refname: &str,
669 params: &ReflogExpireParams,
670 explicit_total: Option<i64>,
671 explicit_unreachable: Option<i64>,
672 gc_patterns: &[GcReflogPattern],
673 gc_global_total: Option<i64>,
674 gc_global_unreachable: Option<i64>,
675 now: i64,
676) -> Result<usize> {
677 if crate::reftable::is_reftable_repo(git_dir) {
678 return Ok(0);
679 }
680 let base_total = gc_global_total.unwrap_or_else(|| default_expire_total(now));
681 let base_unreachable = gc_global_unreachable.unwrap_or_else(|| default_expire_unreachable(now));
682 let (expire_total, expire_unreachable) = resolve_expire_for_ref(
683 refname,
684 explicit_total,
685 explicit_unreachable,
686 gc_patterns,
687 base_total,
688 base_unreachable,
689 );
690
691 let unreachable_kind = if expire_unreachable <= expire_total {
692 UnreachableKind::Always
693 } else if expire_unreachable == 0 || is_head_ref(refname) {
694 UnreachableKind::Head
695 } else {
696 match refs::resolve_ref(git_dir, refname) {
697 Ok(t) if commit_chain_complete(repo, t, 0) => UnreachableKind::Normal,
698 _ => UnreachableKind::Always,
699 }
700 };
701
702 let tips = tip_commits_for_reflog(repo, git_dir, refname);
703 let reachable = if matches!(unreachable_kind, UnreachableKind::Always) {
704 HashSet::new()
705 } else {
706 reachable_commit_set(repo, &tips)
707 };
708
709 let entries = read_reflog(git_dir, refname)?;
710 if entries.is_empty() {
711 return Ok(0);
712 }
713 let path = reflog_path(git_dir, refname);
714 let mut kept = Vec::new();
715 let mut pruned = 0usize;
716
717 for entry in &entries {
718 let drop = should_drop_reflog_entry(
719 repo,
720 entry,
721 expire_total,
722 expire_unreachable,
723 unreachable_kind,
724 &reachable,
725 params.stale_fix,
726 );
727 if drop {
728 pruned += 1;
729 if params.verbose {
730 if params.dry_run {
731 println!("would prune {}", entry.message);
732 } else {
733 println!("prune {}", entry.message);
734 }
735 }
736 } else {
737 if params.verbose {
738 println!("keep {}", entry.message);
739 }
740 kept.push(format_reflog_entry(entry));
741 }
742 }
743
744 if !params.dry_run && pruned > 0 {
745 if kept.is_empty() {
746 let _ = fs::remove_file(&path);
747 } else {
748 fs::write(&path, kept.join(""))?;
749 }
750 }
751 Ok(pruned)
752}
753
754#[derive(Debug, Clone)]
756pub struct GcReflogExpireConfig {
757 pub patterns: Vec<GcReflogPattern>,
758 pub global_total: Option<i64>,
759 pub global_unreachable: Option<i64>,
760}
761
762#[must_use]
764pub fn load_gc_reflog_expire_config(config: &ConfigSet, now: i64) -> GcReflogExpireConfig {
765 let (global_total, global_unreachable) = global_gc_reflog_expiry(config, now);
766 GcReflogExpireConfig {
767 patterns: collect_gc_reflog_patterns(config, now),
768 global_total,
769 global_unreachable,
770 }
771}
772
773pub fn mark_stalefix_reachable(repo: &Repository, git_dir: &Path) -> Result<HashSet<ObjectId>> {
775 let mut seeds: Vec<ObjectId> = Vec::new();
776 if let Ok(oid) = refs::resolve_ref(git_dir, "HEAD") {
777 seeds.push(oid);
778 }
779 if let Ok(refs) = refs::list_refs(git_dir, "refs/") {
780 for (_, oid) in refs {
781 seeds.push(oid);
782 }
783 }
784 if let Ok(names) = list_reflog_refs(git_dir) {
785 for r in names {
786 if let Ok(ent) = read_reflog(git_dir, &r) {
787 for e in ent {
788 if !e.old_oid.is_zero() {
789 seeds.push(e.old_oid);
790 }
791 if !e.new_oid.is_zero() {
792 seeds.push(e.new_oid);
793 }
794 }
795 }
796 }
797 }
798 seeds.sort();
799 seeds.dedup();
800
801 let mut seen = HashSet::new();
802 let mut queue: std::collections::VecDeque<ObjectId> = seeds.into_iter().collect();
803 while let Some(oid) = queue.pop_front() {
804 if oid.is_zero() || !seen.insert(oid) {
805 continue;
806 }
807 let Ok(obj) = repo.odb.read(&oid) else {
808 continue;
809 };
810 match obj.kind {
811 ObjectKind::Commit => {
812 if let Ok(c) = parse_commit(&obj.data) {
813 queue.push_back(c.tree);
814 for p in c.parents {
815 queue.push_back(p);
816 }
817 }
818 }
819 ObjectKind::Tree => {
820 if let Ok(entries) = parse_tree(&obj.data) {
821 for te in entries {
822 queue.push_back(te.oid);
823 }
824 }
825 }
826 ObjectKind::Tag | ObjectKind::Blob => {}
827 }
828 }
829 Ok(seen)
830}