use std::ops::ControlFlow;
use crate::error::{Error, Result};
use crate::reflog::{
delete_reflog_entries_on_store, delete_reflog_entries_rechain_on_store,
expire_reflog_git_with_cache, load_gc_reflog_expire_config, parse_reflog_expire_spec,
read_reflog_on_store, reflog_identity_timestamp, reflog_refs_on_store, MessageMatcher,
ReflogEntry, ReflogExpireParams, ReflogExpireResult, ReflogExpiryCache,
};
use crate::refs::store::{RefName, RefStore};
use crate::repo::Repository;
use crate::worktree_ref::resolve_ref_dwim;
#[derive(Debug, Clone)]
pub struct ExpireOptions {
pub now: i64,
pub expire: Option<String>,
pub expire_unreachable: Option<String>,
pub all: bool,
pub dry_run: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RefExpireResult {
pub ref_name: String,
pub pruned: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReflogExpireOutcome {
pub entries_pruned: usize,
pub dry_run: bool,
pub refs: Vec<RefExpireResult>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexedReflogEntry {
pub index: usize,
pub entry: ReflogEntry,
}
#[derive(Debug, Clone, Default)]
pub struct ReflogQuery {
pub limit: Option<usize>,
pub since: Option<i64>,
pub until: Option<i64>,
pub grep: Option<MessageMatcher>,
pub now: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReflogPage {
pub entries: Vec<IndexedReflogEntry>,
pub truncated: bool,
}
#[derive(Debug, Copy, Clone)]
pub struct RepositoryReflogs<'repo> {
repo: &'repo Repository,
}
impl<'repo> RepositoryReflogs<'repo> {
pub(crate) fn new(repo: &'repo Repository) -> Self {
Self { repo }
}
#[must_use]
pub fn repository(&self) -> &'repo Repository {
self.repo
}
pub fn refs(&self) -> Result<Vec<RefName>> {
Ok(reflog_refs_on_store(self.store())?
.iter()
.filter_map(|name| RefName::try_from(name.as_str()).ok())
.collect())
}
fn store(&self) -> &'repo dyn RefStore {
self.repo.refs()
}
pub fn entries(&self, ref_name: &RefName) -> Result<Vec<IndexedReflogEntry>> {
let page = self.query(ref_name, &ReflogQuery::default())?;
Ok(page.entries)
}
pub fn query(&self, ref_name: &RefName, query: &ReflogQuery) -> Result<ReflogPage> {
let canonical = self.resolve_reflog_canonical(ref_name.as_str())?;
if query.limit == Some(0) {
return Ok(ReflogPage {
entries: Vec::new(),
truncated: false,
});
}
let mut state = QueryScanState {
query,
entries: Vec::new(),
limit_reached: false,
truncated: false,
};
let mut index = 0usize;
self.store()
.for_each_reflog_entry(&canonical, true, &mut |entry| {
let flow = state.visit(index, entry);
index += 1;
flow
})?;
Ok(ReflogPage {
entries: state.entries,
truncated: state.truncated,
})
}
fn resolve_reflog_canonical(&self, user_ref: &str) -> Result<String> {
let store = self.store();
for candidate in dwim_reflog_candidates(user_ref) {
if reflog_has_entries(store, &candidate)? {
return Ok(candidate);
}
}
canonical_reflog_ref(store, user_ref)
}
pub fn delete(&self, ref_name: &RefName, indices: &[usize], rewrite: bool) -> Result<()> {
if indices.is_empty() {
return Ok(());
}
let (canonical, entries) = self.load_reflog(ref_name.as_str())?;
let len = entries.len();
for &idx in indices {
if idx >= len {
return Err(Error::ReflogIndexOutOfRange {
refname: canonical,
index: idx,
});
}
}
if rewrite {
delete_reflog_entries_rechain_on_store(self.store(), &canonical, indices)
} else {
delete_reflog_entries_on_store(self.store(), &canonical, indices)
}
}
pub fn expire(&self, options: &ExpireOptions) -> Result<ReflogExpireOutcome> {
let cfg = self.repo.config()?;
let gc = load_gc_reflog_expire_config(&cfg, options.now);
let explicit_total = options
.expire
.as_deref()
.map(|s| parse_reflog_expire_spec(s, options.now))
.transpose()?;
let explicit_unreachable = options
.expire_unreachable
.as_deref()
.map(|s| parse_reflog_expire_spec(s, options.now))
.transpose()?;
let params = ReflogExpireParams {
stale_fix: false,
dry_run: options.dry_run,
verbose: false,
};
let refnames = expire_target_refs(self.store(), options.all)?;
let mut total = 0usize;
let mut refs = Vec::with_capacity(refnames.len());
let cache = ReflogExpiryCache::default();
for refname in refnames {
let ReflogExpireResult { pruned, .. } = expire_reflog_git_with_cache(
self.repo,
&refname,
¶ms,
explicit_total,
explicit_unreachable,
&gc.patterns,
gc.global_total,
gc.global_unreachable,
options.now,
&cache,
)?;
total += pruned;
refs.push(RefExpireResult {
ref_name: refname,
pruned,
});
}
Ok(ReflogExpireOutcome {
entries_pruned: total,
dry_run: options.dry_run,
refs,
})
}
fn load_reflog(&self, user_ref: &str) -> Result<(String, Vec<ReflogEntry>)> {
let store = self.store();
for candidate in dwim_reflog_candidates(user_ref) {
let entries = read_reflog_on_store(store, &candidate)?;
if !entries.is_empty() {
return Ok((candidate, entries));
}
}
Ok((canonical_reflog_ref(store, user_ref)?, Vec::new()))
}
}
struct QueryScanState<'a> {
query: &'a ReflogQuery,
entries: Vec<IndexedReflogEntry>,
limit_reached: bool,
truncated: bool,
}
impl QueryScanState<'_> {
fn visit(&mut self, index: usize, entry: &ReflogEntry) -> ControlFlow<()> {
if self.limit_reached {
self.truncated = true;
return ControlFlow::Break(());
}
if !entry_matches_query(entry, self.query) {
return ControlFlow::Continue(());
}
self.entries.push(IndexedReflogEntry {
index,
entry: entry.clone(),
});
self.limit_reached = self
.query
.limit
.is_some_and(|limit| self.entries.len() >= limit);
ControlFlow::Continue(())
}
}
fn entry_matches_query(entry: &ReflogEntry, query: &ReflogQuery) -> bool {
if let Some(since) = query.since {
if reflog_identity_timestamp(&entry.identity).is_some_and(|ts| ts < since) {
return false;
}
}
if let Some(until) = query.until {
if reflog_identity_timestamp(&entry.identity).is_some_and(|ts| ts > until) {
return false;
}
}
if let Some(grep) = &query.grep {
if !grep.matches(&entry.message) {
return false;
}
}
let _ = query.now;
true
}
fn reflog_has_entries(store: &dyn RefStore, refname: &str) -> Result<bool> {
let mut found = false;
store.for_each_reflog_entry(refname, true, &mut |_| {
found = true;
ControlFlow::Break(())
})?;
Ok(found)
}
fn dwim_reflog_candidates(user_ref: &str) -> Vec<String> {
let mut out = vec![user_ref.to_owned()];
if user_ref != "HEAD" && !user_ref.starts_with("refs/") {
out.push(format!("refs/{user_ref}"));
out.push(format!("refs/heads/{user_ref}"));
}
out
}
fn canonical_reflog_ref(store: &dyn RefStore, user_ref: &str) -> Result<String> {
for candidate in dwim_reflog_candidates(user_ref) {
if store.reflog_exists(&candidate)? {
return Ok(candidate);
}
}
if !ref_exists_dwim(store, user_ref)? {
return Err(Error::ReflogUnknownRef {
refname: user_ref.to_owned(),
});
}
if user_ref.starts_with("refs/") || user_ref == "HEAD" {
return Ok(user_ref.to_owned());
}
Ok(format!("refs/heads/{user_ref}"))
}
fn ref_exists_dwim(store: &dyn RefStore, user_ref: &str) -> Result<bool> {
if user_ref == "HEAD" {
return Ok(store.read_raw("HEAD")?.is_some());
}
let (_, oid) = resolve_ref_dwim(|candidate| store.resolve(candidate).ok(), user_ref);
Ok(oid.is_some())
}
fn expire_target_refs(store: &dyn RefStore, all: bool) -> Result<Vec<String>> {
if !all {
return reflog_refs_on_store(store);
}
let mut names: Vec<String> = crate::refs::collect_resolved_refs(store, "refs/")?
.into_iter()
.map(|(name, _)| name)
.collect();
if store.reflog_exists("HEAD")? {
names.push("HEAD".to_owned());
}
names.sort();
names.dedup();
Ok(names)
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
use crate::refs::append_reflog;
use crate::repo::{InitOptions, Repository};
fn init_repo() -> (tempfile::TempDir, Repository) {
let dir = tempfile::tempdir().expect("tempdir");
let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
(dir, repo)
}
#[test]
fn query_limit_zero_returns_empty_without_reading_entries() {
let (_dir, repo) = init_repo();
let git_dir = repo.git_dir().to_path_buf();
let refname = "refs/heads/main";
append_reflog(
&git_dir,
refname,
&crate::objects::ObjectId::zero(),
&crate::objects::ObjectId::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
.expect("oid"),
"T <t@example.com> 1000000000 +0000",
"commit: one",
true,
)
.expect("append");
let name = RefName::try_from(refname).expect("valid");
let page = repo
.reflogs()
.query(
&name,
&ReflogQuery {
limit: Some(0),
..ReflogQuery::default()
},
)
.expect("query");
assert!(page.entries.is_empty());
assert!(!page.truncated);
}
#[test]
fn loose_scan_index_skips_unparseable_lines_without_renumbering() {
let (_dir, repo) = init_repo();
let git_dir = repo.git_dir().to_path_buf();
let refname = "refs/heads/x";
let oid = crate::objects::ObjectId::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
.expect("oid");
let body = format!(
"not-a-valid-reflog-line\n0000000000000000000000000000000000000000 {oid} T <t@e.com> 2000 +0000\tcommit: newest\n"
);
let path = crate::reflog::reflog_path(&git_dir, refname);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("mkdir");
}
fs::write(&path, body).expect("write");
let name = RefName::try_from(refname).expect("valid");
let page = repo
.reflogs()
.query(&name, &ReflogQuery::default())
.expect("query");
assert_eq!(page.entries.len(), 1);
assert_eq!(page.entries[0].index, 0);
}
#[test]
fn loose_scan_index_skips_unparseable_newer_line_without_renumbering() {
let (_dir, repo) = init_repo();
let git_dir = repo.git_dir().to_path_buf();
let refname = "refs/heads/y";
let oid = crate::objects::ObjectId::from_hex("cccccccccccccccccccccccccccccccccccccccc")
.expect("oid");
let body = format!(
"0000000000000000000000000000000000000000 {oid} T <t@e.com> 2000 +0000\tcommit: older\nnot-a-valid-reflog-line\n"
);
let path = crate::reflog::reflog_path(&git_dir, refname);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("mkdir");
}
fs::write(&path, body).expect("write");
let name = RefName::try_from(refname).expect("valid");
let page = repo
.reflogs()
.query(&name, &ReflogQuery::default())
.expect("query");
assert_eq!(page.entries.len(), 1);
assert_eq!(page.entries[0].index, 0);
}
#[test]
fn delete_out_of_range_returns_typed_error() {
let (_dir, repo) = init_repo();
let git_dir = repo.git_dir().to_path_buf();
let refname = "refs/heads/main";
append_reflog(
&git_dir,
refname,
&crate::objects::ObjectId::zero(),
&crate::objects::ObjectId::zero(),
"T <t@example.com> 1000000000 +0000",
"only",
true,
)
.expect("append");
let name = RefName::try_from(refname).expect("valid");
let err = repo
.reflogs()
.delete(&name, &[1], false)
.expect_err("out of range");
match err {
Error::ReflogIndexOutOfRange { refname, index } => {
assert_eq!(refname, "refs/heads/main");
assert_eq!(index, 1);
}
other => panic!("expected ReflogIndexOutOfRange, got {other:?}"),
}
}
#[test]
fn expire_outcome_refs_are_sorted() {
let (_dir, repo) = init_repo();
let git_dir = repo.git_dir();
let zero = crate::objects::ObjectId::zero();
let identity = "T <t@example.com> 1700000000 +0000";
for refname in ["refs/heads/zebra", "refs/heads/alpha"] {
append_reflog(git_dir, refname, &zero, &zero, identity, "seed", true).expect("append");
}
let outcome = repo
.reflogs()
.expire(&ExpireOptions {
now: 1_700_000_000,
expire: Some("never".into()),
expire_unreachable: Some("never".into()),
all: false,
dry_run: true,
})
.expect("expire");
let names: Vec<_> = outcome.refs.iter().map(|r| r.ref_name.as_str()).collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted);
assert!(names.iter().any(|n| *n == "refs/heads/alpha"));
assert!(names.iter().any(|n| *n == "refs/heads/zebra"));
}
}