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.
228 lines
7.5 KiB
Python
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))},
|
|
)
|