| """Read immutable source snapshots directly from a local Git object database.""" |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from hashlib import sha256 |
| from pathlib import Path |
| import subprocess |
| from typing import Iterator |
|
|
|
|
| class GitSnapshotError(RuntimeError): |
| """Raised when a repository or pinned commit cannot be read safely.""" |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class SourceFile: |
| path: str |
| text: str |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class SourceChunk: |
| chunk_id: str |
| path: str |
| line_start: int |
| line_end: int |
| text: str |
|
|
|
|
| class GitSnapshot: |
| def __init__(self, repository: Path): |
| self.repository = repository.resolve() |
| if not (self.repository / ".git").exists(): |
| raise GitSnapshotError(f"Not a Git checkout: {self.repository}") |
|
|
| def _git(self, arguments: list[str], timeout_seconds: int = 60) -> bytes: |
| result = subprocess.run( |
| ["git", *arguments], |
| cwd=self.repository, |
| check=False, |
| capture_output=True, |
| timeout=timeout_seconds, |
| ) |
| if result.returncode != 0: |
| detail = result.stderr.decode("utf-8", errors="replace").strip() |
| raise GitSnapshotError(f"git {' '.join(arguments)} failed: {detail}") |
| return result.stdout |
|
|
| def verify_commit(self, commit: str) -> None: |
| self._git(["cat-file", "-e", f"{commit}^{{commit}}"]) |
|
|
| def tracked_paths(self, commit: str, suffixes: tuple[str, ...] = (".go",)) -> tuple[str, ...]: |
| self.verify_commit(commit) |
| raw = self._git(["ls-tree", "-r", "--name-only", "-z", commit]) |
| paths = tuple( |
| path |
| for path in raw.decode("utf-8", errors="surrogateescape").split("\0") |
| if path and path.endswith(suffixes) |
| ) |
| return tuple(sorted(paths)) |
|
|
| def read_file(self, commit: str, path: str) -> SourceFile: |
| if Path(path).is_absolute() or ".." in Path(path).parts: |
| raise GitSnapshotError(f"Unsafe repository path: {path}") |
| raw = self._git(["show", f"{commit}:{path}"]) |
| return SourceFile(path=path, text=raw.decode("utf-8", errors="replace")) |
|
|
| def iter_files( |
| self, |
| commit: str, |
| suffixes: tuple[str, ...] = (".go",), |
| ) -> Iterator[SourceFile]: |
| for path in self.tracked_paths(commit, suffixes): |
| yield self.read_file(commit, path) |
|
|
|
|
| def chunk_file( |
| source: SourceFile, |
| chunk_lines: int, |
| overlap_lines: int, |
| char_limit: int, |
| ) -> tuple[SourceChunk, ...]: |
| if chunk_lines <= 0 or char_limit <= 0 or not 0 <= overlap_lines < chunk_lines: |
| raise ValueError("invalid line chunking policy") |
| lines = source.text.splitlines(keepends=True) |
| if not lines: |
| return () |
| stride = chunk_lines - overlap_lines |
| chunks: list[SourceChunk] = [] |
| for start in range(0, len(lines), stride): |
| selected = lines[start : start + chunk_lines] |
| if not selected: |
| break |
| block = "".join(selected) |
| block_line_start = start + 1 |
| char_stride = max(char_limit - 512, 1) |
| for char_start in range(0, len(block), char_stride): |
| text = block[char_start : char_start + char_limit] |
| if not text: |
| break |
| line_start = block_line_start + block[:char_start].count("\n") |
| line_end = line_start + text.count("\n") |
| if text and not text.endswith("\n"): |
| line_end += 1 |
| identity = sha256( |
| f"{source.path}\0{line_start}\0{line_end}\0{text}".encode("utf-8") |
| ).hexdigest() |
| chunks.append( |
| SourceChunk( |
| chunk_id=identity, |
| path=source.path, |
| line_start=line_start, |
| line_end=line_end, |
| text=text, |
| ) |
| ) |
| if char_start + char_limit >= len(block): |
| break |
| if line_end == len(lines): |
| break |
| return tuple(chunks) |
|
|
|
|
| def chunk_snapshot( |
| snapshot: GitSnapshot, |
| commit: str, |
| chunk_lines: int, |
| overlap_lines: int, |
| char_limit: int, |
| suffixes: tuple[str, ...] = (".go",), |
| ) -> tuple[SourceChunk, ...]: |
| chunks: list[SourceChunk] = [] |
| for source in snapshot.iter_files(commit, suffixes): |
| chunks.extend(chunk_file(source, chunk_lines, overlap_lines, char_limit)) |
| return tuple(chunks) |
|
|