use 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: 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>, parent_tree: I, parent_entry: Option>, } impl<'repo, 'a, I> EntryScanner<'repo, 'a, I> where I: Iterator, DecodeError>>, { pub fn new(current_tree: I, parent_tree: I) -> Result { 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>, DecodeError> { let old = self.current_entry.take(); self.current_entry = self.current_tree.next().transpose()?; Ok(old) } fn advance_parent(&mut self) -> Result>, DecodeError> { let old = self.parent_entry.take(); self.parent_entry = self.parent_tree.next().transpose()?; Ok(old) } fn advance_both( &mut self, ) -> Result<(Option>, Option>), 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>, 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 { 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, Self::Error>; } impl ResolveRevSpec for Repository { type Error = anyhow::Error; fn resolve_revspec<'repo>(&'repo self, spec: &str) -> Result, 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) } }