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