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 nodes: RefCell<Vec<Node>>,
99 commits: RefCell<HashMap<String, CommitInfo>>,
100 commit_nodes: RefCell<HashMap<String, NodeId>>,
102 enumerated: RefCell<bool>,
104 changed: RefCell<HashMap<String, Vec<String>>>,
106 tag_map: RefCell<Option<HashMap<String, Vec<String>>>>,
108}
109
110const ROOT: NodeId = NodeId(0);
111const BRANCHES: NodeId = NodeId(1);
112const TAGS: NodeId = NodeId(2);
113const COMMITS: NodeId = NodeId(3);
114
115impl GitAdapter {
116 pub fn open(path: &std::path::Path) -> Result<Self, GitError> {
118 let adapter = GitAdapter {
119 repo: path.to_path_buf(),
120 nodes: RefCell::new(vec![
121 Node {
122 kind: Kind::Root,
123 parent: None,
124 children: RefCell::new(None),
125 },
126 Node {
127 kind: Kind::Dir("branches"),
128 parent: Some(ROOT),
129 children: RefCell::new(None),
130 },
131 Node {
132 kind: Kind::Dir("tags"),
133 parent: Some(ROOT),
134 children: RefCell::new(None),
135 },
136 Node {
137 kind: Kind::Dir("commits"),
138 parent: Some(ROOT),
139 children: RefCell::new(None),
140 },
141 ]),
142 commits: RefCell::new(HashMap::new()),
143 commit_nodes: RefCell::new(HashMap::new()),
144 enumerated: RefCell::new(false),
145 changed: RefCell::new(HashMap::new()),
146 tag_map: RefCell::new(None),
147 };
148 adapter.git(&["rev-parse", "--git-dir"])?;
150 Ok(adapter)
151 }
152
153 pub fn locator(&self, node: NodeId) -> String {
156 let nodes = self.nodes.borrow();
157 let mut parts = Vec::new();
158 let mut cur = Some(node);
159 while let Some(n) = cur {
160 let nd = &nodes[n.0 as usize];
161 match &nd.kind {
162 Kind::Root => {}
163 Kind::Dir(d) => parts.push(d.to_string()),
164 Kind::Ref { name, .. } => parts.push(name.clone()),
165 Kind::Commit(h) => parts.push(h[..7.min(h.len())].to_string()),
166 Kind::Entry { name, .. } => parts.push(name.clone()),
167 }
168 cur = nd.parent;
169 }
170 parts.reverse();
171 format!("/{}", parts.join("/"))
172 }
173
174 fn git(&self, args: &[&str]) -> Result<String, GitError> {
175 let out = std::process::Command::new("git")
176 .arg("-C")
177 .arg(&self.repo)
178 .args(args)
179 .output()?;
180 if !out.status.success() {
181 return Err(GitError::Git(
182 String::from_utf8_lossy(&out.stderr).trim().to_string(),
183 ));
184 }
185 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
186 }
187
188 fn push_node(&self, kind: Kind, parent: Option<NodeId>) -> NodeId {
189 let mut nodes = self.nodes.borrow_mut();
190 let id = NodeId(nodes.len() as u64);
191 nodes.push(Node {
192 kind,
193 parent,
194 children: RefCell::new(None),
195 });
196 id
197 }
198
199 fn commit_node(&self, hash: &str) -> NodeId {
201 if let Some(&id) = self.commit_nodes.borrow().get(hash) {
202 return id;
203 }
204 let id = self.push_node(Kind::Commit(hash.to_string()), Some(COMMITS));
205 self.commit_nodes.borrow_mut().insert(hash.to_string(), id);
206 id
207 }
208
209 fn commit_info(&self, hash: &str) -> Option<CommitInfo> {
211 if let Some(i) = self.commits.borrow().get(hash) {
212 return Some(i.clone());
213 }
214 let out = self
215 .git(&[
216 "show",
217 "-s",
218 "--format=%an%x00%ae%x00%at%x00%cn%x00%s%x00%B%x00%T%x00%P%x00%ai",
219 hash,
220 ])
221 .ok()?;
222 let info = parse_info(&out)?;
223 self.commits
224 .borrow_mut()
225 .insert(hash.to_string(), info.clone());
226 Some(info)
227 }
228
229 fn enumerate_commits(&self) -> Vec<NodeId> {
232 if let Some(c) = self.nodes.borrow()[COMMITS.0 as usize]
233 .children
234 .borrow()
235 .as_ref()
236 {
237 return c.clone();
238 }
239 let out = self
240 .git(&[
241 "rev-list",
242 "--all",
243 "--format=%an%x00%ae%x00%at%x00%cn%x00%s%x00%B%x00%T%x00%P%x00%ai%x1e",
244 ])
245 .unwrap_or_default();
246 let mut ids = Vec::new();
247 for (hash, body) in split_commit_records(&out) {
248 if let Some(info) = parse_info(body) {
249 self.commits.borrow_mut().insert(hash.to_string(), info);
250 }
251 ids.push(self.commit_node(hash));
252 }
253 *self.enumerated.borrow_mut() = true;
254 *self.nodes.borrow()[COMMITS.0 as usize]
255 .children
256 .borrow_mut() = Some(ids.clone());
257 ids
258 }
259
260 fn tags_at(&self, hash: &str) -> Vec<String> {
263 if self.tag_map.borrow().is_none() {
264 let out = self
265 .git(&[
266 "for-each-ref",
267 "refs/tags",
268 "--format=%(refname:short)%00%(objectname)%00%(*objectname)",
269 ])
270 .unwrap_or_default();
271 let mut map: HashMap<String, Vec<String>> = HashMap::new();
272 for line in out.lines() {
273 let mut f = line.split('\u{0}');
274 let (Some(name), Some(oid)) = (f.next(), f.next()) else {
275 continue;
276 };
277 let peeled = f.next().filter(|p| !p.is_empty()).unwrap_or(oid);
278 map.entry(peeled.to_string())
279 .or_default()
280 .push(name.to_string());
281 }
282 *self.tag_map.borrow_mut() = Some(map);
283 }
284 self.tag_map
285 .borrow()
286 .as_ref()
287 .and_then(|m| m.get(hash).cloned())
288 .unwrap_or_default()
289 }
290
291 fn refs(&self, dir: NodeId, prefix: &str) -> Vec<NodeId> {
294 if let Some(c) = self.nodes.borrow()[dir.0 as usize]
295 .children
296 .borrow()
297 .as_ref()
298 {
299 return c.clone();
300 }
301 let out = self
302 .git(&[
303 "for-each-ref",
304 prefix,
305 "--format=%(refname:short)%00%(objectname)%00%(*objectname)",
306 ])
307 .unwrap_or_default();
308 let mut ids = Vec::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 deref = f.next().unwrap_or("");
315 let commit = if deref.is_empty() { oid } else { deref };
316 ids.push(self.push_node(
317 Kind::Ref {
318 name: name.to_string(),
319 commit: commit.to_string(),
320 },
321 Some(dir),
322 ));
323 }
324 *self.nodes.borrow()[dir.0 as usize].children.borrow_mut() = Some(ids.clone());
325 ids
326 }
327
328 fn tree_children(&self, parent: NodeId, tree_oid: &str) -> Vec<NodeId> {
330 if let Some(c) = self.nodes.borrow()[parent.0 as usize]
331 .children
332 .borrow()
333 .as_ref()
334 {
335 return c.clone();
336 }
337 let out = self.git(&["ls-tree", "-z", tree_oid]).unwrap_or_default();
338 let mut ids = Vec::new();
339 for entry in out.split('\u{0}') {
343 let Some((meta, name)) = entry.split_once('\t') else {
344 continue;
345 };
346 let mut f = meta.split(' ');
347 let (Some(mode), Some(entry_type), Some(oid)) = (f.next(), f.next(), f.next()) else {
348 continue;
349 };
350 ids.push(self.push_node(
351 Kind::Entry {
352 name: name.to_string(),
353 oid: oid.to_string(),
354 entry_type: entry_type.to_string(),
355 mode: mode.to_string(),
356 },
357 Some(parent),
358 ));
359 }
360 *self.nodes.borrow()[parent.0 as usize].children.borrow_mut() = Some(ids.clone());
361 ids
362 }
363
364 fn changed_paths(&self, hash: &str) -> Vec<String> {
367 if let Some(c) = self.changed.borrow().get(hash) {
368 return c.clone();
369 }
370 let out = self
371 .git(&[
372 "diff-tree",
373 "--root",
374 "--no-commit-id",
375 "--name-only",
376 "-z",
377 "-r",
378 hash,
379 ])
380 .unwrap_or_default();
381 let paths: Vec<String> = out
384 .split('\u{0}')
385 .filter(|p| !p.is_empty())
386 .map(str::to_string)
387 .collect();
388 self.changed
389 .borrow_mut()
390 .insert(hash.to_string(), paths.clone());
391 paths
392 }
393
394 fn entry_context(&self, node: NodeId) -> Option<(String, String)> {
397 let nodes = self.nodes.borrow();
398 let mut parts = Vec::new();
399 let mut cur = Some(node);
400 while let Some(n) = cur {
401 let nd = &nodes[n.0 as usize];
402 match &nd.kind {
403 Kind::Entry { name, .. } => parts.push(name.clone()),
404 Kind::Commit(h) => {
405 parts.reverse();
406 return Some((h.clone(), parts.join("/")));
407 }
408 Kind::Ref { commit, .. } => {
409 parts.reverse();
410 return Some((commit.clone(), parts.join("/")));
411 }
412 _ => return None,
413 }
414 cur = nd.parent;
415 }
416 None
417 }
418
419 fn commit_of(&self, node: NodeId) -> Option<String> {
421 match &self.nodes.borrow()[node.0 as usize].kind {
422 Kind::Commit(h) => Some(h.clone()),
423 Kind::Ref { commit, .. } => Some(commit.clone()),
424 _ => None,
425 }
426 }
427
428 fn commit_ancestor(&self, node: NodeId) -> Option<String> {
431 let mut cur = Some(node);
432 while let Some(n) = cur {
433 if let Some(h) = self.commit_of(n) {
434 return Some(h);
435 }
436 cur = self.nodes.borrow()[n.0 as usize].parent;
437 }
438 None
439 }
440}
441
442fn split_commit_records(out: &str) -> impl Iterator<Item = (&str, &str)> {
449 out.split('\u{1e}').filter_map(|record| {
450 let rest = record.trim_start().strip_prefix("commit ")?;
451 let (hash, body) = rest.split_once('\n')?;
452 Some((hash.trim(), body))
453 })
454}
455
456fn parse_info(body: &str) -> Option<CommitInfo> {
457 let f: Vec<&str> = body.trim_end_matches('\n').split('\u{0}').collect();
458 if f.len() < 8 {
459 return None;
460 }
461 Some(CommitInfo {
462 author: f[0].to_string(),
463 email: f[1].to_string(),
464 date: f[2].parse().unwrap_or(0),
465 date_offset: f.get(8).and_then(|iso| {
466 let tail = iso.trim().rsplit(' ').next()?;
469 let sign = match tail.as_bytes().first()? {
470 b'+' => 1i16,
471 b'-' => -1i16,
472 _ => return None,
473 };
474 let h: i16 = tail.get(1..3)?.parse().ok()?;
475 let m: i16 = tail.get(3..5)?.parse().ok()?;
476 Some(sign * (h * 60 + m))
477 }),
478 committer: f[3].to_string(),
479 subject: f[4].to_string(),
480 message: f[5].trim_end().to_string(),
481 tree: f[6].to_string(),
482 parents: f[7].split_whitespace().map(str::to_string).collect(),
483 })
484}
485
486impl AstAdapter for GitAdapter {
487 fn root(&self) -> NodeId {
488 ROOT
489 }
490
491 fn children(&self, node: NodeId) -> Vec<NodeId> {
492 let kind = self.nodes.borrow()[node.0 as usize].kind.clone();
493 match kind {
494 Kind::Root => {
495 if self.nodes.borrow()[ROOT.0 as usize]
498 .children
499 .borrow()
500 .is_none()
501 {
502 let mut ids = vec![BRANCHES, TAGS, COMMITS];
503 if let Ok(h) = self.git(&["rev-parse", "HEAD"]) {
504 ids.push(self.push_node(
505 Kind::Ref {
506 name: "HEAD".to_string(),
507 commit: h.trim().to_string(),
508 },
509 Some(ROOT),
510 ));
511 }
512 *self.nodes.borrow()[ROOT.0 as usize].children.borrow_mut() = Some(ids);
513 }
514 self.nodes.borrow()[ROOT.0 as usize]
515 .children
516 .borrow()
517 .clone()
518 .unwrap_or_default()
519 }
520 Kind::Dir("branches") => self.refs(BRANCHES, "refs/heads"),
521 Kind::Dir("tags") => self.refs(TAGS, "refs/tags"),
522 Kind::Dir(_) => self.enumerate_commits(),
523 Kind::Ref { commit, .. } | Kind::Commit(commit) => {
524 let Some(info) = self.commit_info(&commit) else {
525 return Vec::new();
526 };
527 self.tree_children(node, &info.tree)
528 }
529 Kind::Entry {
530 oid, entry_type, ..
531 } => {
532 if entry_type == "tree" {
533 self.tree_children(node, &oid)
534 } else {
535 Vec::new()
536 }
537 }
538 }
539 }
540
541 fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
545 if node == COMMITS {
546 if let Some(&id) = self.commit_nodes.borrow().get(name) {
550 return vec![id];
551 }
552 let Ok(out) = self.git(&[
553 "rev-parse",
554 "--verify",
555 "--quiet",
556 &format!("{name}^{{commit}}"),
557 ]) else {
558 return Vec::new();
559 };
560 return vec![self.commit_node(out.trim())];
561 }
562 self.children(node)
563 .into_iter()
564 .filter(|&c| self.name(c).as_deref() == Some(name))
565 .collect()
566 }
567
568 fn name(&self, node: NodeId) -> Option<String> {
569 match &self.nodes.borrow()[node.0 as usize].kind {
570 Kind::Root => None,
571 Kind::Dir(d) => Some(d.to_string()),
572 Kind::Ref { name, .. } => Some(name.clone()),
573 Kind::Commit(h) => Some(h.clone()),
574 Kind::Entry { name, .. } => Some(name.clone()),
575 }
576 }
577
578 fn parent(&self, node: NodeId) -> Option<NodeId> {
579 self.nodes.borrow()[node.0 as usize].parent
580 }
581
582 fn traits(&self, node: NodeId) -> Vec<String> {
584 let nodes = self.nodes.borrow();
585 let t = match &nodes[node.0 as usize].kind {
586 Kind::Root | Kind::Dir(_) => return Vec::new(),
587 Kind::Commit(_) => "commit",
588 Kind::Ref { .. } => match nodes[node.0 as usize].parent {
589 Some(TAGS) => "tag",
590 Some(BRANCHES) => "branch",
591 _ => "commit",
592 },
593 Kind::Entry { entry_type, .. } => {
594 let base = if entry_type == "tree" { "tree" } else { "blob" };
595 let mut out = vec![base.to_string()];
596 drop(nodes);
597 if let Some((hash, path)) = self.entry_context(node) {
601 let prefix = format!("{path}/");
602 if self
603 .changed_paths(&hash)
604 .iter()
605 .any(|p| *p == path || p.starts_with(&prefix))
606 {
607 out.push("changed".to_string());
608 }
609 }
610 return out;
611 }
612 };
613 vec![t.to_string()]
614 }
615
616 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
618 let hash = self.commit_of(node)?;
619 let info = self.commit_info(&hash)?;
620 Some(match name {
621 "author" => Value::Str(info.author),
622 "email" => Value::Str(info.email),
623 "date" => Value::Instant {
624 secs: info.date,
625 nanos: 0,
626 offset_min: info.date_offset,
627 },
628 "committer" => Value::Str(info.committer),
629 "subject" => Value::Str(info.subject),
630 "message" => Value::Str(info.message),
631 "tree" => Value::Str(info.tree),
632 "hash" => Value::Str(hash),
633 "parent" => Value::Str(info.parents.first()?.clone()),
634 "changed" => Value::List(
637 self.changed_paths(&hash)
638 .into_iter()
639 .map(Value::Str)
640 .collect(),
641 ),
642 _ => return None,
643 })
644 }
645
646 fn provenance(&self, node: NodeId) -> quarb::Provenance {
653 quarb::Provenance {
654 source: Some(self.repo.display().to_string()),
655 instant: self
656 .commit_ancestor(node)
657 .and_then(|h| self.commit_info(&h))
658 .map(|info| (info.date, 0, info.date_offset)),
659 dpid: None,
660 }
661 }
662
663 fn default_value(&self, node: NodeId) -> Option<Value> {
665 let (oid, is_blob) = match &self.nodes.borrow()[node.0 as usize].kind {
666 Kind::Entry {
667 oid, entry_type, ..
668 } => (oid.clone(), entry_type == "blob"),
669 _ => return None,
670 };
671 if !is_blob {
672 return None;
673 }
674 self.git(&["cat-file", "blob", &oid]).ok().map(Value::Str)
675 }
676
677 fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
688 &[
689 "short", "hash", "n-parents", "n-changed", "tags", "n-tags", "type", "mode", "size",
690 ]
691 }
692
693 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
694 if let Some(hash) = self.commit_of(node) {
695 return match key {
696 "short" => Some(Value::Str(hash[..7.min(hash.len())].to_string())),
697 "n-parents" => Some(Value::Int(self.commit_info(&hash)?.parents.len() as i64)),
698 "n-changed" => Some(Value::Int(self.changed_paths(&hash).len() as i64)),
699 "tags" => Some(Value::List(
700 self.tags_at(&hash).into_iter().map(Value::Str).collect(),
701 )),
702 "n-tags" => Some(Value::Int(self.tags_at(&hash).len() as i64)),
703 _ => None,
704 };
705 }
706 let (oid, entry_type, mode) = match &self.nodes.borrow()[node.0 as usize].kind {
707 Kind::Entry {
708 oid,
709 entry_type,
710 mode,
711 ..
712 } => (oid.clone(), entry_type.clone(), mode.clone()),
713 _ => return None,
714 };
715 match key {
716 "type" => Some(Value::Str(entry_type)),
717 "mode" => Some(Value::Str(mode)),
718 "hash" => Some(Value::Str(oid)),
719 "size" => self
720 .git(&["cat-file", "-s", &oid])
721 .ok()
722 .and_then(|s| s.trim().parse().ok())
723 .map(Value::bytes),
724 _ => None,
725 }
726 }
727
728 fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
731 if property != "parent" {
732 return None;
733 }
734 let hash = self.commit_of(node)?;
735 let first = self.commit_info(&hash)?.parents.first()?.clone();
736 Some(self.commit_node(&first))
737 }
738
739 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
741 let Some(hash) = self.commit_of(node) else {
742 return Vec::new();
743 };
744 let Some(info) = self.commit_info(&hash) else {
745 return Vec::new();
746 };
747 info.parents
748 .iter()
749 .map(|p| ("parent".to_string(), self.commit_node(p)))
750 .collect()
751 }
752
753 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
756 let Some(hash) = self.commit_of(node) else {
757 return Vec::new();
758 };
759 self.enumerate_commits();
760 let commits = self.commits.borrow();
761 let mut out: Vec<(String, String)> = commits
762 .iter()
763 .filter(|(_, i)| i.parents.contains(&hash))
764 .map(|(h, _)| ("parent".to_string(), h.clone()))
765 .collect();
766 out.sort();
767 out.into_iter()
768 .map(|(l, h)| (l, self.commit_node(&h)))
769 .collect()
770 }
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776
777 #[test]
781 fn record_split_survives_commit_word_in_body() {
782 let out = "commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
783Ann\u{0}ann@example\u{0}1000\u{0}Ann\u{0}\
784Revert commit deadbeef\u{0}\
785Revert commit deadbeef\n\nThis reverts commit deadbeef.\u{0}\
786tttttttttttttttttttttttttttttttttttttttt\u{0}\
787pppppppppppppppppppppppppppppppppppppppp\u{0}\
7882026-07-15 12:00:00 +0100\u{1e}\n\
789commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
790Bo\u{0}bo@example\u{0}2000\u{0}Bo\u{0}second\u{0}second\u{0}\
791tttttttttttttttttttttttttttttttttttttttt\u{0}\u{0}\
7922026-07-15 13:00:00 +0100\u{1e}\n";
793 let recs: Vec<(&str, &str)> = split_commit_records(out).collect();
794 assert_eq!(recs.len(), 2);
795 assert_eq!(recs[0].0, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
796 let a = parse_info(recs[0].1).expect("first record parses");
797 assert_eq!(a.author, "Ann");
798 assert_eq!(a.subject, "Revert commit deadbeef");
799 assert_eq!(
800 a.message,
801 "Revert commit deadbeef\n\nThis reverts commit deadbeef."
802 );
803 assert_eq!(recs[1].0, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
804 let b = parse_info(recs[1].1).expect("second record parses");
805 assert_eq!(b.subject, "second");
806 }
807}