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    /// commit hash → the commits that list it as a parent (built
112    /// once from the enumeration; `<--` over every commit is one
113    /// pass, not one scan of the list per commit).
114    children_of: RefCell<Option<HashMap<String, Vec<String>>>>,
115    /// One `git cat-file --batch` (contents) and one `--batch-check`
116    /// (sizes) kept open: a blob per request on a pipe, not a git
117    /// process per blob.
118    blobs: RefCell<Option<Batch>>,
119    sizes: RefCell<Option<Batch>>,
120}
121
122/// A `git cat-file --batch[-check]` child on a pipe.
123struct Batch {
124    child: std::process::Child,
125    stdin: std::process::ChildStdin,
126    out: std::io::BufReader<std::process::ChildStdout>,
127}
128
129impl Batch {
130    fn spawn(repo: &std::path::Path, check: bool) -> std::io::Result<Batch> {
131        let mut child = std::process::Command::new("git")
132            .arg("-C")
133            .arg(repo)
134            .args(["cat-file", if check { "--batch-check" } else { "--batch" }])
135            .stdin(std::process::Stdio::piped())
136            .stdout(std::process::Stdio::piped())
137            .stderr(std::process::Stdio::null())
138            .spawn()?;
139        let stdin = child.stdin.take().expect("piped");
140        let out = std::io::BufReader::new(child.stdout.take().expect("piped"));
141        Ok(Batch { child, stdin, out })
142    }
143
144    /// Ask for `oid`: the header line (`<oid> <type> <size>`), or
145    /// `None` when the object is missing.
146    fn header(&mut self, oid: &str) -> std::io::Result<Option<u64>> {
147        use std::io::{BufRead, Write};
148        writeln!(self.stdin, "{oid}")?;
149        self.stdin.flush()?;
150        let mut line = String::new();
151        self.out.read_line(&mut line)?;
152        let mut parts = line.split_whitespace();
153        let _oid = parts.next();
154        match (parts.next(), parts.next()) {
155            (Some("missing"), _) | (None, _) => Ok(None),
156            (Some(_), Some(size)) => Ok(size.parse().ok()),
157            _ => Ok(None),
158        }
159    }
160
161    /// The object's bytes (after `header`, on a `--batch` child).
162    fn body(&mut self, size: u64) -> std::io::Result<Vec<u8>> {
163        use std::io::Read;
164        let mut buf = vec![0u8; size as usize];
165        self.out.read_exact(&mut buf)?;
166        let mut nl = [0u8; 1];
167        self.out.read_exact(&mut nl)?;
168        Ok(buf)
169    }
170}
171
172impl Drop for GitAdapter {
173    fn drop(&mut self) {
174        for b in [self.blobs.get_mut(), self.sizes.get_mut()] {
175            if let Some(b) = b {
176                let _ = b.child.kill();
177                let _ = b.child.wait();
178            }
179        }
180    }
181}
182
183const ROOT: NodeId = NodeId(0);
184const BRANCHES: NodeId = NodeId(1);
185const TAGS: NodeId = NodeId(2);
186const COMMITS: NodeId = NodeId(3);
187
188impl GitAdapter {
189    /// Open the repository at `path` (any directory inside it).
190    pub fn open(path: &std::path::Path) -> Result<Self, GitError> {
191        Self::open_with_decoding(path, quarb::source::SourceDecoding::default())
192    }
193
194    /// [`open`](Self::open) under a mount's source-boundary rule:
195    /// blobs decode by `?encoding=`, a byte-order mark, else strict
196    /// UTF-8 (a blob that decodes under none has no text).
197    pub fn open_with_decoding(
198        path: &std::path::Path,
199        decoding: quarb::source::SourceDecoding,
200    ) -> Result<Self, GitError> {
201        let adapter = GitAdapter {
202            repo: path.to_path_buf(),
203            decoding,
204            nodes: RefCell::new(vec![
205                Node {
206                    kind: Kind::Root,
207                    parent: None,
208                    children: RefCell::new(None),
209                },
210                Node {
211                    kind: Kind::Dir("branches"),
212                    parent: Some(ROOT),
213                    children: RefCell::new(None),
214                },
215                Node {
216                    kind: Kind::Dir("tags"),
217                    parent: Some(ROOT),
218                    children: RefCell::new(None),
219                },
220                Node {
221                    kind: Kind::Dir("commits"),
222                    parent: Some(ROOT),
223                    children: RefCell::new(None),
224                },
225            ]),
226            commits: RefCell::new(HashMap::new()),
227            commit_nodes: RefCell::new(HashMap::new()),
228            enumerated: RefCell::new(false),
229            changed: RefCell::new(HashMap::new()),
230            tag_map: RefCell::new(None),
231            children_of: RefCell::new(None),
232            blobs: RefCell::new(None),
233            sizes: RefCell::new(None),
234        };
235        // Probe: is this a repository at all?
236        adapter.git(&["rev-parse", "--git-dir"])?;
237        Ok(adapter)
238    }
239
240    /// A human-readable locator: `/commits/<short>/path`,
241    /// `/branches/<name>/path`, ...
242    pub fn locator(&self, node: NodeId) -> String {
243        let nodes = self.nodes.borrow();
244        let mut parts = Vec::new();
245        let mut cur = Some(node);
246        while let Some(n) = cur {
247            let nd = &nodes[n.0 as usize];
248            match &nd.kind {
249                Kind::Root => {}
250                Kind::Dir(d) => parts.push(d.to_string()),
251                Kind::Ref { name, .. } => parts.push(name.clone()),
252                Kind::Commit(h) => parts.push(h[..7.min(h.len())].to_string()),
253                Kind::Entry { name, .. } => parts.push(name.clone()),
254            }
255            cur = nd.parent;
256        }
257        parts.reverse();
258        format!("/{}", parts.join("/"))
259    }
260
261    /// Run git for structured output (headers, trees, refs): paths
262    /// come unquoted (`core.quotepath=off`) and the output must be
263    /// UTF-8 — the commands that print messages take
264    /// `--encoding=UTF-8`, so git transcodes a commit written in
265    /// another declared encoding. Output that still is not UTF-8
266    /// is an error, never replaced.
267    fn git(&self, args: &[&str]) -> Result<String, GitError> {
268        let out = self.git_bytes(args)?;
269        String::from_utf8(out).map_err(|e| {
270            GitError::Git(format!(
271                "git {}: output is not UTF-8 at byte {}",
272                args.first().unwrap_or(&""),
273                e.utf8_error().valid_up_to()
274            ))
275        })
276    }
277
278    /// A blob's bytes through the open `cat-file --batch` pipe; a
279    /// pipe that fails is dropped and the one-shot command answers.
280    fn blob_bytes(&self, oid: &str) -> Result<Vec<u8>, GitError> {
281        let batched = (|| -> std::io::Result<Option<Vec<u8>>> {
282            let mut slot = self.blobs.borrow_mut();
283            if slot.is_none() {
284                *slot = Some(Batch::spawn(&self.repo, false)?);
285            }
286            let b = slot.as_mut().expect("spawned");
287            match b.header(oid)? {
288                Some(size) => b.body(size).map(Some),
289                None => Ok(None),
290            }
291        })();
292        match batched {
293            Ok(Some(bytes)) => Ok(bytes),
294            Ok(None) => Err(GitError::Git(format!("blob {oid}: missing"))),
295            Err(_) => {
296                *self.blobs.borrow_mut() = None;
297                self.git_bytes(&["cat-file", "blob", oid])
298            }
299        }
300    }
301
302    /// A blob's size through the open `cat-file --batch-check` pipe,
303    /// with the same fallback.
304    fn blob_size(&self, oid: &str) -> Option<u64> {
305        let batched = (|| -> std::io::Result<Option<u64>> {
306            let mut slot = self.sizes.borrow_mut();
307            if slot.is_none() {
308                *slot = Some(Batch::spawn(&self.repo, true)?);
309            }
310            slot.as_mut().expect("spawned").header(oid)
311        })();
312        match batched {
313            Ok(size) => size,
314            Err(_) => {
315                *self.sizes.borrow_mut() = None;
316                self.git(&["cat-file", "-s", oid])
317                    .ok()
318                    .and_then(|s| s.trim().parse().ok())
319            }
320        }
321    }
322
323    /// The commits that list `hash` as a parent, from an index
324    /// built once over the enumeration.
325    fn children_of(&self, hash: &str) -> Vec<String> {
326        self.enumerate_commits();
327        if self.children_of.borrow().is_none() {
328            let mut index: HashMap<String, Vec<String>> = HashMap::new();
329            for (h, info) in self.commits.borrow().iter() {
330                for p in &info.parents {
331                    index.entry(p.clone()).or_default().push(h.clone());
332                }
333            }
334            for v in index.values_mut() {
335                v.sort();
336            }
337            *self.children_of.borrow_mut() = Some(index);
338        }
339        self.children_of
340            .borrow()
341            .as_ref()
342            .and_then(|i| i.get(hash).cloned())
343            .unwrap_or_default()
344    }
345
346    /// Run git for raw bytes (a blob): the caller decodes.
347    fn git_bytes(&self, args: &[&str]) -> Result<Vec<u8>, GitError> {
348        let out = std::process::Command::new("git")
349            .arg("-C")
350            .arg(&self.repo)
351            .args(["-c", "core.quotepath=off"])
352            .args(args)
353            .output()?;
354        if !out.status.success() {
355            return Err(GitError::Git(
356                String::from_utf8(out.stderr)
357                    .map(|s| s.trim().to_string())
358                    .unwrap_or_else(|_| "(git's message is not UTF-8)".to_string()),
359            ));
360        }
361        Ok(out.stdout)
362    }
363
364    fn push_node(&self, kind: Kind, parent: Option<NodeId>) -> NodeId {
365        let mut nodes = self.nodes.borrow_mut();
366        let id = NodeId(nodes.len() as u64);
367        nodes.push(Node {
368            kind,
369            parent,
370            children: RefCell::new(None),
371        });
372        id
373    }
374
375    /// The `/commits/<hash>` node for a full hash, interning it.
376    fn commit_node(&self, hash: &str) -> NodeId {
377        if let Some(&id) = self.commit_nodes.borrow().get(hash) {
378            return id;
379        }
380        let id = self.push_node(Kind::Commit(hash.to_string()), Some(COMMITS));
381        self.commit_nodes.borrow_mut().insert(hash.to_string(), id);
382        id
383    }
384
385    /// A commit's parsed header, fetched on first touch.
386    fn commit_info(&self, hash: &str) -> Option<CommitInfo> {
387        if let Some(i) = self.commits.borrow().get(hash) {
388            return Some(i.clone());
389        }
390        let out = self
391            .git(&[
392                "show",
393                "-s",
394                "--encoding=UTF-8",
395                "--format=%an%x00%ae%x00%at%x00%cn%x00%s%x00%B%x00%T%x00%P%x00%ai",
396                hash,
397            ])
398            .ok()?;
399        let info = parse_info(&out)?;
400        self.commits
401            .borrow_mut()
402            .insert(hash.to_string(), info.clone());
403        Some(info)
404    }
405
406    /// Enumerate every commit reachable from any ref: one
407    /// `rev-list --all` with the header format, parsed in bulk.
408    /// The first `k` commits of `rev-list --all`, for a hop that keeps
409    /// a prefix (the pushdown scanning round, 2026-11-06): the walk
410    /// stops at k, the nodes keyed by hash so the full enumeration
411    /// finds them again; nothing cached as the listing.
412    fn commits_prefix(&self, k: usize) -> Vec<NodeId> {
413        let n = k.to_string();
414        let out = self
415            .git(&[
416                "rev-list",
417                "--encoding=UTF-8",
418                "--all",
419                "-n",
420                &n,
421                "--format=%an%x00%ae%x00%at%x00%cn%x00%s%x00%B%x00%T%x00%P%x00%ai%x1e",
422            ])
423            .unwrap_or_default();
424        let mut ids = Vec::new();
425        for (hash, body) in split_commit_records(&out) {
426            if let Some(info) = parse_info(body) {
427                self.commits.borrow_mut().insert(hash.to_string(), info);
428            }
429            ids.push(self.commit_node(hash));
430        }
431        ids
432    }
433
434    fn enumerate_commits(&self) -> Vec<NodeId> {
435        if let Some(c) = self.nodes.borrow()[COMMITS.0 as usize]
436            .children
437            .borrow()
438            .as_ref()
439        {
440            return c.clone();
441        }
442        let out = self
443            .git(&[
444                "rev-list",
445                "--encoding=UTF-8",
446                "--all",
447                "--format=%an%x00%ae%x00%at%x00%cn%x00%s%x00%B%x00%T%x00%P%x00%ai%x1e",
448            ])
449            .unwrap_or_default();
450        let mut ids = Vec::new();
451        for (hash, body) in split_commit_records(&out) {
452            if let Some(info) = parse_info(body) {
453                self.commits.borrow_mut().insert(hash.to_string(), info);
454            }
455            ids.push(self.commit_node(hash));
456        }
457        *self.enumerated.borrow_mut() = true;
458        *self.nodes.borrow()[COMMITS.0 as usize]
459            .children
460            .borrow_mut() = Some(ids.clone());
461        ids
462    }
463
464    /// The tag names pointing at `hash` (annotated tags
465    /// dereferenced), from one cached `for-each-ref` sweep.
466    fn tags_at(&self, hash: &str) -> Vec<String> {
467        if self.tag_map.borrow().is_none() {
468            let out = self
469                .git(&[
470                    "for-each-ref",
471                    "refs/tags",
472                    "--format=%(refname:short)%00%(objectname)%00%(*objectname)",
473                ])
474                .unwrap_or_default();
475            let mut map: HashMap<String, Vec<String>> = HashMap::new();
476            for line in out.lines() {
477                let mut f = line.split('\u{0}');
478                let (Some(name), Some(oid)) = (f.next(), f.next()) else {
479                    continue;
480                };
481                let peeled = f.next().filter(|p| !p.is_empty()).unwrap_or(oid);
482                map.entry(peeled.to_string())
483                    .or_default()
484                    .push(name.to_string());
485            }
486            *self.tag_map.borrow_mut() = Some(map);
487        }
488        self.tag_map
489            .borrow()
490            .as_ref()
491            .and_then(|m| m.get(hash).cloned())
492            .unwrap_or_default()
493    }
494
495    /// The refs under `refs/heads` or `refs/tags` (tags
496    /// dereferenced to their commits).
497    fn refs(&self, dir: NodeId, prefix: &str) -> Vec<NodeId> {
498        if let Some(c) = self.nodes.borrow()[dir.0 as usize]
499            .children
500            .borrow()
501            .as_ref()
502        {
503            return c.clone();
504        }
505        let out = self
506            .git(&[
507                "for-each-ref",
508                prefix,
509                "--format=%(refname:short)%00%(objectname)%00%(*objectname)",
510            ])
511            .unwrap_or_default();
512        let mut ids = Vec::new();
513        for line in out.lines() {
514            let mut f = line.split('\u{0}');
515            let (Some(name), Some(oid)) = (f.next(), f.next()) else {
516                continue;
517            };
518            let deref = f.next().unwrap_or("");
519            let commit = if deref.is_empty() { oid } else { deref };
520            ids.push(self.push_node(
521                Kind::Ref {
522                    name: name.to_string(),
523                    commit: commit.to_string(),
524                },
525                Some(dir),
526            ));
527        }
528        *self.nodes.borrow()[dir.0 as usize].children.borrow_mut() = Some(ids.clone());
529        ids
530    }
531
532    /// The entries of a tree object, as child nodes of `parent`.
533    fn tree_children(&self, parent: NodeId, tree_oid: &str) -> Vec<NodeId> {
534        if let Some(c) = self.nodes.borrow()[parent.0 as usize]
535            .children
536            .borrow()
537            .as_ref()
538        {
539            return c.clone();
540        }
541        let out = self.git(&["ls-tree", "-z", tree_oid]).unwrap_or_default();
542        let mut ids = Vec::new();
543        // "<mode> <type> <oid>\t<name>" records, NUL-terminated by
544        // `-z` so names arrive raw — never C-quoted the way the
545        // newline-delimited default renders non-ASCII paths.
546        for entry in out.split('\u{0}') {
547            let Some((meta, name)) = entry.split_once('\t') else {
548                continue;
549            };
550            let mut f = meta.split(' ');
551            let (Some(mode), Some(entry_type), Some(oid)) = (f.next(), f.next(), f.next()) else {
552                continue;
553            };
554            ids.push(self.push_node(
555                Kind::Entry {
556                    name: name.to_string(),
557                    oid: oid.to_string(),
558                    entry_type: entry_type.to_string(),
559                    mode: mode.to_string(),
560                },
561                Some(parent),
562            ));
563        }
564        *self.nodes.borrow()[parent.0 as usize].children.borrow_mut() = Some(ids.clone());
565        ids
566    }
567
568    /// The paths a commit's diff touches, relative to its first
569    /// parent (the whole tree for a root commit), computed once.
570    fn changed_paths(&self, hash: &str) -> Vec<String> {
571        if let Some(c) = self.changed.borrow().get(hash) {
572            return c.clone();
573        }
574        let out = self
575            .git(&[
576                "diff-tree",
577                "--root",
578                "--no-commit-id",
579                "--name-only",
580                "-z",
581                "-r",
582                hash,
583            ])
584            .unwrap_or_default();
585        // `-z`: NUL-separated, raw paths (never C-quoted), so they
586        // compare equal to the raw names from `ls-tree -z`.
587        let paths: Vec<String> = out
588            .split('\u{0}')
589            .filter(|p| !p.is_empty())
590            .map(str::to_string)
591            .collect();
592        self.changed
593            .borrow_mut()
594            .insert(hash.to_string(), paths.clone());
595        paths
596    }
597
598    /// The commit a tree entry belongs to and the entry's path
599    /// within it.
600    fn entry_context(&self, node: NodeId) -> Option<(String, String)> {
601        let nodes = self.nodes.borrow();
602        let mut parts = Vec::new();
603        let mut cur = Some(node);
604        while let Some(n) = cur {
605            let nd = &nodes[n.0 as usize];
606            match &nd.kind {
607                Kind::Entry { name, .. } => parts.push(name.clone()),
608                Kind::Commit(h) => {
609                    parts.reverse();
610                    return Some((h.clone(), parts.join("/")));
611                }
612                Kind::Ref { commit, .. } => {
613                    parts.reverse();
614                    return Some((commit.clone(), parts.join("/")));
615                }
616                _ => return None,
617            }
618            cur = nd.parent;
619        }
620        None
621    }
622
623    /// The commit hash a node stands for (commits and ref aliases).
624    fn commit_of(&self, node: NodeId) -> Option<String> {
625        match &self.nodes.borrow()[node.0 as usize].kind {
626            Kind::Commit(h) => Some(h.clone()),
627            Kind::Ref { commit, .. } => Some(commit.clone()),
628            _ => None,
629        }
630    }
631
632    /// The nearest commit-backed ancestor, the node itself included:
633    /// a tree entry answers through the commit it was reached under.
634    fn commit_ancestor(&self, node: NodeId) -> Option<String> {
635        let mut cur = Some(node);
636        while let Some(n) = cur {
637            if let Some(h) = self.commit_of(n) {
638                return Some(h);
639            }
640            cur = self.nodes.borrow()[n.0 as usize].parent;
641        }
642        None
643    }
644}
645
646/// Split `rev-list --format=…%x1e` output into `(hash, body)`
647/// records. Each commit's format expansion is terminated by the
648/// ASCII Record Separator (`%x1e`), so a literal `commit ` line
649/// inside a message body (`%s`/`%B`) can never split a record;
650/// the leading `commit <hash>` header of each record supplies the
651/// hash.
652fn split_commit_records(out: &str) -> impl Iterator<Item = (&str, &str)> {
653    out.split('\u{1e}').filter_map(|record| {
654        let rest = record.trim_start().strip_prefix("commit ")?;
655        let (hash, body) = rest.split_once('\n')?;
656        Some((hash.trim(), body))
657    })
658}
659
660fn parse_info(body: &str) -> Option<CommitInfo> {
661    let f: Vec<&str> = body.trim_end_matches('\n').split('\u{0}').collect();
662    if f.len() < 8 {
663        return None;
664    }
665    Some(CommitInfo {
666        author: f[0].to_string(),
667        email: f[1].to_string(),
668        date: f[2].parse().unwrap_or(0),
669        date_offset: f.get(8).and_then(|iso| {
670            // `%ai` ends `±HHMM`; the offset rides the instant for
671            // display only.
672            let tail = iso.trim().rsplit(' ').next()?;
673            let sign = match tail.as_bytes().first()? {
674                b'+' => 1i16,
675                b'-' => -1i16,
676                _ => return None,
677            };
678            let h: i16 = tail.get(1..3)?.parse().ok()?;
679            let m: i16 = tail.get(3..5)?.parse().ok()?;
680            Some(sign * (h * 60 + m))
681        }),
682        committer: f[3].to_string(),
683        subject: f[4].to_string(),
684        message: f[5].trim_end().to_string(),
685        tree: f[6].to_string(),
686        parents: f[7].split_whitespace().map(str::to_string).collect(),
687    })
688}
689
690impl AstAdapter for GitAdapter {
691    fn root(&self) -> NodeId {
692        ROOT
693    }
694
695    /// `/commits/* @| [..k]` before the history is enumerated walks
696    /// k commits and no more (the pushdown scanning round,
697    /// 2026-11-06).
698    fn select(&self, from: NodeId, plan: &quarb::StepPlan<'_>) -> Option<quarb::Selection> {
699        let k = plan.limit?;
700        if from != COMMITS
701            || plan.axis != quarb::PlanAxis::Child
702            || plan.name.is_some()
703            || !plan.conjuncts.is_empty()
704            || !plan.traits.is_empty()
705            || *self.enumerated.borrow()
706        {
707            return None;
708        }
709        Some(quarb::Selection {
710            nodes: self
711                .commits_prefix(k)
712                .into_iter()
713                .map(|id| (id, 1))
714                .collect(),
715            applied: Vec::new(),
716        })
717    }
718
719    fn children(&self, node: NodeId) -> Vec<NodeId> {
720        let kind = self.nodes.borrow()[node.0 as usize].kind.clone();
721        match kind {
722            Kind::Root => {
723                // branches, tags, commits — plus HEAD as a ref
724                // alias, interned once.
725                if self.nodes.borrow()[ROOT.0 as usize]
726                    .children
727                    .borrow()
728                    .is_none()
729                {
730                    let mut ids = vec![BRANCHES, TAGS, COMMITS];
731                    if let Ok(h) = self.git(&["rev-parse", "HEAD"]) {
732                        ids.push(self.push_node(
733                            Kind::Ref {
734                                name: "HEAD".to_string(),
735                                commit: h.trim().to_string(),
736                            },
737                            Some(ROOT),
738                        ));
739                    }
740                    *self.nodes.borrow()[ROOT.0 as usize].children.borrow_mut() = Some(ids);
741                }
742                self.nodes.borrow()[ROOT.0 as usize]
743                    .children
744                    .borrow()
745                    .clone()
746                    .unwrap_or_default()
747            }
748            Kind::Dir("branches") => self.refs(BRANCHES, "refs/heads"),
749            Kind::Dir("tags") => self.refs(TAGS, "refs/tags"),
750            Kind::Dir(_) => self.enumerate_commits(),
751            Kind::Ref { commit, .. } | Kind::Commit(commit) => {
752                let Some(info) = self.commit_info(&commit) else {
753                    return Vec::new();
754                };
755                self.tree_children(node, &info.tree)
756            }
757            Kind::Entry {
758                oid, entry_type, ..
759            } => {
760                if entry_type == "tree" {
761                    self.tree_children(node, &oid)
762                } else {
763                    Vec::new()
764                }
765            }
766        }
767    }
768
769    /// Literal names under `/commits` go straight through
770    /// `rev-parse` — unique prefixes, `HEAD~2`, `v1.0^{}` — with
771    /// no enumeration.
772    fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
773        if node == COMMITS {
774            // A known full hash answers from the intern map;
775            // anything else (prefixes, HEAD~2, tag^{}) goes
776            // through rev-parse — enumeration never required.
777            if let Some(&id) = self.commit_nodes.borrow().get(name) {
778                return vec![id];
779            }
780            let Ok(out) = self.git(&[
781                "rev-parse",
782                "--verify",
783                "--quiet",
784                &format!("{name}^{{commit}}"),
785            ]) else {
786                return Vec::new();
787            };
788            return vec![self.commit_node(out.trim())];
789        }
790        self.children(node)
791            .into_iter()
792            .filter(|&c| self.name(c).as_deref() == Some(name))
793            .collect()
794    }
795
796    fn name(&self, node: NodeId) -> Option<String> {
797        match &self.nodes.borrow()[node.0 as usize].kind {
798            Kind::Root => None,
799            Kind::Dir(d) => Some(d.to_string()),
800            Kind::Ref { name, .. } => Some(name.clone()),
801            Kind::Commit(h) => Some(h.clone()),
802            Kind::Entry { name, .. } => Some(name.clone()),
803        }
804    }
805
806    fn parent(&self, node: NodeId) -> Option<NodeId> {
807        self.nodes.borrow()[node.0 as usize].parent
808    }
809
810    /// The kind first (spec: Traits): `<repository>` at the root,
811    /// `<dir>` for a listing, then `<commit>`, `<branch>`, `<tag>`,
812    /// `<tree>`, `<blob>`.
813    fn traits(&self, node: NodeId) -> Vec<String> {
814        let nodes = self.nodes.borrow();
815        let t = match &nodes[node.0 as usize].kind {
816            Kind::Root => "repository",
817            Kind::Dir(_) => "dir",
818            Kind::Commit(_) => "commit",
819            Kind::Ref { .. } => match nodes[node.0 as usize].parent {
820                Some(TAGS) => "tag",
821                Some(BRANCHES) => "branch",
822                _ => "commit",
823            },
824            Kind::Entry { entry_type, .. } => {
825                let base = if entry_type == "tree" { "tree" } else { "blob" };
826                let mut out = vec![base.to_string()];
827                drop(nodes);
828                // <changed>: this path is in its commit's diff
829                // (blobs by exact path, trees when any descendant
830                // changed).
831                if let Some((hash, path)) = self.entry_context(node) {
832                    let prefix = format!("{path}/");
833                    if self
834                        .changed_paths(&hash)
835                        .iter()
836                        .any(|p| *p == path || p.starts_with(&prefix))
837                    {
838                        out.push("changed".to_string());
839                    }
840                }
841                return out;
842            }
843        };
844        vec![t.to_string()]
845    }
846
847    /// Commit header fields (ref aliases answer for their commit).
848    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
849        let hash = self.commit_of(node)?;
850        let info = self.commit_info(&hash)?;
851        Some(match name {
852            "author" => Value::Str(info.author),
853            "email" => Value::Str(info.email),
854            "date" => Value::Instant {
855                secs: info.date,
856                nanos: 0,
857                offset_min: info.date_offset,
858                calendar: Default::default(),
859            },
860            "committer" => Value::Str(info.committer),
861            "subject" => Value::Str(info.subject),
862            "message" => Value::Str(info.message),
863            "tree" => Value::Str(info.tree),
864            "hash" => Value::Str(hash),
865            "parent" => Value::Str(info.parents.first()?.clone()),
866            // The paths this commit's diff touches (vs its first
867            // parent) — deletions included, unlike the tree view.
868            "changed" => Value::list(
869                self.changed_paths(&hash)
870                    .into_iter()
871                    .map(Value::Str)
872                    .collect(),
873            ),
874            _ => return None,
875        })
876    }
877
878    /// Data provenance: the repository path as the source; the
879    /// nearest commit-backed ancestor's author date as the instant
880    /// (a tree entry answers "this repo, as of that commit" — the
881    /// same instant `::date` mints). The structural dirs and the
882    /// root have no commit ancestor and answer source only. No
883    /// dpid — the commit hash stays on `::hash` / `:::short`.
884    fn provenance(&self, node: NodeId) -> quarb::Provenance {
885        let instant = self
886            .commit_ancestor(node)
887            .and_then(|h| self.commit_info(&h))
888            .map(|info| (info.date, 0, info.date_offset));
889        quarb::Provenance {
890            source: Some(self.repo.display().to_string()),
891            path: None,
892            instant_from: instant.map(|_| quarb::InstantFrom::Node),
893            instant,
894            dpid: None,
895        }
896    }
897
898    /// A blob's content (text, lossily decoded).
899    fn default_value(&self, node: NodeId) -> Option<Value> {
900        let (oid, is_blob) = match &self.nodes.borrow()[node.0 as usize].kind {
901            Kind::Entry {
902                oid, entry_type, ..
903            } => (oid.clone(), entry_type == "blob"),
904            _ => return None,
905        };
906        if !is_blob {
907            return None;
908        }
909        // The blob's bytes under the mount's rule; one that decodes
910        // under none has no text (the leaf stays a leaf).
911        self.blob_bytes(&oid)
912            .ok()
913            .and_then(|bytes| self.decoding.decode(&oid, &bytes).ok())
914            .map(|d| Value::Str(d.text))
915    }
916
917    /// Commits: `:::short`, `:::n-parents`, `:::tags`,
918    /// `:::n-tags`. Entries: `:::type`, `:::mode`, `:::size`,
919    /// `:::hash`.
920    /// Ruling #29: this adapter's property surface is closed — a
921    /// commit object cannot grow fields — so its annotations
922    /// answer at `::` too. `hash` is in the list on purpose:
923    /// commits expose the oid as a property and tree entries as
924    /// metadata, but it is one concept — this node's object id —
925    /// and aliasing is what makes `::hash` answer everywhere in
926    /// the arbor instead of falling silently null on entries.
927    fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
928        &[
929            "short",
930            "hash",
931            "n-parents",
932            "n-changed",
933            "tags",
934            "n-tags",
935            "type",
936            "mode",
937            "size",
938        ]
939    }
940
941    /// The `:::` keys this adapter answers (`:meta` lists the ones
942
943    /// a node answers).
944
945    fn metadata_catalogue(&self) -> &'static [&'static str] {
946        &[
947            "short",
948            "n-parents",
949            "n-changed",
950            "tags",
951            "n-tags",
952            "type",
953            "mode",
954            "hash",
955            "size",
956        ]
957    }
958
959    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
960        if let Some(hash) = self.commit_of(node) {
961            return match key {
962                "short" => Some(Value::Str(hash[..7.min(hash.len())].to_string())),
963                "n-parents" => Some(Value::Int(self.commit_info(&hash)?.parents.len() as i64)),
964                "n-changed" => Some(Value::Int(self.changed_paths(&hash).len() as i64)),
965                "tags" => Some(Value::list(
966                    self.tags_at(&hash).into_iter().map(Value::Str).collect(),
967                )),
968                "n-tags" => Some(Value::Int(self.tags_at(&hash).len() as i64)),
969                _ => None,
970            };
971        }
972        let (oid, entry_type, mode) = match &self.nodes.borrow()[node.0 as usize].kind {
973            Kind::Entry {
974                oid,
975                entry_type,
976                mode,
977                ..
978            } => (oid.clone(), entry_type.clone(), mode.clone()),
979            _ => return None,
980        };
981        match key {
982            "type" => Some(Value::Str(entry_type)),
983            "mode" => Some(Value::Str(mode)),
984            "hash" => Some(Value::Str(oid)),
985            "size" => self.blob_size(&oid).map(|n| Value::bytes(n as i64)),
986            _ => None,
987        }
988    }
989
990    /// `::parent-->` — the first parent (the hint is unused: the
991    /// target is inherently a commit).
992    fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
993        if property != "parent" {
994            return None;
995        }
996        let hash = self.commit_of(node)?;
997        let first = self.commit_info(&hash)?.parents.first()?.clone();
998        Some(self.commit_node(&first))
999    }
1000
1001    /// A history is acyclic by its nature: a commit's parents
1002    /// precede it, so a walk over `parent` never returns (spec:
1003    /// Model Files, Acyclic links — a source that knows this of its
1004    /// own links carries the promise without a declaration).
1005    fn acyclic_link(&self, label: &str) -> Option<String> {
1006        (label == "parent")
1007            .then(|| "the commit history (a commit's parents precede it)".to_string())
1008    }
1009
1010    /// Every parent is an outgoing `parent` edge (merges fan out).
1011    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
1012        let Some(hash) = self.commit_of(node) else {
1013            return Vec::new();
1014        };
1015        let Some(info) = self.commit_info(&hash) else {
1016            return Vec::new();
1017        };
1018        info.parents
1019            .iter()
1020            .map(|p| ("parent".to_string(), self.commit_node(p)))
1021            .collect()
1022    }
1023
1024    /// A commit's reference is its parent: the bare arrows
1025    /// (`/HEAD-->`, `/HEAD<--`) read it.
1026    fn ref_property(&self, node: NodeId) -> Option<String> {
1027        self.commit_of(node).map(|_| "parent".to_string())
1028    }
1029
1030    /// `<--(::parent)` from the enumeration, with no walk: the
1031    /// commits whose `parent` resolves here — `::parent-->` follows
1032    /// the FIRST parent, so a merge counts under its first parent
1033    /// only. Any other property has no referrers; with no property
1034    /// named the question stays the engine's.
1035    fn reverse_resolve(
1036        &self,
1037        node: NodeId,
1038        property: Option<&str>,
1039        _hint: Option<&str>,
1040    ) -> Option<Vec<NodeId>> {
1041        let hash = self.commit_of(node)?;
1042        // With no property named: the commit's reference property.
1043        if property.is_some_and(|p| p != "parent") {
1044            return Some(Vec::new());
1045        }
1046        // The index lists every child; `::parent-->` follows the
1047        // first parent, so keep the children whose first parent is
1048        // here.
1049        let children: Vec<String> = self
1050            .children_of(&hash)
1051            .into_iter()
1052            .filter(|h| {
1053                self.commit_info(h)
1054                    .is_some_and(|i| i.parents.first() == Some(&hash))
1055            })
1056            .collect();
1057        Some(children.iter().map(|h| self.commit_node(h)).collect())
1058    }
1059
1060    /// The commits whose parent is here (the children — served
1061    /// from the enumeration, so this loads the commit list).
1062    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1063        let Some(hash) = self.commit_of(node) else {
1064            return Vec::new();
1065        };
1066        self.children_of(&hash)
1067            .into_iter()
1068            .map(|h| ("parent".to_string(), self.commit_node(&h)))
1069            .collect()
1070    }
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075    use super::*;
1076
1077    // Regression: a commit whose subject and body contain the word
1078    // "commit " must not corrupt enumeration. Records are delimited
1079    // by the trailing %x1e sentinel, not by "commit " in the body.
1080    #[test]
1081    fn record_split_survives_commit_word_in_body() {
1082        let out = "commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
1083Ann\u{0}ann@example\u{0}1000\u{0}Ann\u{0}\
1084Revert commit deadbeef\u{0}\
1085Revert commit deadbeef\n\nThis reverts commit deadbeef.\u{0}\
1086tttttttttttttttttttttttttttttttttttttttt\u{0}\
1087pppppppppppppppppppppppppppppppppppppppp\u{0}\
10882026-07-15 12:00:00 +0100\u{1e}\n\
1089commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\
1090Bo\u{0}bo@example\u{0}2000\u{0}Bo\u{0}second\u{0}second\u{0}\
1091tttttttttttttttttttttttttttttttttttttttt\u{0}\u{0}\
10922026-07-15 13:00:00 +0100\u{1e}\n";
1093        let recs: Vec<(&str, &str)> = split_commit_records(out).collect();
1094        assert_eq!(recs.len(), 2);
1095        assert_eq!(recs[0].0, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
1096        let a = parse_info(recs[0].1).expect("first record parses");
1097        assert_eq!(a.author, "Ann");
1098        assert_eq!(a.subject, "Revert commit deadbeef");
1099        assert_eq!(
1100            a.message,
1101            "Revert commit deadbeef\n\nThis reverts commit deadbeef."
1102        );
1103        assert_eq!(recs[1].0, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
1104        let b = parse_info(recs[1].1).expect("second record parses");
1105        assert_eq!(b.subject, "second");
1106    }
1107}