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    /// The mount's rule at the source boundary: a blob's text
99    /// decodes under it (`?encoding=`, else strict UTF-8).
100    decoding: quarb::source::SourceDecoding,
101    nodes: RefCell<Vec<Node>>,
102    commits: RefCell<HashMap<String, CommitInfo>>,
103    /// hash → its `/commits/<hash>` node.
104    commit_nodes: RefCell<HashMap<String, NodeId>>,
105    /// Whether `/commits` has been enumerated (rev-list --all).
106    enumerated: RefCell<bool>,
107    /// hash → the paths its diff (vs first parent) touches.
108    changed: RefCell<HashMap<String, Vec<String>>>,
109    /// commit hash → the tag names pointing at it (lazy).
110    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    /// Open the repository at `path` (any directory inside it).
120    pub fn open(path: &std::path::Path) -> Result<Self, GitError> {
121        Self::open_with_decoding(path, quarb::source::SourceDecoding::default())
122    }
123
124    /// [`open`](Self::open) under a mount's source-boundary rule:
125    /// blobs decode by `?encoding=`, a byte-order mark, else strict
126    /// UTF-8 (a blob that decodes under none has no text).
127    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        // Probe: is this a repository at all?
163        adapter.git(&["rev-parse", "--git-dir"])?;
164        Ok(adapter)
165    }
166
167    /// A human-readable locator: `/commits/<short>/path`,
168    /// `/branches/<name>/path`, ...
169    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    /// Run git for structured output (headers, trees, refs): paths
189    /// come unquoted (`core.quotepath=off`) and the output must be
190    /// UTF-8 — the commands that print messages take
191    /// `--encoding=UTF-8`, so git transcodes a commit written in
192    /// another declared encoding. Output that still is not UTF-8
193    /// is an error, never replaced.
194    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    /// Run git for raw bytes (a blob): the caller decodes.
206    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    /// The `/commits/<hash>` node for a full hash, interning it.
235    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    /// A commit's parsed header, fetched on first touch.
245    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    /// Enumerate every commit reachable from any ref: one
266    /// `rev-list --all` with the header format, parsed in bulk.
267    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    /// The tag names pointing at `hash` (annotated tags
298    /// dereferenced), from one cached `for-each-ref` sweep.
299    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    /// The refs under `refs/heads` or `refs/tags` (tags
329    /// dereferenced to their commits).
330    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    /// The entries of a tree object, as child nodes of `parent`.
366    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        // "<mode> <type> <oid>\t<name>" records, NUL-terminated by
377        // `-z` so names arrive raw — never C-quoted the way the
378        // newline-delimited default renders non-ASCII paths.
379        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    /// The paths a commit's diff touches, relative to its first
402    /// parent (the whole tree for a root commit), computed once.
403    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        // `-z`: NUL-separated, raw paths (never C-quoted), so they
419        // compare equal to the raw names from `ls-tree -z`.
420        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    /// The commit a tree entry belongs to and the entry's path
432    /// within it.
433    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    /// The commit hash a node stands for (commits and ref aliases).
457    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    /// The nearest commit-backed ancestor, the node itself included:
466    /// a tree entry answers through the commit it was reached under.
467    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
479/// Split `rev-list --format=…%x1e` output into `(hash, body)`
480/// records. Each commit's format expansion is terminated by the
481/// ASCII Record Separator (`%x1e`), so a literal `commit ` line
482/// inside a message body (`%s`/`%B`) can never split a record;
483/// the leading `commit <hash>` header of each record supplies the
484/// hash.
485fn 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            // `%ai` ends `±HHMM`; the offset rides the instant for
504            // display only.
505            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                // branches, tags, commits — plus HEAD as a ref
533                // alias, interned once.
534                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    /// Literal names under `/commits` go straight through
579    /// `rev-parse` — unique prefixes, `HEAD~2`, `v1.0^{}` — with
580    /// no enumeration.
581    fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
582        if node == COMMITS {
583            // A known full hash answers from the intern map;
584            // anything else (prefixes, HEAD~2, tag^{}) goes
585            // through rev-parse — enumeration never required.
586            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    /// `<commit>`, `<branch>`, `<tag>`, `<tree>`, `<blob>`.
620    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                // <changed>: this path is in its commit's diff
635                // (blobs by exact path, trees when any descendant
636                // changed).
637                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    /// Commit header fields (ref aliases answer for their commit).
654    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            // The paths this commit's diff touches (vs its first
672            // parent) — deletions included, unlike the tree view.
673            "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    /// Data provenance: the repository path as the source; the
684    /// nearest commit-backed ancestor's author date as the instant
685    /// (a tree entry answers "this repo, as of that commit" — the
686    /// same instant `::date` mints). The structural dirs and the
687    /// root have no commit ancestor and answer source only. No
688    /// dpid — the commit hash stays on `::hash` / `::::short`.
689    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    /// A blob's content (text, lossily decoded).
704    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        // The blob's bytes under the mount's rule; one that decodes
715        // under none has no text (the leaf stays a leaf).
716        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    /// Commits: `;;;short`, `;;;n-parents`, `;;;tags`,
723    /// `;;;n-tags`. Entries: `;;;type`, `;;;mode`, `;;;size`,
724    /// `;;;hash`.
725    /// Ruling #29: this adapter's property surface is closed — a
726    /// commit object cannot grow fields — so its annotations
727    /// answer at `::` too. `hash` is in the list on purpose:
728    /// commits expose the oid as a property and tree entries as
729    /// metadata, but it is one concept — this node's object id —
730    /// and aliasing is what makes `::hash` answer everywhere in
731    /// the arbor instead of falling silently null on entries.
732    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    /// `::parent-->` — the first parent (the hint is unused: the
782    /// target is inherently a commit).
783    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    /// Every parent is an outgoing `parent` edge (merges fan out).
793    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    /// A commit's reference is its parent: the bare arrows
807    /// (`/HEAD-->`, `/HEAD<--`) read it.
808    fn ref_property(&self, node: NodeId) -> Option<String> {
809        self.commit_of(node).map(|_| "parent".to_string())
810    }
811
812    /// `<--(::parent)` from the enumeration, with no walk: the
813    /// commits whose `parent` resolves here — `::parent-->` follows
814    /// the FIRST parent, so a merge counts under its first parent
815    /// only. Any other property has no referrers; with no property
816    /// named the question stays the engine's.
817    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        // With no property named: the commit's reference property.
825        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    /// The commits whose parent is here (the children — served
840    /// from the enumeration, so this loads the commit list).
841    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    // Regression: a commit whose subject and body contain the word
864    // "commit " must not corrupt enumeration. Records are delimited
865    // by the trailing %x1e sentinel, not by "commit " in the body.
866    #[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}