Something went wrong. Try again.
WIP: List the most recent change to each top-level entry in a git tree
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296use anyhow::Context as _;use gix::Commit;use gix::ObjectId;use gix::Repository;use gix::bstr::BStr;use gix::bstr::ByteSlice;use gix::date::Time;use gix::object::tree::EntryRef;use gix::objs::decode::Error as DecodeError;use gix::revision::walk::Sorting;use gix::traverse::commit::simple::CommitTimeOrder;use std::collections::HashSet;
#[cfg(feature = "time-budget")]use std::time::Duration;#[cfg(feature = "time-budget")]use std::time::Instant;
mod cli;
fn main() -> anyhow::Result<()> { let arguments = cli::parse();
#[cfg(feature = "time-budget")] let (time_limit, start) = (Duration::from_millis(arguments.time_limit), Instant::now());
let repository = gix::open(arguments.repository)?;
let mut current_commit = repository.resolve_revspec(&arguments.from)?; let mut current_tree = current_commit .tree() .context("Failed to get tree for initial commit")?;
if let Some(subtree) = &arguments.subtree { let entry = current_tree .lookup_entry_by_path(subtree)? .ok_or_else(|| anyhow::anyhow!("Failed to find subtree: {subtree:?}"))?;
if !entry.mode().is_tree() { Err(anyhow::anyhow!("{subtree:?} is not a subtree"))?; }
current_tree = repository .find_tree(entry.id()) .context("Searching for subtree entry's tree")?; }
// Build a set of the entry file names we are interested in. let mut interested: HashSet<_> = Default::default(); for entry in current_tree.iter() { interested.insert(entry?.filename().to_owned()); }
let name_pad = 2 + interested .iter() .fold(0, |len, filename| std::cmp::max(len, filename.len()));
let mut rev_walk = repository .rev_walk([current_commit.id]) .use_commit_graph(true) .first_parent_only() .sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst));
if let Some(boundary) = arguments.to { let stop_commit = repository.resolve_revspec(&boundary)?; rev_walk = rev_walk.with_boundary([stop_commit.id]); }
let output = |filename: &BStr, commit: &Commit| -> anyhow::Result<()> { let id = commit.id; let ts = format_ts(&commit.time()?, arguments.preserve_tz)?; let message = commit.message()?.summary(); let escaped_filename = format!("{filename:?}"); println!("{escaped_filename:<name_pad$}\t{id}\t{ts}\t{message}",); Ok(()) };
for revision in rev_walk.all()? { if interested.is_empty() { break; }
#[cfg(feature = "time-budget")] if start.elapsed() > time_limit { break; }
let revision = revision?; let Some(&parent_id) = revision.parent_ids.first() else { // The revision has no parents. Assume any remaining entries belong // to this commit. for entry in interested.drain() { output(entry.as_bstr(), ¤t_commit)?; } break; };
let parent_commit = repository .find_commit(parent_id) .context("Failed to find parent commit")?; let mut parent_tree = parent_commit .tree() .context("Failed to retrieve parent tree")?;
if let Some(subtree) = &arguments.subtree { let Some(entry) = parent_tree.lookup_entry_by_path(subtree)? else { // The subtree does not exist in this revision. Assume any // remaining entries of interest belong to this commit. for entry in interested.drain() { output(entry.as_bstr(), ¤t_commit)?; } break; }; if !entry.mode().is_tree() { // The subtree is no longer a subtree in this revision. Assume any // remaining entries of interest belong to this commit. for entry in interested.drain() { output(entry.as_bstr(), ¤t_commit)?; } break; } parent_tree = repository.find_tree(entry.id())?; }
let mut scanner = EntryScanner::new(current_tree.iter(), parent_tree.iter())?; while let Some(change) = scanner.next_change()? { if interested.remove(change.filename()) { output(change.filename(), ¤t_commit)?; } }
current_commit = parent_commit; }
if !interested.is_empty() { for entry in interested.drain() { println!("{:?}", entry.as_bstr()); } }
Ok(())}
struct EntryScanner<'repo, 'a, I> { current_tree: I, current_entry: Option<EntryRef<'repo, 'a>>, parent_tree: I, parent_entry: Option<EntryRef<'repo, 'a>>,}
impl<'repo, 'a, I> EntryScanner<'repo, 'a, I>where I: Iterator<Item = Result<EntryRef<'repo, 'a>, DecodeError>>,{ pub fn new(current_tree: I, parent_tree: I) -> Result<Self, DecodeError> { let mut this = Self { current_tree, parent_tree, current_entry: None, parent_entry: None, };
this.advance_both()?; Ok(this) }
fn advance_current(&mut self) -> Result<Option<EntryRef<'repo, 'a>>, DecodeError> { let old = self.current_entry.take(); self.current_entry = self.current_tree.next().transpose()?; Ok(old) }
fn advance_parent(&mut self) -> Result<Option<EntryRef<'repo, 'a>>, DecodeError> { let old = self.parent_entry.take(); self.parent_entry = self.parent_tree.next().transpose()?; Ok(old) }
fn advance_both( &mut self, ) -> Result<(Option<EntryRef<'repo, 'a>>, Option<EntryRef<'repo, 'a>>), DecodeError> { Ok((self.advance_current()?, self.advance_parent()?)) }
pub fn is_complete(&self) -> bool { self.current_entry.is_none() && self.parent_entry.is_none() }
/// Get the next change between the two trees. pub fn next_change(&mut self) -> Result<Option<Change<'a>>, DecodeError> { while !self.is_complete() { match (&self.current_entry, &self.parent_entry) { (None, None) => break, (Some(current), Some(parent)) if current.filename() == parent.filename() => { if current.id() != parent.id() { return Ok(self .advance_both()? .0 .map(|entry| Change::Modify(entry.detach()))); } self.advance_both()?; continue; } (Some(current), Some(parent)) => { if current.filename() < parent.filename() { return Ok(self .advance_current()? .map(|entry| Change::Create(entry.detach()))); } else { return Ok(self .advance_parent()? .map(|entry| Change::Delete(entry.detach()))); } } (Some(_), None) => { // Entry was created by the current commit. return Ok(self .advance_current()? .map(|entry| Change::Create(entry.detach()))); } (None, Some(_)) => { // Entry was deleted by the current commit. return Ok(self .advance_parent()? .map(|entry| Change::Delete(entry.detach()))); } } }
Ok(None) }}
#[derive(Debug)]pub enum Change<'a> { Create(gix::objs::tree::EntryRef<'a>), Modify(gix::objs::tree::EntryRef<'a>), Delete(gix::objs::tree::EntryRef<'a>),}
impl<'a> Change<'a> { pub fn filename(&self) -> &BStr { match self { Self::Create(entry) => entry.filename, Self::Modify(entry) => entry.filename, Self::Delete(entry) => entry.filename, } }}
fn format_ts(time: &Time, preserve_tz: bool) -> anyhow::Result<String> { use time::OffsetDateTime; use time::UtcOffset; use time::format_description::well_known::Rfc3339;
let odt = OffsetDateTime::from_unix_timestamp(time.seconds)? .to_offset(UtcOffset::from_whole_seconds(time.offset)?);
let formatted = match preserve_tz { true => odt.format(&Rfc3339)?, false => odt.to_utc().format(&Rfc3339)?, };
Ok(formatted)}
trait ResolveRevSpec { type Error; /// Resolve a commit ID, reference, or symbolic reference to a [`Commit`]. fn resolve_revspec<'repo>(&'repo self, spec: &str) -> Result<Commit<'repo>, Self::Error>;}
impl ResolveRevSpec for Repository { type Error = anyhow::Error; fn resolve_revspec<'repo>(&'repo self, spec: &str) -> Result<Commit<'repo>, Self::Error> { use std::str::FromStr;
let resolved = match ObjectId::from_str(spec) { Ok(id) => self .find_commit(id) .context("Failed to find commit in repository")?, Err(_) => { let id = self .find_reference(spec) .context("Failed to resolve reference in repository")? .into_fully_peeled_id()?;
self.find_commit(id) .context("Failed to find commit in repository")? } };
Ok(resolved) }}