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
429fn split_commit_records(out: &str) -> impl Iterator<Item = (&str, &str)> {
436 out.split('\u{1e}').filter_map(|record| {
437 let rest = record.trim_start().strip_prefix("commit ")?;
438 let (hash, body) = rest.split_once('\n')?;
439 Some((hash.trim(), body))
440 })
441}
442
443fn parse_info(body: &str) -> Option<CommitInfo> {
444 let f: Vec<&str> = body.trim_end_matches('\n').split('\u{0}').collect();
445 if f.len() < 8 {
446 return None;
447 }
448 Some(CommitInfo {
449 author: f[0].to_string(),
450 email: f[1].to_string(),
451 date: f[2].parse().unwrap_or(0),
452 date_offset: f.get(8).and_then(|iso| {
453 let tail = iso.trim().rsplit(' ').next()?;
456 let sign = match tail.as_bytes().first()? {
457 b'+' => 1i16,
458 b'-' => -1i16,
459 _ => return None,
460 };
461 let h: i16 = tail.get(1..3)?.parse().ok()?;
462 let m: i16 = tail.get(3..5)?.parse().ok()?;
463 Some(sign * (h * 60 + m))
464 }),
465 committer: f[3].to_string(),
466 subject: f[4].to_string(),
467 message: f[5].trim_end().to_string(),
468 tree: f[6].to_string(),
469 parents: f[7].split_whitespace().map(str::to_string).collect(),
470 })
471}
472
473impl AstAdapter for GitAdapter {
474 fn root(&self) -> NodeId {
475 ROOT
476 }
477
478 fn children(&self, node: NodeId) -> Vec<NodeId> {
479 let kind = self.nodes.borrow()[node.0 as usize].kind.clone();
480 match kind {
481 Kind::Root => {
482 if self.nodes.borrow()[ROOT.0 as usize]
485 .children
486 .borrow()
487 .is_none()
488 {
489 let mut ids = vec![BRANCHES, TAGS, COMMITS];
490 if let Ok(h) = self.git(&["rev-parse", "HEAD"]) {
491 ids.push(self.push_node(
492 Kind::Ref {
493 name: "HEAD".to_string(),
494 commit: h.trim().to_string(),
495 },
496 Some(ROOT),
497 ));
498 }
499 *self.nodes.borrow()[ROOT.0 as usize].children.borrow_mut() = Some(ids);
500 }
501 self.nodes.borrow()[ROOT.0 as usize]
502 .children
503 .borrow()
504 .clone()
505 .unwrap_or_default()
506 }
507 Kind::Dir("branches") => self.refs(BRANCHES, "refs/heads"),
508 Kind::Dir("tags") => self.refs(TAGS, "refs/tags"),
509 Kind::Dir(_) => self.enumerate_commits(),
510 Kind::Ref { commit, .. } | Kind::Commit(commit) => {
511 let Some(info) = self.commit_info(&commit) else {
512 return Vec::new();
513 };
514 self.tree_children(node, &info.tree)
515 }
516 Kind::Entry {
517 oid, entry_type, ..
518 } => {
519 if entry_type == "tree" {
520 self.tree_children(node, &oid)
521 } else {
522 Vec::new()
523 }
524 }
525 }
526 }
527
528 fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
532 if node == COMMITS {
533 if let Some(&id) = self.commit_nodes.borrow().get(name) {
537 return vec![id];
538 }
539 let Ok(out) = self.git(&[
540 "rev-parse",
541 "--verify",
542 "--quiet",
543 &format!("{name}^{{commit}}"),
544 ]) else {
545 return Vec::new();
546 };
547 return vec![self.commit_node(out.trim())];
548 }
549 self.children(node)
550 .into_iter()
551 .filter(|&c| self.name(c).as_deref() == Some(name))
552 .collect()
553 }
554
555 fn name(&self, node: NodeId) -> Option<String> {
556 match &self.nodes.borrow()[node.0 as usize].kind {
557 Kind::Root => None,
558 Kind::Dir(d) => Some(d.to_string()),
559 Kind::Ref { name, .. } => Some(name.clone()),
560 Kind::Commit(h) => Some(h.clone()),
561 Kind::Entry { name, .. } => Some(name.clone()),
562 }
563 }
564
565 fn parent(&self, node: NodeId) -> Option<NodeId> {
566 self.nodes.borrow()[node.0 as usize].parent
567 }
568
569 fn traits(&self, node: NodeId) -> Vec<String> {
571 let nodes = self.nodes.borrow();
572 let t = match &nodes[node.0 as usize].kind {
573 Kind::Root | Kind::Dir(_) => return Vec::new(),
574 Kind::Commit(_) => "commit",
575 Kind::Ref { .. } => match nodes[node.0 as usize].parent {
576 Some(TAGS) => "tag",
577 Some(BRANCHES) => "branch",
578 _ => "commit",
579 },
580 Kind::Entry { entry_type, .. } => {
581 let base = if entry_type == "tree" { "tree" } else { "blob" };
582 let mut out = vec![base.to_string()];
583 drop(nodes);
584 if let Some((hash, path)) = self.entry_context(node) {
588 let prefix = format!("{path}/");
589 if self
590 .changed_paths(&hash)
591 .iter()
592 .any(|p| *p == path || p.starts_with(&prefix))
593 {
594 out.push("changed".to_string());
595 }
596 }
597 return out;
598 }
599 };
600 vec![t.to_string()]
601 }
602
603 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
605 let hash = self.commit_of(node)?;
606 let info = self.commit_info(&hash)?;
607 Some(match name {
608 "author" => Value::Str(info.author),
609 "email" => Value::Str(info.email),
610 "date" => Value::Instant {
611 secs: info.date,
612 nanos: 0,
613 offset_min: info.date_offset,
614 },
615 "committer" => Value::Str(info.committer),
616 "subject" => Value::Str(info.subject),
617 "message" => Value::Str(info.message),
618 "tree" => Value::Str(info.tree),
619 "hash" => Value::Str(hash),
620 "parent" => Value::Str(info.parents.first()?.clone()),
621 "changed" => Value::List(
624 self.changed_paths(&hash)
625 .into_iter()
626 .map(Value::Str)
627 .collect(),
628 ),
629 _ => return None,
630 })
631 }
632
633 fn default_value(&self, node: NodeId) -> Option<Value> {
635 let (oid, is_blob) = match &self.nodes.borrow()[node.0 as usize].kind {
636 Kind::Entry {
637 oid, entry_type, ..
638 } => (oid.clone(), entry_type == "blob"),
639 _ => return None,
640 };
641 if !is_blob {
642 return None;
643 }
644 self.git(&["cat-file", "blob", &oid]).ok().map(Value::Str)
645 }
646
647 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
651 if let Some(hash) = self.commit_of(node) {
652 return match key {
653 "short" => Some(Value::Str(hash[..7.min(hash.len())].to_string())),
654 "n-parents" => Some(Value::Int(self.commit_info(&hash)?.parents.len() as i64)),
655 "n-changed" => Some(Value::Int(self.changed_paths(&hash).len() as i64)),
656 "tags" => Some(Value::List(
657 self.tags_at(&hash).into_iter().map(Value::Str).collect(),
658 )),
659 "n-tags" => Some(Value::Int(self.tags_at(&hash).len() as i64)),
660 _ => None,
661 };
662 }
663 let (oid, entry_type, mode) = match &self.nodes.borrow()[node.0 as usize].kind {
664 Kind::Entry {
665 oid,
666 entry_type,
667 mode,
668 ..
669 } => (oid.clone(), entry_type.clone(), mode.clone()),
670 _ => return None,
671 };
672 match key {
673 "type" => Some(Value::Str(entry_type)),
674 "mode" => Some(Value::Str(mode)),
675 "hash" => Some(Value::Str(oid)),
676 "size" => self
677 .git(&["cat-file", "-s", &oid])
678 .ok()
679 .and_then(|s| s.trim().parse().ok())
680 .map(Value::bytes),
681 _ => None,
682 }
683 }
684
685 fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
688 if property != "parent" {
689 return None;
690 }
691 let hash = self.commit_of(node)?;
692 let first = self.commit_info(&hash)?.parents.first()?.clone();
693 Some(self.commit_node(&first))
694 }
695
696 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
698 let Some(hash) = self.commit_of(node) else {
699 return Vec::new();
700 };
701 let Some(info) = self.commit_info(&hash) else {
702 return Vec::new();
703 };
704 info.parents
705 .iter()
706 .map(|p| ("parent".to_string(), self.commit_node(p)))
707 .collect()
708 }
709
710 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
713 let Some(hash) = self.commit_of(node) else {
714 return Vec::new();
715 };
716 self.enumerate_commits();
717 let commits = self.commits.borrow();
718 let mut out: Vec<(String, String)> = commits
719 .iter()
720 .filter(|(_, i)| i.parents.contains(&hash))
721 .map(|(h, _)| ("parent".to_string(), h.clone()))
722 .collect();
723 out.sort();
724 out.into_iter()
725 .map(|(l, h)| (l, self.commit_node(&h)))
726 .collect()
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733
734 #[test]
738 fn record_split_survives_commit_word_in_body() {
739 let out = "commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
740Ann\u{0}ann@example\u{0}1000\u{0}Ann\u{0}\
741Revert commit deadbeef\u{0}\
742Revert commit deadbeef\n\nThis reverts commit deadbeef.\u{0}\
743tttttttttttttttttttttttttttttttttttttttt\u{0}\
744pppppppppppppppppppppppppppppppppppppppp\u{0}\
7452026-07-15 12:00:00 +0100\u{1e}\n\
746commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
747Bo\u{0}bo@example\u{0}2000\u{0}Bo\u{0}second\u{0}second\u{0}\
748tttttttttttttttttttttttttttttttttttttttt\u{0}\u{0}\
7492026-07-15 13:00:00 +0100\u{1e}\n";
750 let recs: Vec<(&str, &str)> = split_commit_records(out).collect();
751 assert_eq!(recs.len(), 2);
752 assert_eq!(recs[0].0, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
753 let a = parse_info(recs[0].1).expect("first record parses");
754 assert_eq!(a.author, "Ann");
755 assert_eq!(a.subject, "Revert commit deadbeef");
756 assert_eq!(
757 a.message,
758 "Revert commit deadbeef\n\nThis reverts commit deadbeef."
759 );
760 assert_eq!(recs[1].0, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
761 let b = parse_info(recs[1].1).expect("second record parses");
762 assert_eq!(b.subject, "second");
763 }
764}