chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""Multi-repo registry and connection pool.
|
||||
|
||||
Manages a registry of multiple repositories at ``~/.code-review-graph/registry.json``
|
||||
and provides a connection pool for concurrent access to multiple graph databases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default registry path
|
||||
_REGISTRY_DIR = Path.home() / ".code-review-graph"
|
||||
_REGISTRY_PATH = _REGISTRY_DIR / "registry.json"
|
||||
|
||||
|
||||
class Registry:
|
||||
"""Manages a JSON-based registry of code-review-graph repositories.
|
||||
|
||||
Each entry stores the repo path and an optional alias.
|
||||
The registry lives at ``~/.code-review-graph/registry.json``.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self._path = path or _REGISTRY_PATH
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
self._repos: list[dict[str, str]] = []
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load registry from disk."""
|
||||
if self._path.exists():
|
||||
try:
|
||||
data = json.loads(self._path.read_text(encoding="utf-8", errors="replace"))
|
||||
self._repos = data.get("repos", [])
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
logger.warning("Invalid registry file, starting fresh: %s", self._path)
|
||||
self._repos = []
|
||||
else:
|
||||
self._repos = []
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Write registry to disk."""
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {"repos": self._repos}
|
||||
self._path.write_text(
|
||||
json.dumps(data, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
def register(
|
||||
self, path: str, alias: str | None = None, data_dir: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Register a repository path.
|
||||
|
||||
Validates that the path contains a ``.git`` or ``.code-review-graph``
|
||||
directory.
|
||||
|
||||
Args:
|
||||
path: Absolute or relative path to the repository root.
|
||||
alias: Optional short alias for the repository.
|
||||
data_dir: Optional external directory for graph database.
|
||||
|
||||
Returns:
|
||||
The registered entry dict.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path is not a valid repository.
|
||||
"""
|
||||
resolved = Path(path).resolve()
|
||||
if not resolved.is_dir():
|
||||
raise ValueError(f"Path is not a directory: {resolved}")
|
||||
has_repo_marker = (
|
||||
(resolved / ".git").exists()
|
||||
or (resolved / ".svn").exists()
|
||||
or (resolved / ".code-review-graph").exists()
|
||||
)
|
||||
if not has_repo_marker:
|
||||
raise ValueError(
|
||||
f"Path does not look like a repository "
|
||||
f"(no .git, .svn, or .code-review-graph): {resolved}"
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
# Check for duplicate path
|
||||
str_path = str(resolved)
|
||||
for entry in self._repos:
|
||||
if entry["path"] == str_path:
|
||||
# Update alias and/or data_dir if provided
|
||||
if alias:
|
||||
entry["alias"] = alias
|
||||
if data_dir:
|
||||
entry["data_dir"] = str(Path(data_dir).resolve())
|
||||
self._save()
|
||||
return entry
|
||||
|
||||
new_entry: dict[str, str] = {"path": str_path}
|
||||
if alias:
|
||||
new_entry["alias"] = alias
|
||||
if data_dir:
|
||||
new_entry["data_dir"] = str(Path(data_dir).resolve())
|
||||
self._repos.append(new_entry)
|
||||
self._save()
|
||||
return new_entry
|
||||
|
||||
def unregister(self, path_or_alias: str) -> bool:
|
||||
"""Remove a repository by path or alias.
|
||||
|
||||
Args:
|
||||
path_or_alias: Either the absolute path or the alias.
|
||||
|
||||
Returns:
|
||||
True if an entry was removed, False otherwise.
|
||||
"""
|
||||
with self._lock:
|
||||
resolved = str(Path(path_or_alias).resolve())
|
||||
original_len = len(self._repos)
|
||||
self._repos = [
|
||||
entry for entry in self._repos
|
||||
if entry["path"] != resolved
|
||||
and entry.get("alias") != path_or_alias
|
||||
]
|
||||
if len(self._repos) < original_len:
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_repos(self) -> list[dict[str, str]]:
|
||||
"""Return list of all registered repositories.
|
||||
|
||||
Returns:
|
||||
List of dicts with 'path' and optional 'alias' keys.
|
||||
"""
|
||||
with self._lock:
|
||||
return list(self._repos)
|
||||
|
||||
def find_by_alias(self, alias: str) -> dict[str, str] | None:
|
||||
"""Look up a repository by its alias.
|
||||
|
||||
Args:
|
||||
alias: The alias to search for.
|
||||
|
||||
Returns:
|
||||
The matching entry, or None.
|
||||
"""
|
||||
with self._lock:
|
||||
for entry in self._repos:
|
||||
if entry.get("alias") == alias:
|
||||
return dict(entry)
|
||||
return None
|
||||
|
||||
def find_by_path(self, path: str) -> dict[str, str] | None:
|
||||
"""Look up a repository by its path.
|
||||
|
||||
Args:
|
||||
path: The path to search for.
|
||||
|
||||
Returns:
|
||||
The matching entry, or None.
|
||||
"""
|
||||
resolved = str(Path(path).resolve())
|
||||
with self._lock:
|
||||
for entry in self._repos:
|
||||
if entry["path"] == resolved:
|
||||
return dict(entry)
|
||||
return None
|
||||
|
||||
def set_data_dir(self, path: str, data_dir: str) -> dict[str, str]:
|
||||
"""Set the external data directory for a repository.
|
||||
|
||||
Args:
|
||||
path: Repository path (absolute or relative).
|
||||
data_dir: External directory path to store graph database.
|
||||
|
||||
Returns:
|
||||
The updated or created registry entry.
|
||||
"""
|
||||
resolved = str(Path(path).resolve())
|
||||
data_resolved = str(Path(data_dir).resolve())
|
||||
|
||||
with self._lock:
|
||||
# Check for existing entry
|
||||
for entry in self._repos:
|
||||
if entry["path"] == resolved:
|
||||
entry["data_dir"] = data_resolved
|
||||
self._save()
|
||||
return dict(entry)
|
||||
|
||||
# Create new entry if not found
|
||||
new_entry = {
|
||||
"path": resolved,
|
||||
"data_dir": data_resolved
|
||||
}
|
||||
self._repos.append(new_entry)
|
||||
self._save()
|
||||
return new_entry
|
||||
|
||||
def get_data_dir_for_repo(self, path: str) -> str | None:
|
||||
"""Get the stored data directory for a repository.
|
||||
|
||||
Args:
|
||||
path: Repository path (absolute or relative).
|
||||
|
||||
Returns:
|
||||
The stored data_dir path, or None if not set.
|
||||
"""
|
||||
resolved = str(Path(path).resolve())
|
||||
with self._lock:
|
||||
for entry in self._repos:
|
||||
if entry["path"] == resolved:
|
||||
return entry.get("data_dir")
|
||||
return None
|
||||
|
||||
|
||||
class ConnectionPool:
|
||||
"""LRU connection pool for SQLite graph databases.
|
||||
|
||||
Caches open connections keyed by database path, evicting the least
|
||||
recently used connection when the pool is full.
|
||||
"""
|
||||
|
||||
def __init__(self, max_size: int = 10) -> None:
|
||||
self._max_size = max_size
|
||||
self._pool: OrderedDict[str, sqlite3.Connection] = OrderedDict()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, db_path: str) -> sqlite3.Connection:
|
||||
"""Get or create a connection for the given database path.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file.
|
||||
|
||||
Returns:
|
||||
An open SQLite connection.
|
||||
"""
|
||||
key = str(Path(db_path).resolve())
|
||||
with self._lock:
|
||||
if key in self._pool:
|
||||
self._pool.move_to_end(key)
|
||||
return self._pool[key]
|
||||
|
||||
# Evict LRU if full
|
||||
while len(self._pool) >= self._max_size:
|
||||
evict_key, evict_conn = self._pool.popitem(last=False)
|
||||
try:
|
||||
evict_conn.close()
|
||||
except sqlite3.Error:
|
||||
logger.debug("Failed to close evicted connection: %s", evict_key)
|
||||
logger.debug("Evicted connection: %s", evict_key)
|
||||
|
||||
conn = sqlite3.connect(
|
||||
key, timeout=30, check_same_thread=False,
|
||||
isolation_level=None,
|
||||
)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
self._pool[key] = conn
|
||||
return conn
|
||||
|
||||
def close_all(self) -> None:
|
||||
"""Close all connections in the pool."""
|
||||
with self._lock:
|
||||
for key, conn in self._pool.items():
|
||||
try:
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
logger.debug("Failed to close connection: %s", key)
|
||||
self._pool.clear()
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""Current number of open connections."""
|
||||
with self._lock:
|
||||
return len(self._pool)
|
||||
|
||||
|
||||
def resolve_repo(
|
||||
registry: Registry,
|
||||
repo: str | None,
|
||||
cwd: str | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve a repo parameter to an absolute path.
|
||||
|
||||
Resolution order:
|
||||
1. If repo is given, try as alias first.
|
||||
2. If repo is given and not an alias, try as a direct path.
|
||||
3. If repo is None, use cwd.
|
||||
|
||||
Args:
|
||||
registry: The Registry instance.
|
||||
repo: Alias or path string, or None.
|
||||
cwd: Current working directory fallback.
|
||||
|
||||
Returns:
|
||||
Resolved absolute path string, or None if unresolvable.
|
||||
"""
|
||||
if repo:
|
||||
# Try alias first
|
||||
entry = registry.find_by_alias(repo)
|
||||
if entry:
|
||||
return entry["path"]
|
||||
|
||||
# Try as direct path
|
||||
path = Path(repo).resolve()
|
||||
if path.is_dir():
|
||||
return str(path)
|
||||
|
||||
# Fall back to CWD
|
||||
if cwd:
|
||||
return str(Path(cwd).resolve())
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user