Skip to main content

quarb_git/
lib.rs

1//! Git repository adapter for the Quarb query engine.
2//!
3//! A repository is an arbor with a real DAG for its crosslink
4//! fabric. The root exposes four names:
5//!
6//! - `/branches/<name>`, `/tags/<name>` — the refs. A ref node is
7//!   an *alias for its commit*: same properties, same children.
8//! - `/HEAD` — the checked-out commit, same alias treatment.
9//! - `/commits/<hash>` — every commit. Navigating by literal name
10//!   accepts anything `git rev-parse` does (unique prefixes,
11//!   `HEAD~2`, `v1.0^{}`), without enumerating; enumeration
12//!   (`/commits/*`) lists every commit reachable from any ref, in
13//!   reverse chronological order, batched through one
14//!   `rev-list --all`.
15//!
16//! A commit's properties are its header (`::author`, `::email`,
17//! `::date` as an instant (the author date, offset preserved),
18//! `::committer`, `::subject`,
19//! `::message`, `::parent` — the first parent's hash); its
20//! *children are its tree* — descend `/branches/master/src/lib.rs`
21//! and the blob's content is the node's value (`::`), giving
22//! time-travel file access at any commit. Tree entries carry
23//! `;;;type`, `;;;mode`, `;;;size`, and `;;;hash`.
24//!
25//! References are inherent, no schema needed: `::parent~>`
26//! resolves to the first parent, `->parent` enumerates all
27//! parents (merges fan out), and `<-parent` finds the commits
28//! that point *here* (children — served from the enumeration, so
29//! it loads the commit list). Traits name what a node is:
30//! `<commit>`, `<branch>`, `<tag>`, `<tree>`, `<blob>`.
31//!
32//! Everything is read through git plumbing over subprocess
33//! (`rev-list`, `rev-parse`, `ls-tree`, `cat-file`, ...) — no
34//! libgit2, no new dependencies — lazily, one object on first
35//! touch, cached for the adapter's lifetime. The adapter never
36//! writes.
37
38use quarb::{AstAdapter, NodeId, Value};
39use std::cell::RefCell;
40use std::collections::HashMap;
41use std::path::PathBuf;
42
43/// An error opening or reading a repository.
44#[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/// A commit's parsed header.
53#[derive(Clone)]
54struct CommitInfo {
55    author: String,
56    email: String,
57    date: i64,
58    /// The author date's UTC offset in minutes (from `%ai`),
59    /// preserved for display on the minted instant.
60    date_offset: Option<i16>,
61    committer: String,
62    subject: String,
63    message: String,
64    tree: String,
65    parents: Vec<String>,
66}
67
68/// What a node is.
69#[derive(Clone)]
70enum Kind {
71    Root,
72    /// `branches`, `tags`, or `commits`.
73    Dir(&'static str),
74    /// A ref (branch, tag, or HEAD): an alias for its commit.
75    Ref {
76        name: String,
77        commit: String,
78    },
79    Commit(String),
80    /// A tree entry: a subtree or a blob.
81    Entry {
82        name: String,
83        oid: String,
84        entry_type: String, // "tree" | "blob"
85        mode: String,
86    },
87}
88
89struct Node {
90    kind: Kind,
91    parent: Option<NodeId>,
92    children: RefCell<Option<Vec<NodeId>>>,
93}
94
95/// A git repository, exposed as an arbor.
96pub struct GitAdapter {
97    repo: PathBuf,
98    nodes: RefCell<Vec<Node>>,
99    commits: RefCell<HashMap<String, CommitInfo>>,
100    /// hash → its `/commits/<hash>` node.
101    commit_nodes: RefCell<HashMap<String, NodeId>>,
102    /// Whether `/commits` has been enumerated (rev-list --all).
103    enumerated: RefCell<bool>,
104    /// hash → the paths its diff (vs first parent) touches.
105    changed: RefCell<HashMap<String, Vec<String>>>,
106    /// commit hash → the tag names pointing at it (lazy).
107    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    /// Open the repository at `path` (any directory inside it).
117    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        // Probe: is this a repository at all?
149        adapter.git(&["rev-parse", "--git-dir"])?;
150        Ok(adapter)
151    }
152
153    /// A human-readable locator: `/commits/<short>/path`,
154    /// `/branches/<name>/path`, ...
155    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    /// The `/commits/<hash>` node for a full hash, interning it.
200    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    /// A commit's parsed header, fetched on first touch.
210    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    /// Enumerate every commit reachable from any ref: one
230    /// `rev-list --all` with the header format, parsed in bulk.
231    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    /// The tag names pointing at `hash` (annotated tags
261    /// dereferenced), from one cached `for-each-ref` sweep.
262    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    /// The refs under `refs/heads` or `refs/tags` (tags
292    /// dereferenced to their commits).
293    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    /// The entries of a tree object, as child nodes of `parent`.
329    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        // "<mode> <type> <oid>\t<name>" records, NUL-terminated by
340        // `-z` so names arrive raw — never C-quoted the way the
341        // newline-delimited default renders non-ASCII paths.
342        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    /// The paths a commit's diff touches, relative to its first
365    /// parent (the whole tree for a root commit), computed once.
366    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        // `-z`: NUL-separated, raw paths (never C-quoted), so they
382        // compare equal to the raw names from `ls-tree -z`.
383        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    /// The commit a tree entry belongs to and the entry's path
395    /// within it.
396    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    /// The commit hash a node stands for (commits and ref aliases).
420    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    /// The nearest commit-backed ancestor, the node itself included:
429    /// a tree entry answers through the commit it was reached under.
430    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
442/// Split `rev-list --format=…%x1e` output into `(hash, body)`
443/// records. Each commit's format expansion is terminated by the
444/// ASCII Record Separator (`%x1e`), so a literal `commit ` line
445/// inside a message body (`%s`/`%B`) can never split a record;
446/// the leading `commit <hash>` header of each record supplies the
447/// hash.
448fn 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            // `%ai` ends `±HHMM`; the offset rides the instant for
467            // display only.
468            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                // branches, tags, commits — plus HEAD as a ref
496                // alias, interned once.
497                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    /// Literal names under `/commits` go straight through
542    /// `rev-parse` — unique prefixes, `HEAD~2`, `v1.0^{}` — with
543    /// no enumeration.
544    fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
545        if node == COMMITS {
546            // A known full hash answers from the intern map;
547            // anything else (prefixes, HEAD~2, tag^{}) goes
548            // through rev-parse — enumeration never required.
549            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    /// `<commit>`, `<branch>`, `<tag>`, `<tree>`, `<blob>`.
583    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                // <changed>: this path is in its commit's diff
598                // (blobs by exact path, trees when any descendant
599                // changed).
600                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    /// Commit header fields (ref aliases answer for their commit).
617    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            // The paths this commit's diff touches (vs its first
635            // parent) — deletions included, unlike the tree view.
636            "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    /// Data provenance: the repository path as the source; the
647    /// nearest commit-backed ancestor's author date as the instant
648    /// (a tree entry answers "this repo, as of that commit" — the
649    /// same instant `::date` mints). The structural dirs and the
650    /// root have no commit ancestor and answer source only. No
651    /// dpid — the commit hash stays on `::hash` / `::::short`.
652    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    /// A blob's content (text, lossily decoded).
664    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    /// Commits: `;;;short`, `;;;n-parents`, `;;;tags`,
678    /// `;;;n-tags`. Entries: `;;;type`, `;;;mode`, `;;;size`,
679    /// `;;;hash`.
680    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
681        if let Some(hash) = self.commit_of(node) {
682            return match key {
683                "short" => Some(Value::Str(hash[..7.min(hash.len())].to_string())),
684                "n-parents" => Some(Value::Int(self.commit_info(&hash)?.parents.len() as i64)),
685                "n-changed" => Some(Value::Int(self.changed_paths(&hash).len() as i64)),
686                "tags" => Some(Value::List(
687                    self.tags_at(&hash).into_iter().map(Value::Str).collect(),
688                )),
689                "n-tags" => Some(Value::Int(self.tags_at(&hash).len() as i64)),
690                _ => None,
691            };
692        }
693        let (oid, entry_type, mode) = match &self.nodes.borrow()[node.0 as usize].kind {
694            Kind::Entry {
695                oid,
696                entry_type,
697                mode,
698                ..
699            } => (oid.clone(), entry_type.clone(), mode.clone()),
700            _ => return None,
701        };
702        match key {
703            "type" => Some(Value::Str(entry_type)),
704            "mode" => Some(Value::Str(mode)),
705            "hash" => Some(Value::Str(oid)),
706            "size" => self
707                .git(&["cat-file", "-s", &oid])
708                .ok()
709                .and_then(|s| s.trim().parse().ok())
710                .map(Value::bytes),
711            _ => None,
712        }
713    }
714
715    /// `::parent~>` — the first parent (the hint is unused: the
716    /// target is inherently a commit).
717    fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
718        if property != "parent" {
719            return None;
720        }
721        let hash = self.commit_of(node)?;
722        let first = self.commit_info(&hash)?.parents.first()?.clone();
723        Some(self.commit_node(&first))
724    }
725
726    /// Every parent is an outgoing `parent` edge (merges fan out).
727    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
728        let Some(hash) = self.commit_of(node) else {
729            return Vec::new();
730        };
731        let Some(info) = self.commit_info(&hash) else {
732            return Vec::new();
733        };
734        info.parents
735            .iter()
736            .map(|p| ("parent".to_string(), self.commit_node(p)))
737            .collect()
738    }
739
740    /// The commits whose parent is here (the children — served
741    /// from the enumeration, so this loads the commit list).
742    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
743        let Some(hash) = self.commit_of(node) else {
744            return Vec::new();
745        };
746        self.enumerate_commits();
747        let commits = self.commits.borrow();
748        let mut out: Vec<(String, String)> = commits
749            .iter()
750            .filter(|(_, i)| i.parents.contains(&hash))
751            .map(|(h, _)| ("parent".to_string(), h.clone()))
752            .collect();
753        out.sort();
754        out.into_iter()
755            .map(|(l, h)| (l, self.commit_node(&h)))
756            .collect()
757    }
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    // Regression: a commit whose subject and body contain the word
765    // "commit " must not corrupt enumeration. Records are delimited
766    // by the trailing %x1e sentinel, not by "commit " in the body.
767    #[test]
768    fn record_split_survives_commit_word_in_body() {
769        let out = "commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
770Ann\u{0}ann@example\u{0}1000\u{0}Ann\u{0}\
771Revert commit deadbeef\u{0}\
772Revert commit deadbeef\n\nThis reverts commit deadbeef.\u{0}\
773tttttttttttttttttttttttttttttttttttttttt\u{0}\
774pppppppppppppppppppppppppppppppppppppppp\u{0}\
7752026-07-15 12:00:00 +0100\u{1e}\n\
776commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
777Bo\u{0}bo@example\u{0}2000\u{0}Bo\u{0}second\u{0}second\u{0}\
778tttttttttttttttttttttttttttttttttttttttt\u{0}\u{0}\
7792026-07-15 13:00:00 +0100\u{1e}\n";
780        let recs: Vec<(&str, &str)> = split_commit_records(out).collect();
781        assert_eq!(recs.len(), 2);
782        assert_eq!(recs[0].0, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
783        let a = parse_info(recs[0].1).expect("first record parses");
784        assert_eq!(a.author, "Ann");
785        assert_eq!(a.subject, "Revert commit deadbeef");
786        assert_eq!(
787            a.message,
788            "Revert commit deadbeef\n\nThis reverts commit deadbeef."
789        );
790        assert_eq!(recs[1].0, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
791        let b = parse_info(recs[1].1).expect("second record parses");
792        assert_eq!(b.subject, "second");
793    }
794}