1use quarb::{AstAdapter, NodeId, Value};
39use std::cell::RefCell;
40use std::collections::HashMap;
41use std::path::PathBuf;
42
43#[derive(Debug, thiserror::Error)]
45pub enum GitError {
46 #[error("git: {0}")]
47 Git(String),
48 #[error("git: running git: {0}")]
49 Spawn(#[from] std::io::Error),
50}
51
52#[derive(Clone)]
54struct CommitInfo {
55 author: String,
56 email: String,
57 date: i64,
58 date_offset: Option<i16>,
61 committer: String,
62 subject: String,
63 message: String,
64 tree: String,
65 parents: Vec<String>,
66}
67
68#[derive(Clone)]
70enum Kind {
71 Root,
72 Dir(&'static str),
74 Ref {
76 name: String,
77 commit: String,
78 },
79 Commit(String),
80 Entry {
82 name: String,
83 oid: String,
84 entry_type: String, mode: String,
86 },
87}
88
89struct Node {
90 kind: Kind,
91 parent: Option<NodeId>,
92 children: RefCell<Option<Vec<NodeId>>>,
93}
94
95pub struct GitAdapter {
97 repo: PathBuf,
98 decoding: quarb::source::SourceDecoding,
101 nodes: RefCell<Vec<Node>>,
102 commits: RefCell<HashMap<String, CommitInfo>>,
103 commit_nodes: RefCell<HashMap<String, NodeId>>,
105 enumerated: RefCell<bool>,
107 changed: RefCell<HashMap<String, Vec<String>>>,
109 tag_map: RefCell<Option<HashMap<String, Vec<String>>>>,
111}
112
113const ROOT: NodeId = NodeId(0);
114const BRANCHES: NodeId = NodeId(1);
115const TAGS: NodeId = NodeId(2);
116const COMMITS: NodeId = NodeId(3);
117
118impl GitAdapter {
119 pub fn open(path: &std::path::Path) -> Result<Self, GitError> {
121 Self::open_with_decoding(path, quarb::source::SourceDecoding::default())
122 }
123
124 pub fn open_with_decoding(
128 path: &std::path::Path,
129 decoding: quarb::source::SourceDecoding,
130 ) -> Result<Self, GitError> {
131 let adapter = GitAdapter {
132 repo: path.to_path_buf(),
133 decoding,
134 nodes: RefCell::new(vec![
135 Node {
136 kind: Kind::Root,
137 parent: None,
138 children: RefCell::new(None),
139 },
140 Node {
141 kind: Kind::Dir("branches"),
142 parent: Some(ROOT),
143 children: RefCell::new(None),
144 },
145 Node {
146 kind: Kind::Dir("tags"),
147 parent: Some(ROOT),
148 children: RefCell::new(None),
149 },
150 Node {
151 kind: Kind::Dir("commits"),
152 parent: Some(ROOT),
153 children: RefCell::new(None),
154 },
155 ]),
156 commits: RefCell::new(HashMap::new()),
157 commit_nodes: RefCell::new(HashMap::new()),
158 enumerated: RefCell::new(false),
159 changed: RefCell::new(HashMap::new()),
160 tag_map: RefCell::new(None),
161 };
162 adapter.git(&["rev-parse", "--git-dir"])?;
164 Ok(adapter)
165 }
166
167 pub fn locator(&self, node: NodeId) -> String {
170 let nodes = self.nodes.borrow();
171 let mut parts = Vec::new();
172 let mut cur = Some(node);
173 while let Some(n) = cur {
174 let nd = &nodes[n.0 as usize];
175 match &nd.kind {
176 Kind::Root => {}
177 Kind::Dir(d) => parts.push(d.to_string()),
178 Kind::Ref { name, .. } => parts.push(name.clone()),
179 Kind::Commit(h) => parts.push(h[..7.min(h.len())].to_string()),
180 Kind::Entry { name, .. } => parts.push(name.clone()),
181 }
182 cur = nd.parent;
183 }
184 parts.reverse();
185 format!("/{}", parts.join("/"))
186 }
187
188 fn git(&self, args: &[&str]) -> Result<String, GitError> {
195 let out = self.git_bytes(args)?;
196 String::from_utf8(out).map_err(|e| {
197 GitError::Git(format!(
198 "git {}: output is not UTF-8 at byte {}",
199 args.first().unwrap_or(&""),
200 e.utf8_error().valid_up_to()
201 ))
202 })
203 }
204
205 fn git_bytes(&self, args: &[&str]) -> Result<Vec<u8>, GitError> {
207 let out = std::process::Command::new("git")
208 .arg("-C")
209 .arg(&self.repo)
210 .args(["-c", "core.quotepath=off"])
211 .args(args)
212 .output()?;
213 if !out.status.success() {
214 return Err(GitError::Git(
215 String::from_utf8(out.stderr)
216 .map(|s| s.trim().to_string())
217 .unwrap_or_else(|_| "(git's message is not UTF-8)".to_string()),
218 ));
219 }
220 Ok(out.stdout)
221 }
222
223 fn push_node(&self, kind: Kind, parent: Option<NodeId>) -> NodeId {
224 let mut nodes = self.nodes.borrow_mut();
225 let id = NodeId(nodes.len() as u64);
226 nodes.push(Node {
227 kind,
228 parent,
229 children: RefCell::new(None),
230 });
231 id
232 }
233
234 fn commit_node(&self, hash: &str) -> NodeId {
236 if let Some(&id) = self.commit_nodes.borrow().get(hash) {
237 return id;
238 }
239 let id = self.push_node(Kind::Commit(hash.to_string()), Some(COMMITS));
240 self.commit_nodes.borrow_mut().insert(hash.to_string(), id);
241 id
242 }
243
244 fn commit_info(&self, hash: &str) -> Option<CommitInfo> {
246 if let Some(i) = self.commits.borrow().get(hash) {
247 return Some(i.clone());
248 }
249 let out = self
250 .git(&[
251 "show",
252 "-s",
253 "--encoding=UTF-8",
254 "--format=%an%x00%ae%x00%at%x00%cn%x00%s%x00%B%x00%T%x00%P%x00%ai",
255 hash,
256 ])
257 .ok()?;
258 let info = parse_info(&out)?;
259 self.commits
260 .borrow_mut()
261 .insert(hash.to_string(), info.clone());
262 Some(info)
263 }
264
265 fn enumerate_commits(&self) -> Vec<NodeId> {
268 if let Some(c) = self.nodes.borrow()[COMMITS.0 as usize]
269 .children
270 .borrow()
271 .as_ref()
272 {
273 return c.clone();
274 }
275 let out = self
276 .git(&[
277 "rev-list",
278 "--encoding=UTF-8",
279 "--all",
280 "--format=%an%x00%ae%x00%at%x00%cn%x00%s%x00%B%x00%T%x00%P%x00%ai%x1e",
281 ])
282 .unwrap_or_default();
283 let mut ids = Vec::new();
284 for (hash, body) in split_commit_records(&out) {
285 if let Some(info) = parse_info(body) {
286 self.commits.borrow_mut().insert(hash.to_string(), info);
287 }
288 ids.push(self.commit_node(hash));
289 }
290 *self.enumerated.borrow_mut() = true;
291 *self.nodes.borrow()[COMMITS.0 as usize]
292 .children
293 .borrow_mut() = Some(ids.clone());
294 ids
295 }
296
297 fn tags_at(&self, hash: &str) -> Vec<String> {
300 if self.tag_map.borrow().is_none() {
301 let out = self
302 .git(&[
303 "for-each-ref",
304 "refs/tags",
305 "--format=%(refname:short)%00%(objectname)%00%(*objectname)",
306 ])
307 .unwrap_or_default();
308 let mut map: HashMap<String, Vec<String>> = HashMap::new();
309 for line in out.lines() {
310 let mut f = line.split('\u{0}');
311 let (Some(name), Some(oid)) = (f.next(), f.next()) else {
312 continue;
313 };
314 let peeled = f.next().filter(|p| !p.is_empty()).unwrap_or(oid);
315 map.entry(peeled.to_string())
316 .or_default()
317 .push(name.to_string());
318 }
319 *self.tag_map.borrow_mut() = Some(map);
320 }
321 self.tag_map
322 .borrow()
323 .as_ref()
324 .and_then(|m| m.get(hash).cloned())
325 .unwrap_or_default()
326 }
327
328 fn refs(&self, dir: NodeId, prefix: &str) -> Vec<NodeId> {
331 if let Some(c) = self.nodes.borrow()[dir.0 as usize]
332 .children
333 .borrow()
334 .as_ref()
335 {
336 return c.clone();
337 }
338 let out = self
339 .git(&[
340 "for-each-ref",
341 prefix,
342 "--format=%(refname:short)%00%(objectname)%00%(*objectname)",
343 ])
344 .unwrap_or_default();
345 let mut ids = Vec::new();
346 for line in out.lines() {
347 let mut f = line.split('\u{0}');
348 let (Some(name), Some(oid)) = (f.next(), f.next()) else {
349 continue;
350 };
351 let deref = f.next().unwrap_or("");
352 let commit = if deref.is_empty() { oid } else { deref };
353 ids.push(self.push_node(
354 Kind::Ref {
355 name: name.to_string(),
356 commit: commit.to_string(),
357 },
358 Some(dir),
359 ));
360 }
361 *self.nodes.borrow()[dir.0 as usize].children.borrow_mut() = Some(ids.clone());
362 ids
363 }
364
365 fn tree_children(&self, parent: NodeId, tree_oid: &str) -> Vec<NodeId> {
367 if let Some(c) = self.nodes.borrow()[parent.0 as usize]
368 .children
369 .borrow()
370 .as_ref()
371 {
372 return c.clone();
373 }
374 let out = self.git(&["ls-tree", "-z", tree_oid]).unwrap_or_default();
375 let mut ids = Vec::new();
376 for entry in out.split('\u{0}') {
380 let Some((meta, name)) = entry.split_once('\t') else {
381 continue;
382 };
383 let mut f = meta.split(' ');
384 let (Some(mode), Some(entry_type), Some(oid)) = (f.next(), f.next(), f.next()) else {
385 continue;
386 };
387 ids.push(self.push_node(
388 Kind::Entry {
389 name: name.to_string(),
390 oid: oid.to_string(),
391 entry_type: entry_type.to_string(),
392 mode: mode.to_string(),
393 },
394 Some(parent),
395 ));
396 }
397 *self.nodes.borrow()[parent.0 as usize].children.borrow_mut() = Some(ids.clone());
398 ids
399 }
400
401 fn changed_paths(&self, hash: &str) -> Vec<String> {
404 if let Some(c) = self.changed.borrow().get(hash) {
405 return c.clone();
406 }
407 let out = self
408 .git(&[
409 "diff-tree",
410 "--root",
411 "--no-commit-id",
412 "--name-only",
413 "-z",
414 "-r",
415 hash,
416 ])
417 .unwrap_or_default();
418 let paths: Vec<String> = out
421 .split('\u{0}')
422 .filter(|p| !p.is_empty())
423 .map(str::to_string)
424 .collect();
425 self.changed
426 .borrow_mut()
427 .insert(hash.to_string(), paths.clone());
428 paths
429 }
430
431 fn entry_context(&self, node: NodeId) -> Option<(String, String)> {
434 let nodes = self.nodes.borrow();
435 let mut parts = Vec::new();
436 let mut cur = Some(node);
437 while let Some(n) = cur {
438 let nd = &nodes[n.0 as usize];
439 match &nd.kind {
440 Kind::Entry { name, .. } => parts.push(name.clone()),
441 Kind::Commit(h) => {
442 parts.reverse();
443 return Some((h.clone(), parts.join("/")));
444 }
445 Kind::Ref { commit, .. } => {
446 parts.reverse();
447 return Some((commit.clone(), parts.join("/")));
448 }
449 _ => return None,
450 }
451 cur = nd.parent;
452 }
453 None
454 }
455
456 fn commit_of(&self, node: NodeId) -> Option<String> {
458 match &self.nodes.borrow()[node.0 as usize].kind {
459 Kind::Commit(h) => Some(h.clone()),
460 Kind::Ref { commit, .. } => Some(commit.clone()),
461 _ => None,
462 }
463 }
464
465 fn commit_ancestor(&self, node: NodeId) -> Option<String> {
468 let mut cur = Some(node);
469 while let Some(n) = cur {
470 if let Some(h) = self.commit_of(n) {
471 return Some(h);
472 }
473 cur = self.nodes.borrow()[n.0 as usize].parent;
474 }
475 None
476 }
477}
478
479fn split_commit_records(out: &str) -> impl Iterator<Item = (&str, &str)> {
486 out.split('\u{1e}').filter_map(|record| {
487 let rest = record.trim_start().strip_prefix("commit ")?;
488 let (hash, body) = rest.split_once('\n')?;
489 Some((hash.trim(), body))
490 })
491}
492
493fn parse_info(body: &str) -> Option<CommitInfo> {
494 let f: Vec<&str> = body.trim_end_matches('\n').split('\u{0}').collect();
495 if f.len() < 8 {
496 return None;
497 }
498 Some(CommitInfo {
499 author: f[0].to_string(),
500 email: f[1].to_string(),
501 date: f[2].parse().unwrap_or(0),
502 date_offset: f.get(8).and_then(|iso| {
503 let tail = iso.trim().rsplit(' ').next()?;
506 let sign = match tail.as_bytes().first()? {
507 b'+' => 1i16,
508 b'-' => -1i16,
509 _ => return None,
510 };
511 let h: i16 = tail.get(1..3)?.parse().ok()?;
512 let m: i16 = tail.get(3..5)?.parse().ok()?;
513 Some(sign * (h * 60 + m))
514 }),
515 committer: f[3].to_string(),
516 subject: f[4].to_string(),
517 message: f[5].trim_end().to_string(),
518 tree: f[6].to_string(),
519 parents: f[7].split_whitespace().map(str::to_string).collect(),
520 })
521}
522
523impl AstAdapter for GitAdapter {
524 fn root(&self) -> NodeId {
525 ROOT
526 }
527
528 fn children(&self, node: NodeId) -> Vec<NodeId> {
529 let kind = self.nodes.borrow()[node.0 as usize].kind.clone();
530 match kind {
531 Kind::Root => {
532 if self.nodes.borrow()[ROOT.0 as usize]
535 .children
536 .borrow()
537 .is_none()
538 {
539 let mut ids = vec![BRANCHES, TAGS, COMMITS];
540 if let Ok(h) = self.git(&["rev-parse", "HEAD"]) {
541 ids.push(self.push_node(
542 Kind::Ref {
543 name: "HEAD".to_string(),
544 commit: h.trim().to_string(),
545 },
546 Some(ROOT),
547 ));
548 }
549 *self.nodes.borrow()[ROOT.0 as usize].children.borrow_mut() = Some(ids);
550 }
551 self.nodes.borrow()[ROOT.0 as usize]
552 .children
553 .borrow()
554 .clone()
555 .unwrap_or_default()
556 }
557 Kind::Dir("branches") => self.refs(BRANCHES, "refs/heads"),
558 Kind::Dir("tags") => self.refs(TAGS, "refs/tags"),
559 Kind::Dir(_) => self.enumerate_commits(),
560 Kind::Ref { commit, .. } | Kind::Commit(commit) => {
561 let Some(info) = self.commit_info(&commit) else {
562 return Vec::new();
563 };
564 self.tree_children(node, &info.tree)
565 }
566 Kind::Entry {
567 oid, entry_type, ..
568 } => {
569 if entry_type == "tree" {
570 self.tree_children(node, &oid)
571 } else {
572 Vec::new()
573 }
574 }
575 }
576 }
577
578 fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
582 if node == COMMITS {
583 if let Some(&id) = self.commit_nodes.borrow().get(name) {
587 return vec![id];
588 }
589 let Ok(out) = self.git(&[
590 "rev-parse",
591 "--verify",
592 "--quiet",
593 &format!("{name}^{{commit}}"),
594 ]) else {
595 return Vec::new();
596 };
597 return vec![self.commit_node(out.trim())];
598 }
599 self.children(node)
600 .into_iter()
601 .filter(|&c| self.name(c).as_deref() == Some(name))
602 .collect()
603 }
604
605 fn name(&self, node: NodeId) -> Option<String> {
606 match &self.nodes.borrow()[node.0 as usize].kind {
607 Kind::Root => None,
608 Kind::Dir(d) => Some(d.to_string()),
609 Kind::Ref { name, .. } => Some(name.clone()),
610 Kind::Commit(h) => Some(h.clone()),
611 Kind::Entry { name, .. } => Some(name.clone()),
612 }
613 }
614
615 fn parent(&self, node: NodeId) -> Option<NodeId> {
616 self.nodes.borrow()[node.0 as usize].parent
617 }
618
619 fn traits(&self, node: NodeId) -> Vec<String> {
621 let nodes = self.nodes.borrow();
622 let t = match &nodes[node.0 as usize].kind {
623 Kind::Root | Kind::Dir(_) => return Vec::new(),
624 Kind::Commit(_) => "commit",
625 Kind::Ref { .. } => match nodes[node.0 as usize].parent {
626 Some(TAGS) => "tag",
627 Some(BRANCHES) => "branch",
628 _ => "commit",
629 },
630 Kind::Entry { entry_type, .. } => {
631 let base = if entry_type == "tree" { "tree" } else { "blob" };
632 let mut out = vec![base.to_string()];
633 drop(nodes);
634 if let Some((hash, path)) = self.entry_context(node) {
638 let prefix = format!("{path}/");
639 if self
640 .changed_paths(&hash)
641 .iter()
642 .any(|p| *p == path || p.starts_with(&prefix))
643 {
644 out.push("changed".to_string());
645 }
646 }
647 return out;
648 }
649 };
650 vec![t.to_string()]
651 }
652
653 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
655 let hash = self.commit_of(node)?;
656 let info = self.commit_info(&hash)?;
657 Some(match name {
658 "author" => Value::Str(info.author),
659 "email" => Value::Str(info.email),
660 "date" => Value::Instant {
661 secs: info.date,
662 nanos: 0,
663 offset_min: info.date_offset,
664 },
665 "committer" => Value::Str(info.committer),
666 "subject" => Value::Str(info.subject),
667 "message" => Value::Str(info.message),
668 "tree" => Value::Str(info.tree),
669 "hash" => Value::Str(hash),
670 "parent" => Value::Str(info.parents.first()?.clone()),
671 "changed" => Value::list(
674 self.changed_paths(&hash)
675 .into_iter()
676 .map(Value::Str)
677 .collect(),
678 ),
679 _ => return None,
680 })
681 }
682
683 fn provenance(&self, node: NodeId) -> quarb::Provenance {
690 let instant = self
691 .commit_ancestor(node)
692 .and_then(|h| self.commit_info(&h))
693 .map(|info| (info.date, 0, info.date_offset));
694 quarb::Provenance {
695 source: Some(self.repo.display().to_string()),
696 path: None,
697 instant_from: instant.map(|_| quarb::InstantFrom::Node),
698 instant,
699 dpid: None,
700 }
701 }
702
703 fn default_value(&self, node: NodeId) -> Option<Value> {
705 let (oid, is_blob) = match &self.nodes.borrow()[node.0 as usize].kind {
706 Kind::Entry {
707 oid, entry_type, ..
708 } => (oid.clone(), entry_type == "blob"),
709 _ => return None,
710 };
711 if !is_blob {
712 return None;
713 }
714 self.git_bytes(&["cat-file", "blob", &oid])
717 .ok()
718 .and_then(|bytes| self.decoding.decode(&oid, &bytes).ok())
719 .map(|d| Value::Str(d.text))
720 }
721
722 fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
733 &[
734 "short",
735 "hash",
736 "n-parents",
737 "n-changed",
738 "tags",
739 "n-tags",
740 "type",
741 "mode",
742 "size",
743 ]
744 }
745
746 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
747 if let Some(hash) = self.commit_of(node) {
748 return match key {
749 "short" => Some(Value::Str(hash[..7.min(hash.len())].to_string())),
750 "n-parents" => Some(Value::Int(self.commit_info(&hash)?.parents.len() as i64)),
751 "n-changed" => Some(Value::Int(self.changed_paths(&hash).len() as i64)),
752 "tags" => Some(Value::list(
753 self.tags_at(&hash).into_iter().map(Value::Str).collect(),
754 )),
755 "n-tags" => Some(Value::Int(self.tags_at(&hash).len() as i64)),
756 _ => None,
757 };
758 }
759 let (oid, entry_type, mode) = match &self.nodes.borrow()[node.0 as usize].kind {
760 Kind::Entry {
761 oid,
762 entry_type,
763 mode,
764 ..
765 } => (oid.clone(), entry_type.clone(), mode.clone()),
766 _ => return None,
767 };
768 match key {
769 "type" => Some(Value::Str(entry_type)),
770 "mode" => Some(Value::Str(mode)),
771 "hash" => Some(Value::Str(oid)),
772 "size" => self
773 .git(&["cat-file", "-s", &oid])
774 .ok()
775 .and_then(|s| s.trim().parse().ok())
776 .map(Value::bytes),
777 _ => None,
778 }
779 }
780
781 fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
784 if property != "parent" {
785 return None;
786 }
787 let hash = self.commit_of(node)?;
788 let first = self.commit_info(&hash)?.parents.first()?.clone();
789 Some(self.commit_node(&first))
790 }
791
792 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
794 let Some(hash) = self.commit_of(node) else {
795 return Vec::new();
796 };
797 let Some(info) = self.commit_info(&hash) else {
798 return Vec::new();
799 };
800 info.parents
801 .iter()
802 .map(|p| ("parent".to_string(), self.commit_node(p)))
803 .collect()
804 }
805
806 fn ref_property(&self, node: NodeId) -> Option<String> {
809 self.commit_of(node).map(|_| "parent".to_string())
810 }
811
812 fn reverse_resolve(
818 &self,
819 node: NodeId,
820 property: Option<&str>,
821 _hint: Option<&str>,
822 ) -> Option<Vec<NodeId>> {
823 let hash = self.commit_of(node)?;
824 if property.is_some_and(|p| p != "parent") {
826 return Some(Vec::new());
827 }
828 self.enumerate_commits();
829 let commits = self.commits.borrow();
830 let mut children: Vec<String> = commits
831 .iter()
832 .filter(|(_, i)| i.parents.first() == Some(&hash))
833 .map(|(h, _)| h.clone())
834 .collect();
835 children.sort();
836 Some(children.iter().map(|h| self.commit_node(h)).collect())
837 }
838
839 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
842 let Some(hash) = self.commit_of(node) else {
843 return Vec::new();
844 };
845 self.enumerate_commits();
846 let commits = self.commits.borrow();
847 let mut out: Vec<(String, String)> = commits
848 .iter()
849 .filter(|(_, i)| i.parents.contains(&hash))
850 .map(|(h, _)| ("parent".to_string(), h.clone()))
851 .collect();
852 out.sort();
853 out.into_iter()
854 .map(|(l, h)| (l, self.commit_node(&h)))
855 .collect()
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862
863 #[test]
867 fn record_split_survives_commit_word_in_body() {
868 let out = "commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
869Ann\u{0}ann@example\u{0}1000\u{0}Ann\u{0}\
870Revert commit deadbeef\u{0}\
871Revert commit deadbeef\n\nThis reverts commit deadbeef.\u{0}\
872tttttttttttttttttttttttttttttttttttttttt\u{0}\
873pppppppppppppppppppppppppppppppppppppppp\u{0}\
8742026-07-15 12:00:00 +0100\u{1e}\n\
875commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
876Bo\u{0}bo@example\u{0}2000\u{0}Bo\u{0}second\u{0}second\u{0}\
877tttttttttttttttttttttttttttttttttttttttt\u{0}\u{0}\
8782026-07-15 13:00:00 +0100\u{1e}\n";
879 let recs: Vec<(&str, &str)> = split_commit_records(out).collect();
880 assert_eq!(recs.len(), 2);
881 assert_eq!(recs[0].0, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
882 let a = parse_info(recs[0].1).expect("first record parses");
883 assert_eq!(a.author, "Ann");
884 assert_eq!(a.subject, "Revert commit deadbeef");
885 assert_eq!(
886 a.message,
887 "Revert commit deadbeef\n\nThis reverts commit deadbeef."
888 );
889 assert_eq!(recs[1].0, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
890 let b = parse_info(recs[1].1).expect("second record parses");
891 assert_eq!(b.subject, "second");
892 }
893}