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(), ¤t_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(), ¤t_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(), ¤t_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(), ¤t_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}