steid

@jamesgill /

1//! Parsing what `git log` and a raw commit object say about history.
2
3use std::time::SystemTime;
4
5use crate::{
6 application::port::GitQueryError,
7 domain::{CommitDetail, CommitEntry, CommitSummary, ObjectId},
8};
9
10use super::unix_time;
11
12/// Parses the NUL-separated `log` stream into four-field records.
13pub(super) fn parse_log_entries(stdout: &[u8]) -> Result<Vec<CommitEntry>, GitQueryError> {
14 // Empty fields are **kept**, as in [`parse_log`]: a one-line commit message has an
15 // empty body, and dropping it would slide every following field one place left and
16 // report one commit's subject as another's author. `-z` terminates the last record
17 // as well, so exactly one trailing empty is the terminator and is dropped by hand.
18 let mut fields: Vec<&[u8]> = stdout.split(|byte| *byte == 0).collect();
19
20 if fields.last().is_some_and(|last| last.is_empty()) {
21 fields.pop();
22 }
23
24 let mut entries = Vec::with_capacity(fields.len() / 5);
25
26 for record in fields.chunks(5) {
27 let [id, committed_at, author_name, summary, body] = record[..] else {
28 return Err(GitQueryError::new(
29 "git logged a commit with missing fields",
30 ));
31 };
32
33 let id = String::from_utf8_lossy(id);
34 let committed_at = String::from_utf8_lossy(committed_at);
35 let committed_at: i64 = committed_at.trim().parse().map_err(|_| {
36 GitQueryError::new(format!(
37 "git logged an unreadable commit time {committed_at:?}"
38 ))
39 })?;
40
41 entries.push(CommitEntry {
42 commit: CommitSummary {
43 id: ObjectId::new(id.trim()).map_err(|error| {
44 GitQueryError::new(format!("git named a bad object id: {error}"))
45 })?,
46 summary: String::from_utf8_lossy(summary)
47 .lines()
48 .next()
49 .unwrap_or_default()
50 .to_owned(),
51 author_name: String::from_utf8_lossy(author_name).into_owned(),
52 committed_at: unix_time(committed_at),
53 },
54 // `%b` ends with the newline that closed the message. Trimmed at the end
55 // only: leading blank lines are the author's, and a feed shows what was
56 // written.
57 body: String::from_utf8_lossy(body).trim_end().to_owned(),
58 });
59 }
60
61 Ok(entries)
62}
63
64pub(super) fn parse_log(stdout: &[u8]) -> Result<Vec<CommitSummary>, GitQueryError> {
65 // Empty fields are **kept**, as in [`parse_log_entries`]: an author name can be
66 // empty and so can a subject, and dropping one slides every following field a place
67 // left, so one commit's subject is reported as the next one's author. `-z`
68 // terminates the last record as well, so exactly one trailing empty is the
69 // terminator and is dropped by hand.
70 let mut fields: Vec<&[u8]> = stdout.split(|byte| *byte == 0).collect();
71
72 if fields.last().is_some_and(|last| last.is_empty()) {
73 fields.pop();
74 }
75
76 let mut commits = Vec::with_capacity(fields.len() / 4);
77
78 for record in fields.chunks(4) {
79 let [id, committed_at, author_name, summary] = record[..] else {
80 return Err(GitQueryError::new(
81 "git logged a commit with missing fields",
82 ));
83 };
84
85 let id = String::from_utf8_lossy(id);
86 let committed_at = String::from_utf8_lossy(committed_at);
87 let committed_at: i64 = committed_at.trim().parse().map_err(|_| {
88 GitQueryError::new(format!(
89 "git logged an unreadable commit time {committed_at:?}"
90 ))
91 })?;
92
93 commits.push(CommitSummary {
94 id: ObjectId::new(id.trim()).map_err(|error| {
95 GitQueryError::new(format!("git named a bad object id: {error}"))
96 })?,
97 // `%s` is git's subject: the first paragraph, joined into one line. Trimmed
98 // to the first line anyway, because that invariant is git's rather than
99 // something this parser should assume.
100 summary: String::from_utf8_lossy(summary)
101 .lines()
102 .next()
103 .unwrap_or_default()
104 .to_owned(),
105 author_name: String::from_utf8_lossy(author_name).into_owned(),
106 committed_at: unix_time(committed_at),
107 });
108 }
109
110 Ok(commits)
111}
112
113/// What a log record carries: id, commit time, author name, subject.
114///
115/// Every separator is a NUL, and `-z` separates the records themselves. A commit
116/// message contains newlines as a matter of course and a name can contain almost
117/// anything, so splitting on lines or whitespace would misread real history rather than
118/// exotic history.
119pub(super) const LOG_FORMAT: &str = "--format=%H%x00%ct%x00%an%x00%s";
120
121/// [`LOG_FORMAT`] plus the message body, which a feed entry shows and a log row does
122/// not.
123///
124/// The body is **last** for the reason [`COMMIT_FORMAT`]'s is: it is the field that can
125/// contain anything, including nothing at all.
126pub(super) const ENTRY_FORMAT: &str = "--format=%H%x00%ct%x00%an%x00%s%x00%b";
127
128/// Parses a commit object as git stores it.
129///
130/// ```text
131/// tree <id>
132/// parent <id> (absent on a root commit, twice on a merge)
133/// author Name <email> <unix seconds> <offset>
134/// committer Name <email> <unix seconds> <offset>
135/// <other headers, whose continuation lines begin with a space>
136///
137/// the message
138/// ```
139///
140/// This replaces `git log --format=…`, which produced the same fields out of these same
141/// bytes for the cost of a process. Two of git's conventions are reproduced rather than
142/// assumed, because a page shows them:
143///
144/// - **The subject is the first paragraph, not the first line.** `%s` folds a
145/// multi-line first paragraph into one line by joining with spaces, and a log row that
146/// showed only the first line of one would silently cut a sentence in half.
147/// - **The body is everything after the blank line that ends the subject**, trimmed of
148/// trailing whitespace. Trailing whitespace is not part of a message.
149///
150/// The id is passed in rather than hashed here: it came off the `--batch` header, which
151/// is git's own answer to what this object is.
152///
153/// The zone offset on a date is read and discarded. Steid renders times in the viewer's
154/// terms, so the committer's timezone is a fact about them and not about the commit.
155pub(super) fn parse_commit_object(
156 id: &ObjectId,
157 bytes: &[u8],
158) -> Result<CommitDetail, GitQueryError> {
159 let (headers, message) = split_headers(bytes);
160
161 let mut tree = None;
162 let mut parents = Vec::new();
163 let mut author = None;
164 let mut committer = None;
165
166 for line in headers.split(|byte| *byte == b'\n') {
167 // A continuation of the header before it — a `gpgsig`, or an embedded
168 // `mergetag`. Skipped whole: nothing here reads either.
169 if line.first() == Some(&b' ') {
170 continue;
171 }
172
173 let line = String::from_utf8_lossy(line);
174 let Some((key, value)) = line.split_once(' ') else {
175 continue;
176 };
177
178 match key {
179 "tree" => tree = Some(object_id(value)?),
180 "parent" => parents.push(object_id(value)?),
181 "author" => author = Some(identity(value)?),
182 "committer" => committer = Some(identity(value)?),
183 _ => {}
184 }
185 }
186
187 let (Some(tree), Some(author), Some(committer)) = (tree, author, committer) else {
188 return Err(GitQueryError::new(format!(
189 "git stored {} as a commit with no tree, author or committer",
190 id.as_str()
191 )));
192 };
193
194 let (summary, body) = split_message(&String::from_utf8_lossy(message));
195
196 Ok(CommitDetail {
197 id: id.clone(),
198 tree,
199 parents,
200 summary,
201 body,
202 author_name: author.name,
203 author_email: author.email,
204 authored_at: author.at,
205 committer_name: committer.name,
206 committer_email: committer.email,
207 committed_at: committer.at,
208 })
209}
210
211/// Splits a commit object into its headers and its message.
212///
213/// The boundary is the first genuinely empty line. A `gpgsig` header's continuation
214/// lines each begin with a space, so even the blank-looking ones inside a signature are
215/// a space long and do not end the headers.
216///
217/// A commit with no message at all — git allows one with `--allow-empty-message` — has
218/// no blank line either, and the whole object is headers.
219fn split_headers(bytes: &[u8]) -> (&[u8], &[u8]) {
220 if let Some(index) = bytes
221 .windows(2)
222 .position(|window| window == b"\n\n")
223 .map(|index| index + 1)
224 {
225 (&bytes[..index], &bytes[index + 1..])
226 } else {
227 (bytes, &[])
228 }
229}
230
231/// Splits a commit message into git's `%s` and `%b`.
232fn split_message(message: &str) -> (String, String) {
233 let (subject, body) = match message.split_once("\n\n") {
234 Some((subject, body)) => (subject, body),
235 None => (message, ""),
236 };
237
238 let subject = subject
239 .lines()
240 .map(str::trim_end)
241 .collect::<Vec<&str>>()
242 .join(" ");
243
244 (subject.trim().to_owned(), body.trim_end().to_owned())
245}
246
247/// Who wrote or committed something, and when.
248struct Identity {
249 name: String,
250 email: String,
251 at: SystemTime,
252}
253
254/// Parses an `author` or `committer` value: `Name <email> <seconds> <offset>`.
255///
256/// Read from the **right**, because a name may contain anything at all — including
257/// angle brackets and, in real history, an email address of its own. The last two
258/// whitespace-separated fields are the time and the zone; the email is what sits in the
259/// final pair of angle brackets before them.
260fn identity(value: &str) -> Result<Identity, GitQueryError> {
261 let bad = || GitQueryError::new(format!("git wrote an identity we cannot read: {value:?}"));
262
263 let (rest, _zone) = value.rsplit_once(' ').ok_or_else(bad)?;
264 let (rest, seconds) = rest.rsplit_once(' ').ok_or_else(bad)?;
265 let (name, email) = rest.rsplit_once(" <").ok_or_else(bad)?;
266
267 Ok(Identity {
268 name: name.trim().to_owned(),
269 email: email.trim_end().trim_end_matches('>').to_owned(),
270 at: seconds
271 .trim()
272 .parse::<i64>()
273 .map(unix_time)
274 .map_err(|_| GitQueryError::new(format!("git dated a commit as {seconds:?}")))?,
275 })
276}
277
278/// An object id git named, validated rather than trusted.
279///
280/// git's ids are trustworthy; this is a parse of a layout we have assumed, and an id is
281/// about to appear in a URL. A misread field should stop here rather than surface as a
282/// broken link.
283fn object_id(value: &str) -> Result<ObjectId, GitQueryError> {
284 ObjectId::new(value.trim())
285 .map_err(|error| GitQueryError::new(format!("git named a bad object id: {error}")))
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 // Pure, so these are tested against bytes written by hand rather than against a
293 // fixture repository: the point is the format, and a fixture would only prove
294 // that git and this parser agree about the easy cases. The round trip through a
295 // real repository is covered by the fixture tests in `git_query_tests.rs`.
296
297 #[test]
298 fn an_empty_author_name_or_subject_does_not_shift_the_log() {
299 // The same trap `parse_log_entries` was written to avoid, on the four-field
300 // parser: git writes the field, empty, and dropping it slides everything after
301 // it one place left — so the *next* commit's id lands in this commit's subject
302 // and the record after that fails to parse for a reason naming none of this.
303 //
304 // Both are reachable: `--allow-empty-message` leaves `%s` empty, and an ident
305 // whose name is blank leaves `%an` empty.
306 let stdout = b"\
307 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x001700000200\x00\x00a commit by nobody\x00\
308 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x001700000100\x00Ada Lovelace\x00\x00";
309
310 let commits = parse_log(stdout).expect("should parse");
311
312 assert_eq!(commits.len(), 2);
313 assert_eq!(commits[0].author_name, "");
314 assert_eq!(commits[0].summary, "a commit by nobody");
315 assert_eq!(commits[1].author_name, "Ada Lovelace");
316 assert_eq!(commits[1].summary, "", "an empty subject stays empty");
317 assert_eq!(
318 commits[1].id.as_str(),
319 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
320 "the second record must not have been shifted into the first"
321 );
322 }
323
324 fn commit_object(headers: &str, message: &str) -> Vec<u8> {
325 format!("{headers}\n{message}").into_bytes()
326 }
327
328 fn an_id(byte: u8) -> ObjectId {
329 ObjectId::from_trusted(format!("{byte:02x}").repeat(20))
330 }
331
332 #[test]
333 fn a_commit_object_is_parsed_into_a_detail() {
334 let bytes = commit_object(
335 &format!(
336 "tree {}\nparent {}\nauthor Ada <ada@example.com> 1700000000 +0100\ncommitter Bo <bo@example.com> 1700000060 -0500\n",
337 an_id(0xaa).as_str(),
338 an_id(0xbb).as_str()
339 ),
340 "feat: a thing\n\nWhy it is a thing.\n",
341 );
342
343 let commit = parse_commit_object(&an_id(0x11), &bytes).expect("should parse");
344
345 assert_eq!(commit.id, an_id(0x11));
346 assert_eq!(commit.tree, an_id(0xaa));
347 assert_eq!(commit.parents, vec![an_id(0xbb)]);
348 assert_eq!(commit.author_name, "Ada");
349 assert_eq!(commit.author_email, "ada@example.com");
350 assert_eq!(commit.committer_name, "Bo");
351 assert_eq!(commit.summary, "feat: a thing");
352 assert_eq!(commit.body, "Why it is a thing.");
353 assert_eq!(commit.authored_at, unix_time(1_700_000_000));
354 assert_eq!(commit.committed_at, unix_time(1_700_000_060));
355 }
356
357 #[test]
358 fn a_root_commit_has_no_parents_and_a_merge_has_two() {
359 let root = commit_object(
360 &format!(
361 "tree {}\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n",
362 an_id(0xaa).as_str()
363 ),
364 "first\n",
365 );
366 let merge = commit_object(
367 &format!(
368 "tree {}\nparent {}\nparent {}\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n",
369 an_id(0xaa).as_str(),
370 an_id(0xbb).as_str(),
371 an_id(0xcc).as_str()
372 ),
373 "merge\n",
374 );
375
376 assert!(
377 parse_commit_object(&an_id(0x11), &root)
378 .expect("should parse")
379 .parents
380 .is_empty()
381 );
382 assert_eq!(
383 parse_commit_object(&an_id(0x11), &merge)
384 .expect("should parse")
385 .parents,
386 vec![an_id(0xbb), an_id(0xcc)]
387 );
388 }
389
390 #[test]
391 fn a_signature_does_not_end_the_headers() {
392 // `gpgsig`'s continuation lines each begin with a space — including the ones that
393 // look blank. A parser that split on the first line with nothing much on it would
394 // read half a signature as the commit message.
395 let bytes = commit_object(
396 &format!(
397 "tree {}\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n iQIzBAABCgAd\n -----END PGP SIGNATURE-----\n",
398 an_id(0xaa).as_str()
399 ),
400 "signed\n",
401 );
402
403 let commit = parse_commit_object(&an_id(0x11), &bytes).expect("should parse");
404
405 assert_eq!(commit.summary, "signed");
406 assert_eq!(commit.body, "");
407 }
408
409 #[test]
410 fn a_multi_line_subject_is_folded_the_way_git_folds_it() {
411 let bytes = commit_object(
412 &format!(
413 "tree {}\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n",
414 an_id(0xaa).as_str()
415 ),
416 "a subject that\nran onto a second line\n\nand then a body\n",
417 );
418
419 let commit = parse_commit_object(&an_id(0x11), &bytes).expect("should parse");
420
421 assert_eq!(commit.summary, "a subject that ran onto a second line");
422 assert_eq!(commit.body, "and then a body");
423 }
424
425 #[test]
426 fn a_commit_with_no_message_parses() {
427 let bytes = format!(
428 "tree {}\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n",
429 an_id(0xaa).as_str()
430 )
431 .into_bytes();
432
433 let commit = parse_commit_object(&an_id(0x11), &bytes).expect("should parse");
434
435 assert_eq!(commit.summary, "");
436 assert_eq!(commit.body, "");
437 }
438
439 #[test]
440 fn an_identity_is_read_from_the_right_so_a_name_may_contain_anything() {
441 let bytes = commit_object(
442 &format!(
443 "tree {}\nauthor A <weird> Name <real@example.com> 1700000000 +0000\ncommitter A <a@b> 1 +0000\n",
444 an_id(0xaa).as_str()
445 ),
446 "x\n",
447 );
448
449 let commit = parse_commit_object(&an_id(0x11), &bytes).expect("should parse");
450
451 assert_eq!(commit.author_name, "A <weird> Name");
452 assert_eq!(commit.author_email, "real@example.com");
453 }
454
455 #[test]
456 fn a_commit_with_no_tree_is_a_fault() {
457 let bytes = commit_object("author A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n", "x\n");
458
459 assert!(parse_commit_object(&an_id(0x11), &bytes).is_err());
460 }
461}