Files
2026-07-13 12:42:18 +08:00

1010 lines
34 KiB
Python

"""Multi-repo watch daemon for code-review-graph.
Reads ``~/.code-review-graph/watch.toml`` to configure which repositories
to watch, then spawns one ``code-review-graph watch`` child process per
repo. Monitors the config file for live changes (adding/removing repos)
and health-checks child processes, restarting any that die.
No external dependencies beyond Python stdlib — no tmux required.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import signal
import subprocess
import sys
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
if sys.version_info >= (3, 11):
import tomllib
else:
try:
import tomli as tomllib # type: ignore[no-redef]
except ImportError:
tomllib = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Config file location
# ---------------------------------------------------------------------------
CONFIG_PATH: Path = Path.home() / ".code-review-graph" / "watch.toml"
PID_PATH: Path = Path.home() / ".code-review-graph" / "daemon.pid"
STATE_PATH: Path = Path.home() / ".code-review-graph" / "daemon-state.json"
_HEALTH_CHECK_INTERVAL = 30
# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------
@dataclass
class WatchRepo:
"""A single repository to watch."""
path: str
"""Resolved absolute path to the repository root."""
alias: str
"""Short name for this repo (derived from directory name when not specified)."""
@dataclass
class DaemonConfig:
"""Top-level daemon configuration."""
session_name: str = "crg-watch"
"""Logical daemon name (used in log messages and status output)."""
log_dir: Path = field(default_factory=lambda: Path.home() / ".code-review-graph" / "logs")
"""Directory for per-repo log files."""
poll_interval: int = 2
"""Seconds between file-system polls for config changes."""
repos: list[WatchRepo] = field(default_factory=list)
"""Repositories the daemon watches."""
# ---------------------------------------------------------------------------
# Loading
# ---------------------------------------------------------------------------
def load_config(path: Path | None = None) -> DaemonConfig:
"""Load daemon configuration from a TOML file.
Args:
path: Explicit config path. Falls back to :data:`CONFIG_PATH`.
Returns:
A fully-validated :class:`DaemonConfig`.
Raises:
RuntimeError: If ``tomllib`` / ``tomli`` is unavailable on Python < 3.11.
"""
if tomllib is None:
raise RuntimeError(
"TOML parsing requires the 'tomli' package on Python < 3.11. "
"Install it with: pip install tomli"
)
config_path = path or CONFIG_PATH
if not config_path.exists():
logger.info("Config file not found at %s — using defaults", config_path)
return DaemonConfig()
with open(config_path, "rb") as fh:
raw: dict[str, Any] = tomllib.load(fh)
# -- [daemon] section ---------------------------------------------------
daemon_section: dict[str, Any] = raw.get("daemon", {})
session_name: str = daemon_section.get("session_name", "crg-watch")
log_dir = Path(daemon_section.get("log_dir", str(DaemonConfig().log_dir)))
poll_interval: int = int(daemon_section.get("poll_interval", 2))
# -- [[repos]] array ----------------------------------------------------
repos: list[WatchRepo] = []
seen_aliases: set[str] = set()
for entry in raw.get("repos", []):
repo_path_str: str = entry.get("path", "")
if not repo_path_str:
logger.warning("Skipping repo entry with empty path")
continue
repo_path = Path(repo_path_str).expanduser().resolve()
if not repo_path.is_dir():
logger.warning("Skipping repo %s — directory does not exist", repo_path)
continue
has_repo_marker = (
(repo_path / ".git").exists()
or (repo_path / ".svn").exists()
or (repo_path / ".code-review-graph").exists()
)
if not has_repo_marker:
logger.warning(
"Skipping repo %s — no .git, .svn, or .code-review-graph directory found",
repo_path,
)
continue
alias: str = entry.get("alias", "") or repo_path.name
if alias in seen_aliases:
logger.warning("Skipping duplicate alias '%s' for repo %s", alias, repo_path)
continue
seen_aliases.add(alias)
repos.append(WatchRepo(path=str(repo_path), alias=alias))
return DaemonConfig(
session_name=session_name,
log_dir=log_dir,
poll_interval=poll_interval,
repos=repos,
)
# ---------------------------------------------------------------------------
# Saving
# ---------------------------------------------------------------------------
def _serialize_toml(config: DaemonConfig) -> str:
"""Serialize a :class:`DaemonConfig` to TOML text.
``tomllib`` is read-only, so we build the TOML manually.
"""
lines: list[str] = [
"[daemon]",
f'session_name = "{config.session_name}"',
f'log_dir = "{config.log_dir}"',
f"poll_interval = {config.poll_interval}",
]
for repo in config.repos:
lines.append("")
lines.append("[[repos]]")
lines.append(f'path = "{repo.path}"')
lines.append(f'alias = "{repo.alias}"')
lines.append("") # trailing newline
return "\n".join(lines)
def save_config(config: DaemonConfig, path: Path | None = None) -> None:
"""Write *config* back to a TOML file.
Creates parent directories if they do not exist.
Args:
config: The daemon configuration to persist.
path: Explicit config path. Falls back to :data:`CONFIG_PATH`.
"""
config_path = path or CONFIG_PATH
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(_serialize_toml(config), encoding="utf-8")
logger.info("Config saved to %s", config_path)
# ---------------------------------------------------------------------------
# Convenience helpers (used by CLI commands)
# ---------------------------------------------------------------------------
def add_repo_to_config(
repo_path: str,
alias: str | None = None,
config_path: Path | None = None,
) -> DaemonConfig:
"""Add a repository to the daemon config and persist the change.
Args:
repo_path: Path to the repository (will be resolved to absolute).
alias: Optional short name. Derived from dirname if *None*.
config_path: Explicit config file path. Falls back to :data:`CONFIG_PATH`.
Returns:
The updated :class:`DaemonConfig`.
Raises:
ValueError: If the path is not a valid repository directory.
"""
resolved = Path(repo_path).expanduser().resolve()
if not resolved.is_dir():
raise ValueError(f"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"No .git, .svn, or .code-review-graph directory in {resolved}")
effective_alias = alias or resolved.name
config = load_config(config_path)
# Check for duplicate path or alias
for existing in config.repos:
if existing.path == str(resolved):
logger.warning("Repo %s is already configured — skipping", resolved)
return config
if existing.alias == effective_alias:
raise ValueError(f"Alias '{effective_alias}' is already in use by {existing.path}")
config.repos.append(WatchRepo(path=str(resolved), alias=effective_alias))
save_config(config, config_path)
return config
def remove_repo_from_config(
path_or_alias: str,
config_path: Path | None = None,
) -> DaemonConfig:
"""Remove a repository from the daemon config by path or alias.
Args:
path_or_alias: Either the absolute/relative repo path or its alias.
config_path: Explicit config file path. Falls back to :data:`CONFIG_PATH`.
Returns:
The updated :class:`DaemonConfig`.
"""
config = load_config(config_path)
resolved = str(Path(path_or_alias).expanduser().resolve())
original_count = len(config.repos)
config.repos = [r for r in config.repos if r.path != resolved and r.alias != path_or_alias]
if len(config.repos) == original_count:
logger.warning(
"No repo matching '%s' found in config — nothing removed",
path_or_alias,
)
else:
save_config(config, config_path)
return config
# ---------------------------------------------------------------------------
# PID file management
# ---------------------------------------------------------------------------
def write_pid(pid: int | None = None, path: Path | None = None) -> None:
"""Write the current (or given) PID to the PID file."""
pid_path = path or PID_PATH
pid_path.parent.mkdir(parents=True, exist_ok=True)
pid_path.write_text(str(pid or os.getpid()), encoding="utf-8")
def read_pid(path: Path | None = None) -> int | None:
"""Read the daemon PID from disk. Returns None if missing/invalid."""
pid_path = path or PID_PATH
if not pid_path.exists():
return None
try:
return int(pid_path.read_text(encoding="utf-8").strip())
except (ValueError, OSError):
return None
def clear_pid(path: Path | None = None) -> None:
"""Remove the PID file."""
pid_path = path or PID_PATH
try:
pid_path.unlink(missing_ok=True)
except OSError:
pass
# Win32 constants for the OpenProcess-based liveness check (#511).
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
_ERROR_ACCESS_DENIED = 5
_WAIT_OBJECT_0 = 0x0
def _pid_alive_windows(
pid: int,
kernel32: Any,
get_last_error: Callable[[], int] | None = None,
) -> bool:
"""Win32 PID liveness check via OpenProcess/WaitForSingleObject.
The kernel32 interface is injected so tests can drive handle/wait
outcomes on non-Windows platforms. *get_last_error* defaults to
``kernel32.GetLastError``; the real caller passes
``ctypes.get_last_error`` (reliable with ``use_last_error=True``).
"""
if get_last_error is None:
get_last_error = kernel32.GetLastError
handle = kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
# NULL handle: process is dead, or we lack access. ACCESS_DENIED
# means it exists but is owned by another user — treat as alive.
return get_last_error() == _ERROR_ACCESS_DENIED
try:
# WAIT_OBJECT_0 means the process handle is signaled (it exited).
return kernel32.WaitForSingleObject(handle, 0) != _WAIT_OBJECT_0
finally:
kernel32.CloseHandle(handle)
def pid_alive(pid: int) -> bool:
"""Cross-platform check whether a process with *pid* is running.
On Windows ``os.kill(pid, 0)`` routes to GenerateConsoleCtrlEvent and
raises ``OSError`` (WinError 87) for alive PIDs outside the caller's
console process group (#511), so the Win32 API is used instead.
"""
if sys.platform == "win32":
import ctypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
return _pid_alive_windows(pid, kernel32, ctypes.get_last_error)
try:
os.kill(pid, 0) # signal 0 = existence check
return True
except ProcessLookupError:
return False
except PermissionError:
return True # process exists but owned by another user
except OSError as exc:
# Unexpected platform quirk — treat as not alive rather than crash.
logger.debug("PID %d liveness check failed: %s", pid, exc)
return False
def is_daemon_running(path: Path | None = None) -> bool:
"""Check whether a daemon process is alive."""
pid = read_pid(path)
if pid is None:
return False
if pid_alive(pid):
return True
# Stale PID file — clean up
clear_pid(path)
return False
# ---------------------------------------------------------------------------
# Child state persistence (for cross-process status queries)
# ---------------------------------------------------------------------------
def load_state(path: Path | None = None) -> dict[str, Any]:
"""Load persisted child process state from disk.
Returns a dict mapping alias to ``{"pid": int, "path": str}``.
Returns an empty dict if the file is missing or corrupt.
"""
state_path = path or STATE_PATH
if not state_path.exists():
return {}
try:
return json.loads(state_path.read_text(encoding="utf-8")) # type: ignore[no-any-return]
except (json.JSONDecodeError, OSError):
return {}
def _is_pid_alive(pid: int) -> bool:
"""Check whether a process with the given PID is running."""
return pid_alive(pid)
# ---------------------------------------------------------------------------
# ConfigWatcher — monitors config file for live changes
# ---------------------------------------------------------------------------
class ConfigWatcher:
"""Watches the daemon config file for changes and triggers reconciliation."""
def __init__(
self,
config_path: Path,
callback: Callable[[], None],
poll_interval: int = 2,
) -> None:
self._config_path = config_path
self._callback = callback
self._poll_interval = poll_interval
self._observer: Any = None # watchdog Observer when available
self._last_mtime: float = 0.0
self._poll_thread: threading.Thread | None = None
self._stop_event: threading.Event = threading.Event()
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def start(self) -> None:
"""Begin watching the config file for modifications."""
try:
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
watcher = self
class _Handler(FileSystemEventHandler): # type: ignore[misc]
def on_modified(self, event: Any) -> None:
if Path(event.src_path).resolve() == watcher._config_path.resolve():
watcher._on_config_changed()
handler = _Handler()
self._observer = Observer()
self._observer.schedule(
handler,
str(self._config_path.parent),
recursive=False,
)
self._observer.daemon = True
self._observer.start()
logger.info(
"Config watcher started (watchdog) for %s",
self._config_path,
)
except ImportError:
# Fallback to polling when watchdog is unavailable
logger.info(
"watchdog not available — falling back to polling for %s",
self._config_path,
)
self._start_polling()
def stop(self) -> None:
"""Stop watching the config file."""
self._stop_event.set()
if self._observer is not None:
self._observer.stop()
self._observer.join(timeout=5)
self._observer = None
if self._poll_thread is not None:
self._poll_thread.join(timeout=5)
self._poll_thread = None
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _start_polling(self) -> None:
"""Poll the config file mtime in a background thread."""
if self._config_path.exists():
self._last_mtime = self._config_path.stat().st_mtime
def _poll() -> None:
while not self._stop_event.is_set():
self._stop_event.wait(self._poll_interval)
if self._stop_event.is_set():
break
try:
if not self._config_path.exists():
continue
mtime = self._config_path.stat().st_mtime
if mtime != self._last_mtime:
self._last_mtime = mtime
self._on_config_changed()
except OSError:
pass
self._poll_thread = threading.Thread(
target=_poll,
daemon=True,
name="config-poller",
)
self._poll_thread.start()
def _on_config_changed(self) -> None:
"""Handle a detected config file modification."""
logger.info("Config file changed, triggering reconciliation")
try:
self._callback()
except Exception:
logger.exception("Error during config-change reconciliation")
# ---------------------------------------------------------------------------
# WatchDaemon — manages child processes for multi-repo watching
# ---------------------------------------------------------------------------
class WatchDaemon:
"""Manages child processes for multi-repo file watching.
Each watched repository gets a ``code-review-graph watch`` child process
managed via :mod:`subprocess`. No external tools (tmux, screen, etc.)
are required.
"""
def __init__(
self,
config: DaemonConfig | None = None,
config_path: Path | None = None,
) -> None:
self._config: DaemonConfig = config or load_config(config_path)
self._config_path: Path = config_path or CONFIG_PATH
self._state_path: Path = STATE_PATH
self._children: dict[str, subprocess.Popen[bytes]] = {}
self._current_repos: dict[str, WatchRepo] = {}
self._config_watcher: ConfigWatcher | None = None
self._health_thread: threading.Thread | None = None
self._health_stop: threading.Event = threading.Event()
self._lock: threading.Lock = threading.Lock()
# ------------------------------------------------------------------
# Public interface
# ------------------------------------------------------------------
def start(self) -> None:
"""Spawn a watcher child process for each configured repo."""
logger.info("Starting daemon '%s'", self._config.session_name)
# Auto-register repos in the central registry
from .registry import Registry
registry = Registry()
for repo in self._config.repos:
registry.register(repo.path, alias=repo.alias)
# Build initial graph for repos that lack a database
for repo in self._config.repos:
db_path = Path(repo.path) / ".code-review-graph" / "graph.db"
if not db_path.exists():
self._initial_build(repo)
# Spawn a watcher child for every repo
for repo in self._config.repos:
self._start_watcher(repo)
# Track current state
self._current_repos = {r.alias: r for r in self._config.repos}
# Persist child PIDs to disk for cross-process status queries
self._save_state()
# Start watching the config file for live changes
self.start_config_watcher()
# Start health checker to auto-restart dead watchers
self.start_health_checker()
msg = f"Daemon started — watching {len(self._config.repos)} repo(s)"
logger.info(msg)
print(msg) # noqa: T201
def stop(self) -> None:
"""Tear down the daemon: stop watchers, terminate children."""
self.stop_config_watcher()
self.stop_health_checker()
with self._lock:
for alias, proc in list(self._children.items()):
self._terminate_child(alias, proc)
self._children.clear()
self._current_repos.clear()
self._clear_state()
clear_pid()
logger.info("Daemon stopped")
def reconcile(self, new_config: DaemonConfig | None = None) -> None:
"""Reconcile running watchers with the (possibly updated) config.
Child processes are started, stopped, or restarted to match the
desired state. New repos are registered in the central registry
and their graphs are built automatically (mirroring ``start()``).
"""
if new_config is not None:
self._config = new_config
desired: dict[str, WatchRepo] = {r.alias: r for r in self._config.repos}
current: set[str] = set(self._current_repos.keys())
to_add: set[str] = desired.keys() - current
to_remove: set[str] = current - desired.keys()
to_update: set[str] = {
alias
for alias in desired.keys() & current
if desired[alias].path != self._current_repos[alias].path
}
# Register new/updated repos and build graphs *before* acquiring
# the lock so that long-running builds don't block health checks.
if to_add or to_update:
from .registry import Registry
registry = Registry()
repos_needing_build: list[WatchRepo] = []
for alias in to_add | to_update:
repo = desired[alias]
registry.register(repo.path, alias=repo.alias)
db_path = Path(repo.path) / ".code-review-graph" / "graph.db"
if not db_path.exists():
repos_needing_build.append(repo)
for repo in repos_needing_build:
self._initial_build(repo)
with self._lock:
# Remove stale watchers
for alias in to_remove:
proc = self._children.pop(alias, None)
if proc is not None:
self._terminate_child(alias, proc)
del self._current_repos[alias]
# Add new watchers
for alias in to_add:
repo = desired[alias]
self._start_watcher(repo)
self._current_repos[alias] = repo
# Update changed watchers (path changed for same alias)
for alias in to_update:
proc = self._children.pop(alias, None)
if proc is not None:
self._terminate_child(alias, proc)
repo = desired[alias]
self._start_watcher(repo)
self._current_repos[alias] = repo
# Persist updated state
self._save_state()
logger.info(
"Reconcile complete — added: %d, removed: %d, updated: %d",
len(to_add),
len(to_remove),
len(to_update),
)
def status(self) -> dict[str, Any]:
"""Return a summary of daemon state.
When called from the daemon process itself, uses the in-memory
``_children`` dict. When called from a separate process (e.g. the
CLI ``status`` command), falls back to the persisted state file and
checks liveness via ``os.kill(pid, 0)``.
"""
repos: list[dict[str, Any]] = []
with self._lock:
if self._children:
# In-process: we have live Popen handles
for alias, repo in self._current_repos.items():
proc = self._children.get(alias)
alive = proc is not None and proc.poll() is None
repos.append(
{
"alias": alias,
"path": repo.path,
"alive": alive,
"pid": proc.pid if proc is not None else None,
}
)
else:
# Cross-process: read persisted state from disk
state = load_state(self._state_path)
for repo in self._config.repos:
entry = state.get(repo.alias, {})
pid: int | None = entry.get("pid")
alive = pid is not None and _is_pid_alive(pid)
repos.append(
{
"alias": repo.alias,
"path": repo.path,
"alive": alive,
"pid": pid,
}
)
return {
"session_name": self._config.session_name,
"running": True,
"repos": repos,
}
# ------------------------------------------------------------------
# Config watching
# ------------------------------------------------------------------
def start_config_watcher(self) -> None:
"""Begin watching the config file for live edits."""
self._config_watcher = ConfigWatcher(
config_path=self._config_path,
callback=self._on_config_change,
poll_interval=self._config.poll_interval,
)
self._config_watcher.start()
def _on_config_change(self) -> None:
"""Reload configuration and reconcile running watchers."""
try:
new_config = load_config(self._config_path)
except Exception:
logger.warning(
"Failed to parse config file — keeping last good config",
exc_info=True,
)
return
self.reconcile(new_config)
def stop_config_watcher(self) -> None:
"""Stop the config file watcher if running."""
if self._config_watcher is not None:
self._config_watcher.stop()
self._config_watcher = None
# ------------------------------------------------------------------
# Health checking
# ------------------------------------------------------------------
def start_health_checker(self) -> None:
"""Start the background health-check thread."""
self._health_stop = threading.Event()
self._health_thread = threading.Thread(
target=self._health_loop,
daemon=True,
name="health-checker",
)
self._health_thread.start()
logger.info(
"Health checker started (interval=%ds)",
_HEALTH_CHECK_INTERVAL,
)
def stop_health_checker(self) -> None:
"""Stop the health-check thread."""
if hasattr(self, "_health_stop"):
self._health_stop.set()
if hasattr(self, "_health_thread") and self._health_thread is not None:
self._health_thread.join(timeout=5)
self._health_thread = None
def _health_loop(self) -> None:
"""Periodically check child processes and restart dead ones."""
while not self._health_stop.is_set():
self._health_stop.wait(_HEALTH_CHECK_INTERVAL)
if self._health_stop.is_set():
break
self._check_health()
def _check_health(self) -> None:
"""Check each watcher child and restart if dead."""
restarted = False
with self._lock:
for alias, repo in list(self._current_repos.items()):
proc = self._children.get(alias)
if proc is None or proc.poll() is not None:
logger.warning("Watcher for '%s' is dead — restarting", alias)
# Clean up dead process entry
self._children.pop(alias, None)
self._start_watcher(repo)
restarted = True
if restarted:
self._save_state()
# ------------------------------------------------------------------
# Daemonization
# ------------------------------------------------------------------
def daemonize(self) -> None:
"""Fork to background using the double-fork pattern.
Redirects stdout/stderr to the daemon log file. Writes PID file.
Sets up SIGTERM handler for graceful shutdown.
On Windows, forking is not supported — the daemon runs in the
foreground and a warning is logged.
"""
if sys.platform == "win32":
logger.warning("Forking is not supported on Windows — running in foreground")
write_pid()
self._setup_signal_handlers()
return
# First fork
pid = os.fork()
if pid > 0:
# Parent exits
sys.exit(0)
# Become session leader
os.setsid()
# Second fork (prevent acquiring a controlling terminal)
pid = os.fork()
if pid > 0:
sys.exit(0)
# Redirect file descriptors
sys.stdout.flush()
sys.stderr.flush()
self._config.log_dir.mkdir(parents=True, exist_ok=True)
log_file = self._config.log_dir / "daemon.log"
# Open log file for stdout/stderr
fd = os.open(
str(log_file),
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
0o644,
)
os.dup2(fd, sys.stdout.fileno())
os.dup2(fd, sys.stderr.fileno())
# Redirect stdin from /dev/null
devnull = os.open(os.devnull, os.O_RDONLY)
os.dup2(devnull, sys.stdin.fileno())
os.close(devnull)
if fd > 2:
os.close(fd)
# Write PID file
write_pid()
# Set up signal handlers
self._setup_signal_handlers()
logger.info("Daemonized (PID %d)", os.getpid())
def _setup_signal_handlers(self) -> None:
"""Install SIGTERM/SIGHUP handlers for graceful shutdown."""
def _handle_sigterm(signum: int, frame: Any) -> None:
logger.info("Received signal %d — shutting down", signum)
self.stop()
sys.exit(0)
signal.signal(signal.SIGTERM, _handle_sigterm)
if sys.platform != "win32":
signal.signal(signal.SIGHUP, _handle_sigterm)
def run_forever(self) -> None:
"""Block forever, keeping the daemon alive.
The config watcher and health checker run in background threads.
This method sleeps in the main thread until interrupted.
"""
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("Keyboard interrupt — stopping daemon")
self.stop()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _save_state(self) -> None:
"""Persist child PIDs and repo paths to disk for cross-process queries.
Called after any mutation of ``_children`` so that ``status`` commands
running in a separate process can determine which watchers are alive.
"""
state: dict[str, dict[str, Any]] = {}
for alias, proc in self._children.items():
repo = self._current_repos.get(alias)
state[alias] = {
"pid": proc.pid,
"path": repo.path if repo else "",
}
try:
self._state_path.parent.mkdir(parents=True, exist_ok=True)
self._state_path.write_text(json.dumps(state), encoding="utf-8")
except OSError:
logger.warning("Failed to persist daemon state to %s", self._state_path)
def _clear_state(self) -> None:
"""Remove the state file from disk."""
try:
self._state_path.unlink(missing_ok=True)
except OSError:
pass
def _start_watcher(self, repo: WatchRepo) -> None:
"""Spawn a child process running ``code-review-graph watch`` for *repo*."""
self._config.log_dir.mkdir(parents=True, exist_ok=True)
log_path = self._config.log_dir / f"{repo.alias}.log"
crg_bin = shutil.which("code-review-graph")
if crg_bin:
cmd: list[str] = [crg_bin, "watch", "--repo", repo.path]
else:
cmd = [
sys.executable,
"-m",
"code_review_graph",
"watch",
"--repo",
repo.path,
]
log_fd = open(log_path, "ab") # noqa: SIM115
try:
proc = subprocess.Popen(
cmd,
cwd=repo.path,
stdout=log_fd,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
)
except Exception:
log_fd.close()
logger.exception("Failed to start watcher for '%s'", repo.alias)
return
# The log fd is inherited by the child; we can close our copy.
# The child keeps the fd open via its own reference.
log_fd.close()
self._children[repo.alias] = proc
logger.info(
"Started watcher for '%s' (PID %d) — log: %s",
repo.alias,
proc.pid,
log_path,
)
@staticmethod
def _terminate_child(alias: str, proc: subprocess.Popen[bytes]) -> None:
"""Gracefully terminate a child process (SIGTERM, then SIGKILL)."""
if proc.poll() is not None:
return # already dead
logger.info("Terminating watcher '%s' (PID %d)", alias, proc.pid)
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
logger.warning("Watcher '%s' did not stop — sending SIGKILL", alias)
proc.kill()
proc.wait(timeout=5)
def _initial_build(self, repo: WatchRepo) -> None:
"""Run a one-off graph build for a repo that has no database yet."""
logger.info("Building initial graph for %s...", repo.alias)
crg_bin = shutil.which("code-review-graph")
if crg_bin:
cmd: list[str] = [crg_bin, "build", "--repo", repo.path]
else:
cmd = [
sys.executable,
"-m",
"code_review_graph",
"build",
"--repo",
repo.path,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
logger.warning(
"Initial build for '%s' failed (rc=%d): %s",
repo.alias,
result.returncode,
result.stderr.strip(),
)