WIP: List the most recent change to each top-level entry in a git tree
at main 9.7 kB view raw
1use anyhow::Context as _; 2use gix::Commit; 3use gix::ObjectId; 4use gix::Repository; 5use gix::bstr::BStr; 6use gix::bstr::ByteSlice; 7use gix::date::Time; 8use gix::object::tree::EntryRef; 9use gix::objs::decode::Error as DecodeError; 10use gix::revision::walk::Sorting; 11use gix::traverse::commit::simple::CommitTimeOrder; 12use std::collections::HashSet; 13 14#[cfg(feature = "time-budget")] 15use std::time::Duration; 16#[cfg(feature = "time-budget")] 17use std::time::Instant; 18 19mod cli; 20 21fn main() -> anyhow::Result<()> { 22 let arguments = cli::parse(); 23 24 #[cfg(feature = "time-budget")] 25 let (time_limit, start) = (Duration::from_millis(arguments.time_limit), Instant::now()); 26 27 let repository = gix::open(arguments.repository)?; 28 29 let mut current_commit = repository.resolve_revspec(&arguments.from)?; 30 let mut current_tree = current_commit 31 .tree() 32 .context("Failed to get tree for initial commit")?; 33 34 if let Some(subtree) = &arguments.subtree { 35 let entry = current_tree 36 .lookup_entry_by_path(subtree)? 37 .ok_or_else(|| anyhow::anyhow!("Failed to find subtree: {subtree:?}"))?; 38 39 if !entry.mode().is_tree() { 40 Err(anyhow::anyhow!("{subtree:?} is not a subtree"))?; 41 } 42 43 current_tree = repository 44 .find_tree(entry.id()) 45 .context("Searching for subtree entry's tree")?; 46 } 47 48 // Build a set of the entry file names we are interested in. 49 let mut interested: HashSet<_> = Default::default(); 50 for entry in current_tree.iter() { 51 interested.insert(entry?.filename().to_owned()); 52 } 53 54 let name_pad = 2 + interested 55 .iter() 56 .fold(0, |len, filename| std::cmp::max(len, filename.len())); 57 58 let mut rev_walk = repository 59 .rev_walk([current_commit.id]) 60 .use_commit_graph(true) 61 .first_parent_only() 62 .sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst)); 63 64 if let Some(boundary) = arguments.to { 65 let stop_commit = repository.resolve_revspec(&boundary)?; 66 rev_walk = rev_walk.with_boundary([stop_commit.id]); 67 } 68 69 let output = |filename: &BStr, commit: &Commit| -> anyhow::Result<()> { 70 let id = commit.id; 71 let ts = format_ts(&commit.time()?, arguments.preserve_tz)?; 72 let message = commit.message()?.summary(); 73 let escaped_filename = format!("{filename:?}"); 74 println!("{escaped_filename:<name_pad$}\t{id}\t{ts}\t{message}",); 75 Ok(()) 76 }; 77 78 for revision in rev_walk.all()? { 79 if interested.is_empty() { 80 break; 81 } 82 83 #[cfg(feature = "time-budget")] 84 if start.elapsed() > time_limit { 85 break; 86 } 87 88 let revision = revision?; 89 let Some(&parent_id) = revision.parent_ids.first() else { 90 // The revision has no parents. Assume any remaining entries belong 91 // to this commit. 92 for entry in interested.drain() { 93 output(entry.as_bstr(), &current_commit)?; 94 } 95 break; 96 }; 97 98 let parent_commit = repository 99 .find_commit(parent_id) 100 .context("Failed to find parent commit")?; 101 let mut parent_tree = parent_commit 102 .tree() 103 .context("Failed to retrieve parent tree")?; 104 105 if let Some(subtree) = &arguments.subtree { 106 let Some(entry) = parent_tree.lookup_entry_by_path(subtree)? else { 107 // The subtree does not exist in this revision. Assume any 108 // remaining entries of interest belong to this commit. 109 for entry in interested.drain() { 110 output(entry.as_bstr(), &current_commit)?; 111 } 112 break; 113 }; 114 if !entry.mode().is_tree() { 115 // The subtree is no longer a subtree in this revision. Assume any 116 // remaining entries of interest belong to this commit. 117 for entry in interested.drain() { 118 output(entry.as_bstr(), &current_commit)?; 119 } 120 break; 121 } 122 parent_tree = repository.find_tree(entry.id())?; 123 } 124 125 let mut scanner = EntryScanner::new(current_tree.iter(), parent_tree.iter())?; 126 while let Some(change) = scanner.next_change()? { 127 if interested.remove(change.filename()) { 128 output(change.filename(), &current_commit)?; 129 } 130 } 131 132 current_commit = parent_commit; 133 } 134 135 if !interested.is_empty() { 136 for entry in interested.drain() { 137 println!("{:?}", entry.as_bstr()); 138 } 139 } 140 141 Ok(()) 142} 143 144struct EntryScanner<'repo, 'a, I> { 145 current_tree: I, 146 current_entry: Option<EntryRef<'repo, 'a>>, 147 parent_tree: I, 148 parent_entry: Option<EntryRef<'repo, 'a>>, 149} 150 151impl<'repo, 'a, I> EntryScanner<'repo, 'a, I> 152where 153 I: Iterator<Item = Result<EntryRef<'repo, 'a>, DecodeError>>, 154{ 155 pub fn new(current_tree: I, parent_tree: I) -> Result<Self, DecodeError> { 156 let mut this = Self { 157 current_tree, 158 parent_tree, 159 current_entry: None, 160 parent_entry: None, 161 }; 162 163 this.advance_both()?; 164 Ok(this) 165 } 166 167 fn advance_current(&mut self) -> Result<Option<EntryRef<'repo, 'a>>, DecodeError> { 168 let old = self.current_entry.take(); 169 self.current_entry = self.current_tree.next().transpose()?; 170 Ok(old) 171 } 172 173 fn advance_parent(&mut self) -> Result<Option<EntryRef<'repo, 'a>>, DecodeError> { 174 let old = self.parent_entry.take(); 175 self.parent_entry = self.parent_tree.next().transpose()?; 176 Ok(old) 177 } 178 179 fn advance_both( 180 &mut self, 181 ) -> Result<(Option<EntryRef<'repo, 'a>>, Option<EntryRef<'repo, 'a>>), DecodeError> { 182 Ok((self.advance_current()?, self.advance_parent()?)) 183 } 184 185 pub fn is_complete(&self) -> bool { 186 self.current_entry.is_none() && self.parent_entry.is_none() 187 } 188 189 /// Get the next change between the two trees. 190 pub fn next_change(&mut self) -> Result<Option<Change<'a>>, DecodeError> { 191 while !self.is_complete() { 192 match (&self.current_entry, &self.parent_entry) { 193 (None, None) => break, 194 (Some(current), Some(parent)) if current.filename() == parent.filename() => { 195 if current.id() != parent.id() { 196 return Ok(self 197 .advance_both()? 198 .0 199 .map(|entry| Change::Modify(entry.detach()))); 200 } 201 self.advance_both()?; 202 continue; 203 } 204 (Some(current), Some(parent)) => { 205 if current.filename() < parent.filename() { 206 return Ok(self 207 .advance_current()? 208 .map(|entry| Change::Create(entry.detach()))); 209 } else { 210 return Ok(self 211 .advance_parent()? 212 .map(|entry| Change::Delete(entry.detach()))); 213 } 214 } 215 (Some(_), None) => { 216 // Entry was created by the current commit. 217 return Ok(self 218 .advance_current()? 219 .map(|entry| Change::Create(entry.detach()))); 220 } 221 (None, Some(_)) => { 222 // Entry was deleted by the current commit. 223 return Ok(self 224 .advance_parent()? 225 .map(|entry| Change::Delete(entry.detach()))); 226 } 227 } 228 } 229 230 Ok(None) 231 } 232} 233 234#[derive(Debug)] 235pub enum Change<'a> { 236 Create(gix::objs::tree::EntryRef<'a>), 237 Modify(gix::objs::tree::EntryRef<'a>), 238 Delete(gix::objs::tree::EntryRef<'a>), 239} 240 241impl<'a> Change<'a> { 242 pub fn filename(&self) -> &BStr { 243 match self { 244 Self::Create(entry) => entry.filename, 245 Self::Modify(entry) => entry.filename, 246 Self::Delete(entry) => entry.filename, 247 } 248 } 249} 250 251fn format_ts(time: &Time, preserve_tz: bool) -> anyhow::Result<String> { 252 use time::OffsetDateTime; 253 use time::UtcOffset; 254 use time::format_description::well_known::Rfc3339; 255 256 let odt = OffsetDateTime::from_unix_timestamp(time.seconds)? 257 .to_offset(UtcOffset::from_whole_seconds(time.offset)?); 258 259 let formatted = match preserve_tz { 260 true => odt.format(&Rfc3339)?, 261 false => odt.to_utc().format(&Rfc3339)?, 262 }; 263 264 Ok(formatted) 265} 266 267trait ResolveRevSpec { 268 type Error; 269 /// Resolve a commit ID, reference, or symbolic reference to a [`Commit`]. 270 fn resolve_revspec<'repo>(&'repo self, spec: &str) -> Result<Commit<'repo>, Self::Error>; 271} 272 273impl ResolveRevSpec for Repository { 274 type Error = anyhow::Error; 275 fn resolve_revspec<'repo>(&'repo self, spec: &str) -> Result<Commit<'repo>, Self::Error> { 276 use std::str::FromStr; 277 278 let resolved = match ObjectId::from_str(spec) { 279 Ok(id) => self 280 .find_commit(id) 281 .context("Failed to find commit in repository")?, 282 Err(_) => { 283 let id = self 284 .find_reference(spec) 285 .context("Failed to resolve reference in repository")? 286 .into_fully_peeled_id()?; 287 288 self.find_commit(id) 289 .context("Failed to find commit in repository")? 290 } 291 }; 292 293 Ok(resolved) 294 } 295}