Files
tirth8205--code-review-graph/code_review_graph/daemon_cli.py
T
2026-07-13 12:42:18 +08:00

321 lines
8.9 KiB
Python

"""CLI entry point for the crg-daemon multi-repo watcher.
Usage:
crg-daemon start [--foreground]
crg-daemon stop
crg-daemon restart [--foreground]
crg-daemon status
crg-daemon logs [--repo ALIAS] [--follow] [--lines N]
crg-daemon add <path> [--alias ALIAS]
crg-daemon remove <path_or_alias>
"""
from __future__ import annotations
import argparse
import logging
import os
import signal
import subprocess
import sys
import time
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------
def _handle_start(args: argparse.Namespace) -> None:
"""Start the daemon process."""
from .daemon import WatchDaemon, is_daemon_running, load_config
if is_daemon_running():
print("Error: Daemon is already running.")
sys.exit(1)
config = load_config()
daemon = WatchDaemon(config=config)
daemon.start()
if not args.foreground:
daemon.daemonize()
daemon.run_forever()
def _handle_stop(_args: argparse.Namespace) -> None:
"""Stop the running daemon process."""
from .daemon import clear_pid, is_daemon_running, read_pid
if not is_daemon_running():
print("Daemon is not running.")
sys.exit(1)
pid = read_pid()
if pid is None:
print("Error: Could not read daemon PID.")
sys.exit(1)
print(f"Stopping daemon (PID {pid})...")
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
clear_pid()
print("Daemon stopped (process already gone).")
return
except PermissionError:
print(f"Error: Permission denied sending signal to PID {pid}.")
sys.exit(1)
# Wait up to 5 seconds for process to die
for _ in range(50):
try:
os.kill(pid, 0)
except ProcessLookupError:
break
time.sleep(0.1)
else:
# Still alive after 5s — send SIGKILL
print("Daemon did not stop gracefully, sending SIGKILL...")
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
clear_pid()
print("Daemon stopped.")
def _handle_restart(args: argparse.Namespace) -> None:
"""Restart the daemon (stop + start)."""
from .daemon import is_daemon_running
if is_daemon_running():
_handle_stop(args)
else:
print("Daemon is not running, starting fresh.")
_handle_start(args)
def _handle_status(_args: argparse.Namespace) -> None:
"""Show daemon status and configuration."""
from .daemon import is_daemon_running, load_config, load_state, pid_alive, read_pid
config = load_config()
running = is_daemon_running()
if running:
pid = read_pid()
print(f"Daemon: running (PID {pid})")
else:
print("Daemon: not running")
print(f"Name: {config.session_name}")
print(f"Log dir: {config.log_dir}")
print(f"Poll: {config.poll_interval}s")
print()
if not config.repos:
print("No repositories configured.")
print("Use: crg-daemon add <path> [--alias NAME]")
return
# Header
alias_width = max(len(r.alias) for r in config.repos)
alias_width = max(alias_width, 5) # minimum "Alias" header width
if running:
state = load_state()
print(f" {'Alias':<{alias_width}} {'Status':<8} {'PID':<8} Path")
print(f" {'-' * alias_width} {'-' * 8} {'-' * 8} {'-' * 40}")
for repo in config.repos:
entry = state.get(repo.alias, {})
child_pid: int | None = entry.get("pid")
alive = child_pid is not None and pid_alive(child_pid)
status_str = "alive" if alive else "dead"
pid_str = str(child_pid) if child_pid is not None else "-"
print(f" {repo.alias:<{alias_width}} {status_str:<8} {pid_str:<8} {repo.path}")
else:
print(f" {'Alias':<{alias_width}} Path")
print(f" {'-' * alias_width} {'-' * 40}")
for repo in config.repos:
print(f" {repo.alias:<{alias_width}} {repo.path}")
def _handle_logs(args: argparse.Namespace) -> None:
"""Show daemon or per-repo log files."""
from .daemon import load_config
config = load_config()
if args.repo:
log_file = config.log_dir / f"{args.repo}.log"
else:
log_file = config.log_dir / "daemon.log"
if not log_file.exists():
print(f"Log file not found: {log_file}")
sys.exit(1)
if args.follow:
try:
subprocess.run(["tail", "-f", str(log_file)], check=False)
except KeyboardInterrupt:
pass
return
# Read last N lines
lines_count = args.lines
try:
text = log_file.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
print(f"Error reading log file: {exc}")
sys.exit(1)
lines = text.splitlines()
tail = lines[-lines_count:] if len(lines) > lines_count else lines
for line in tail:
print(line)
def _handle_add(args: argparse.Namespace) -> None:
"""Add a repository to the daemon config."""
from .daemon import add_repo_to_config, is_daemon_running
try:
add_repo_to_config(args.path, alias=args.alias)
except ValueError as exc:
print(f"Error: {exc}")
sys.exit(1)
# Find the repo we just added to show confirmation
alias = args.alias or os.path.basename(os.path.abspath(args.path))
print(f"Added repository: {args.path} (alias: {alias})")
if is_daemon_running():
print("Daemon will pick up the change automatically.")
def _handle_remove(args: argparse.Namespace) -> None:
"""Remove a repository from the daemon config."""
from .daemon import is_daemon_running, load_config, remove_repo_from_config
config_before = load_config()
count_before = len(config_before.repos)
config_after = remove_repo_from_config(args.path_or_alias)
count_after = len(config_after.repos)
if count_before == count_after:
print(f"No repository matching '{args.path_or_alias}' found in config.")
sys.exit(1)
print(f"Removed repository: {args.path_or_alias}")
if is_daemon_running():
print("Daemon will pick up the change automatically.")
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Entry point for the crg-daemon CLI."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
ap = argparse.ArgumentParser(
prog="crg-daemon",
description="Multi-repo watch daemon for code-review-graph",
)
sub = ap.add_subparsers(dest="command")
# start
start_cmd = sub.add_parser("start", help="Start the daemon")
start_cmd.add_argument(
"--foreground",
action="store_true",
help="Run in the foreground instead of daemonizing",
)
# stop
sub.add_parser("stop", help="Stop the daemon")
# restart
restart_cmd = sub.add_parser("restart", help="Restart the daemon")
restart_cmd.add_argument(
"--foreground",
action="store_true",
help="Run in the foreground instead of daemonizing",
)
# status
sub.add_parser("status", help="Show daemon status and configuration")
# logs
logs_cmd = sub.add_parser("logs", help="Show daemon or per-repo logs")
logs_cmd.add_argument(
"--repo",
default=None,
metavar="ALIAS",
help="Show logs for a specific repo (by alias)",
)
logs_cmd.add_argument(
"--follow",
"-f",
action="store_true",
help="Follow log output (tail -f)",
)
logs_cmd.add_argument(
"--lines",
"-n",
type=int,
default=50,
help="Number of lines to show (default: 50)",
)
# add
add_cmd = sub.add_parser("add", help="Add a repository to the daemon config")
add_cmd.add_argument("path", help="Path to the repository")
add_cmd.add_argument(
"--alias",
default=None,
help="Short alias for the repository (default: directory name)",
)
# remove
remove_cmd = sub.add_parser("remove", help="Remove a repository from the daemon config")
remove_cmd.add_argument("path_or_alias", help="Repository path or alias to remove")
args = ap.parse_args()
if not args.command:
ap.print_help()
sys.exit(0)
handlers: dict[str, object] = {
"start": _handle_start,
"stop": _handle_stop,
"restart": _handle_restart,
"status": _handle_status,
"logs": _handle_logs,
"add": _handle_add,
"remove": _handle_remove,
}
handler = handlers.get(args.command)
if handler is None:
ap.print_help()
sys.exit(1)
handler(args) # type: ignore[operator]
if __name__ == "__main__":
main()