arborist/aborist/sources/vcs.py
russell@unturf.com 082143e158
add git and mercurial repo sources for self-play
GitRepoSource walks the working tree at HEAD via `git ls-tree` + `git show`,
yielding one Document per text file. URI shape `git://<repo>/file/<path>`
intentionally omits the commit hash — re-ingest after new commits produces
fresh document_roots that aborist's prior-doc detection chains via
`supersedes` edges, so the audit trail and Merkle tree grow as the repo
grows. Binaries skipped via NUL-byte + UTF-8 decode probes; >5 MB files
filtered out by default. Commit hash + timestamp + subject ride along in
`extra` (informational only; not part of the Merkle commitment).

MercurialRepoSource mirrors via `hg manifest` + `hg cat`. Same supersedes
semantics, same shape.

Makefile adds:
  make ingest-self                          (this repo -> aborist-self.db)
  make ingest-git GIT_REPO=/path/to/repo    (arbitrary git clone)
  make ingest-hg  HG_REPO=/path/to/repo     (mercurial)

Each lands in its own shard file alongside existing shards/grok.db,
keeping per-shard write paths independent of the wikipedia 4-way ingest's
WAL writer lock.
2026-04-27 18:17:39 -04:00

228 lines
7.5 KiB
Python

"""Version-control-system sources: git and Mercurial.
Each yields one Document per text file at HEAD/tip, with a stable URI
that does NOT include the commit hash — so re-ingesting the same repo
after new commits produces *new* documents for changed files which
aborist's prior-document detection auto-chains via `supersedes` edges.
That gives "the Merkle tree grows over time" semantics for free: every
new commit appends to the audit chain, every changed file gets a new
content-addressed Document, and the supersedes edges connect them.
Binary files are skipped (best-effort UTF-8 decode; fall back rejects
the file). Files larger than `max_bytes` are skipped to avoid pulling
generated artifacts (build outputs, vendored libraries) into the
content store.
Both sources subprocess the underlying CLI rather than importing a
client library — keeps the dependency surface minimal and works with
whatever git/hg the user has installed.
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from typing import Iterator
from aborist.document import Document
from aborist.source import Source
# Skip files >5 MB by default. Source code, prose, configs all fit
# comfortably; this filters out lockfiles, generated assets, and
# accidentally-committed binaries.
_DEFAULT_MAX_BYTES = 5 * 1024 * 1024
def _run(cmd: list[str], cwd: Path) -> str:
"""Run a subprocess and return decoded stdout. Raise on non-zero."""
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
check=True,
)
return result.stdout.decode("utf-8", errors="replace")
def _run_bytes(cmd: list[str], cwd: Path) -> bytes:
"""Run a subprocess and return raw stdout bytes."""
result = subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
check=True,
)
return result.stdout
def _try_decode(raw: bytes) -> str | None:
"""Best-effort UTF-8 decode; return None for binary content."""
if b"\x00" in raw[:4096]:
return None # NUL bytes -> almost certainly binary
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return None
class GitRepoSource(Source):
"""Yields one Document per text file at the given git ref.
URI shape: `git://<repo-basename>/file/<relative-path>`.
Re-ingesting after new commits auto-chains via supersedes edges —
the same file path with new content gets a fresh document_root
plus an edge `(new_root, old_root, edge_type='supersedes')`.
`extra` carries the commit hash, author timestamp, and short
summary of HEAD at ingest time (informational; not Merkle-bound).
"""
source_type = "git_repo"
def __init__(
self,
repo_path: str | Path,
*,
ref: str = "HEAD",
repo_name: str | None = None,
max_bytes: int = _DEFAULT_MAX_BYTES,
):
self.repo_path = Path(repo_path).resolve()
if not (self.repo_path / ".git").exists():
raise FileNotFoundError(f"not a git repo: {self.repo_path}")
if shutil.which("git") is None:
raise RuntimeError("git executable not found in PATH")
self.ref = ref
self.repo_name = repo_name or self.repo_path.name
self.max_bytes = max_bytes
def _commit_meta(self) -> dict[str, str]:
try:
line = _run(
["git", "log", "-1", "--format=%H%x09%at%x09%s", self.ref],
self.repo_path,
).strip()
commit_hash, ts, subject = line.split("\t", 2)
except Exception:
return {}
return {
"commit_hash": commit_hash,
"commit_ts": ts,
"commit_subject": subject,
}
def iter_documents(self) -> Iterator[Document]:
meta = self._commit_meta()
listing = _run(
["git", "ls-tree", "-r", "--name-only", self.ref],
self.repo_path,
)
for relpath in listing.splitlines():
relpath = relpath.strip()
if not relpath:
continue
try:
raw = _run_bytes(
["git", "show", f"{self.ref}:{relpath}"], self.repo_path
)
except subprocess.CalledProcessError:
# Submodule entry, broken ref, etc.
continue
if len(raw) > self.max_bytes:
continue
text = _try_decode(raw)
if text is None or not text.strip():
continue
yield Document(
uri=f"git://{self.repo_name}/file/{relpath}",
content=text,
source_type=self.source_type,
title=relpath,
extra={**meta, "path": relpath, "size_bytes": str(len(raw))},
)
class MercurialRepoSource(Source):
"""Yields one Document per text file at the given hg revision.
Mirror of GitRepoSource. URI shape:
`hg://<repo-basename>/file/<relative-path>`. Same supersedes-on-rerun
semantics.
"""
source_type = "hg_repo"
def __init__(
self,
repo_path: str | Path,
*,
rev: str = "tip",
repo_name: str | None = None,
max_bytes: int = _DEFAULT_MAX_BYTES,
):
self.repo_path = Path(repo_path).resolve()
if not (self.repo_path / ".hg").exists():
raise FileNotFoundError(f"not a mercurial repo: {self.repo_path}")
if shutil.which("hg") is None:
raise RuntimeError("hg executable not found in PATH")
self.rev = rev
self.repo_name = repo_name or self.repo_path.name
self.max_bytes = max_bytes
def _changeset_meta(self) -> dict[str, str]:
try:
# template emits: full-hash<TAB>unix-time<TAB>summary
line = _run(
[
"hg",
"log",
"-r",
self.rev,
"--template",
"{node}\t{date|hgdate}\t{desc|firstline}",
],
self.repo_path,
).strip()
parts = line.split("\t", 2)
if len(parts) != 3:
return {}
changeset_hash, hgdate, subject = parts
# hgdate is "<unix> <tzoffset>"; keep just the unix part.
ts = hgdate.split()[0] if hgdate else ""
except Exception:
return {}
return {
"changeset_hash": changeset_hash,
"commit_ts": ts,
"commit_subject": subject,
}
def iter_documents(self) -> Iterator[Document]:
meta = self._changeset_meta()
listing = _run(
["hg", "manifest", "-r", self.rev], self.repo_path
)
for relpath in listing.splitlines():
relpath = relpath.strip()
if not relpath:
continue
try:
raw = _run_bytes(
["hg", "cat", "-r", self.rev, relpath], self.repo_path
)
except subprocess.CalledProcessError:
continue
if len(raw) > self.max_bytes:
continue
text = _try_decode(raw)
if text is None or not text.strip():
continue
yield Document(
uri=f"hg://{self.repo_name}/file/{relpath}",
content=text,
source_type=self.source_type,
title=relpath,
extra={**meta, "path": relpath, "size_bytes": str(len(raw))},
)