diff --git a/.beads/.gitignore b/.beads/.gitignore new file mode 100644 index 0000000..eb82c48 --- /dev/null +++ b/.beads/.gitignore @@ -0,0 +1,72 @@ +# Dolt database (managed by Dolt, not git) +dolt/ + +# Runtime files +bd.sock +bd.sock.startlock +sync-state.json +last-touched +.exclusive-lock + +# Daemon runtime (lock, log, pid) +daemon.* + +# Interactions log (runtime, not versioned) +interactions.jsonl + +# Push state (runtime, per-machine) +push-state.json + +# Lock files (various runtime locks) +*.lock + +# Credential key (encryption key for federation peer auth — never commit) +.beads-credential-key + +# Local version tracking (prevents upgrade notification spam after git ops) +.local_version + +# Worktree redirect file (contains relative path to main repo's .beads/) +# Must not be committed as paths would be wrong in other clones +redirect + +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +export-state/ +export-state.json + +# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) +ephemeral.sqlite3 +ephemeral.sqlite3-journal +ephemeral.sqlite3-wal +ephemeral.sqlite3-shm + +# Dolt server management (auto-started by bd) +dolt-server.pid +dolt-server.log +dolt-server.lock +dolt-server.port +dolt-server.activity + +# Corrupt backup directories (created by bd doctor --fix recovery) +*.corrupt.backup/ + +# Backup data (auto-exported JSONL, local-only) +backup/ + +# Per-project environment file (Dolt connection config, GH#2520) +.env + +# Legacy files (from pre-Dolt versions) +*.db +*.db?* +*.db-journal +*.db-wal +*.db-shm +db.sqlite +bd.db +# NOTE: Do NOT add negation patterns here. +# They would override fork protection in .git/info/exclude. +# Config files (metadata.json, config.yaml) are tracked by git by default +# since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 0000000..dbfe363 --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --claim +bd update --status done + +# Sync with Dolt remote +bd dolt push +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in Dolt database with version control and branching +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Dolt-native three-way merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 0000000..232b151 --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,54 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: JSONL-only, no Dolt database +# When true, bd will use .beads/issues.jsonl as the source of truth +# no-db: false + +# Enable JSON output by default +# json: false + +# Feedback title formatting for mutating commands (create/update/close/dep/edit) +# 0 = hide titles, N > 0 = truncate to N characters +# output: +# title-length: 255 + +# Default actor for audit trails (overridden by BEADS_ACTOR or --actor) +# actor: "" + +# Export events (audit trail) to .beads/events.jsonl on each flush/sync +# When enabled, new events are appended incrementally using a high-water mark. +# Use 'bd export --events' to trigger manually regardless of this setting. +# events-export: false + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct database +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# JSONL backup (periodic export for off-machine recovery) +# Auto-enabled when a git remote exists. Override explicitly: +# backup: +# enabled: false # Disable auto-backup entirely +# interval: 15m # Minimum time between auto-exports +# git-push: false # Disable git push (export locally only) +# git-repo: "" # Separate git repo for backups (default: project repo) + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/hooks/post-checkout b/.beads/hooks/post-checkout new file mode 100755 index 0000000..67ad327 --- /dev/null +++ b/.beads/hooks/post-checkout @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v1.0.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run post-checkout "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run post-checkout "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'post-checkout'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v1.0.0 --- diff --git a/.beads/hooks/post-merge b/.beads/hooks/post-merge new file mode 100755 index 0000000..a731aec --- /dev/null +++ b/.beads/hooks/post-merge @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v1.0.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run post-merge "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run post-merge "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'post-merge'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v1.0.0 --- diff --git a/.beads/hooks/pre-commit b/.beads/hooks/pre-commit new file mode 100755 index 0000000..02cf2ac --- /dev/null +++ b/.beads/hooks/pre-commit @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v1.0.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run pre-commit "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run pre-commit "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'pre-commit'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v1.0.0 --- diff --git a/.beads/hooks/pre-push b/.beads/hooks/pre-push new file mode 100755 index 0000000..7918492 --- /dev/null +++ b/.beads/hooks/pre-push @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v1.0.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run pre-push "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run pre-push "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'pre-push'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v1.0.0 --- diff --git a/.beads/hooks/prepare-commit-msg b/.beads/hooks/prepare-commit-msg new file mode 100755 index 0000000..c0c3ce1 --- /dev/null +++ b/.beads/hooks/prepare-commit-msg @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v1.0.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run prepare-commit-msg "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v1.0.0 --- diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..7a934e6 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,7 @@ +{ + "database": "dolt", + "backend": "dolt", + "dolt_mode": "embedded", + "dolt_database": "code_review_graph", + "project_id": "487c4722-5abe-4a00-92da-9e60064d65d0" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5036fd6..762e426 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,7 @@ design-system-audit.md research-synthesis.md code-review-graph-analysis.md SCALING_AND_TOKEN_EFFICIENCY_PLAN.md + +# Beads / Dolt files (added by bd init) +.dolt/ +.beads-credential-key diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9390d72 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,84 @@ +# Agent Instructions + +This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context. + +## Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work atomically +bd close # Complete work +bd dolt push # Push beads data to remote +``` + +## Non-Interactive Shell Commands + +**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts. + +Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input. + +**Use these forms instead:** +```bash +# Force overwrite without prompting +cp -f source dest # NOT: cp source dest +mv -f source dest # NOT: mv source dest +rm -f file # NOT: rm file + +# For recursive operations +rm -rf directory # NOT: rm -r directory +cp -rf source dest # NOT: cp -r source dest +``` + +**Other commands that may prompt:** +- `scp` - use `-o BatchMode=yes` for non-interactive +- `ssh` - use `-o BatchMode=yes` to fail instead of prompting +- `apt-get` - use `-y` flag +- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var + + +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files + +## Session Completion + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + diff --git a/CLAUDE.md b/CLAUDE.md index 682cfea..65e6cbf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,3 +113,51 @@ uv run code-review-graph eval # Run evaluation benchmarks - **type-check**: mypy - **security**: bandit scan - **test**: pytest matrix (3.10, 3.11, 3.12, 3.13) with 50% coverage minimum + + + +## Beads Issue Tracker + +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files + +## Session Completion + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + diff --git a/README.md b/README.md index 3b35695..5fde86f 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ The blast-radius analysis never misses an actually impacted file (perfect recall | **Refactoring tools** | Rename preview, dead code detection, community-driven suggestions | | **Wiki generation** | Auto-generate markdown wiki from community structure | | **Multi-repo registry** | Register multiple repos, search across all of them | +| **Multi-repo daemon** | `crg-daemon` watches multiple repos as child processes, with health checks and auto-restart | | **MCP prompts** | 5 workflow templates: review, architecture, debug, onboard, pre-merge | | **Full-text search** | FTS5-powered hybrid search combining keyword and vector similarity | @@ -232,12 +233,66 @@ code-review-graph detect-changes # Risk-scored change impact analysis code-review-graph register # Register repo in multi-repo registry code-review-graph unregister # Remove repo from registry code-review-graph repos # List registered repositories +code-review-graph daemon start # Start multi-repo watch daemon +code-review-graph daemon stop # Stop the daemon +code-review-graph daemon status # Show daemon status and repos code-review-graph eval # Run evaluation benchmarks code-review-graph serve # Start MCP server ``` +
+Multi-repo daemon +
+ +If your editor doesn't support hooks (e.g. Cursor, OpenCode), or you just want your +graph to stay fresh in the background without any editor integration, the daemon is +for you. It watches your repos for file changes and automatically rebuilds the graph +— no manual `build` or `update` commands needed. + +The daemon is included with `code-review-graph` — no separate install required. + +**Quick setup:** + +```bash +# 1. Register the repos you want to watch +crg-daemon add ~/project-a --alias proj-a +crg-daemon add ~/project-b + +# 2. Start the daemon (runs in the background) +crg-daemon start + +# 3. That's it — graphs stay up to date automatically +crg-daemon status # check daemon and per-repo watcher status +crg-daemon logs --repo proj-a -f # tail logs for a specific repo +crg-daemon stop # stop daemon and all watcher processes +``` + +Also available as `code-review-graph daemon start|stop|status|...`. + +Under the hood, `crg-daemon add` writes to a TOML config file at +`~/.code-review-graph/watch.toml`. You can also edit this file directly: + +```toml +[[repos]] +path = "/home/user/project-a" +alias = "proj-a" + +[[repos]] +path = "/home/user/project-b" +alias = "project-b" +``` + +The daemon monitors this config file for changes and automatically starts/stops +watcher processes as repos are added or removed. Health checks every 30 seconds +restart dead watchers. No external dependencies required. + +See [docs/COMMANDS.md](docs/COMMANDS.md#standalone-daemon-cli-crg-daemon) for the +full config reference and all available options. + +
+
22 MCP tools
diff --git a/code_review_graph/cli.py b/code_review_graph/cli.py index 3fb4f93..040dcb6 100644 --- a/code_review_graph/cli.py +++ b/code_review_graph/cli.py @@ -14,6 +14,13 @@ code-review-graph register [--alias name] code-review-graph unregister code-review-graph repos + code-review-graph daemon start [--foreground] + code-review-graph daemon stop + code-review-graph daemon restart [--foreground] + code-review-graph daemon status + code-review-graph daemon logs [--repo ALIAS] [-f] [-n N] + code-review-graph daemon add [--alias NAME] + code-review-graph daemon remove """ from __future__ import annotations @@ -59,12 +66,12 @@ def _print_banner() -> None: version = _get_version() # ANSI escape codes - c = "\033[36m" if color else "" # cyan — graph art - y = "\033[33m" if color else "" # yellow — center node - b = "\033[1m" if color else "" # bold - d = "\033[2m" if color else "" # dim - g = "\033[32m" if color else "" # green — commands - r = "\033[0m" if color else "" # reset + c = "\033[36m" if color else "" # cyan — graph art + y = "\033[33m" if color else "" # yellow — center node + b = "\033[1m" if color else "" # bold + d = "\033[2m" if color else "" # dim + g = "\033[32m" if color else "" # green — commands + r = "\033[0m" if color else "" # reset print(f""" {c} ●──●──●{r} @@ -87,6 +94,7 @@ def _print_banner() -> None: {g}unregister{r} Remove a repository from the registry {g}repos{r} List registered repositories {g}postprocess{r} Run post-processing {d}(flows, communities, FTS){r} + {g}daemon{r} Multi-repo watch daemon management {g}eval{r} Run evaluation benchmarks {g}serve{r} Start MCP server @@ -157,68 +165,82 @@ def main() -> None: prog="code-review-graph", description="Persistent incremental knowledge graph for code reviews", ) - ap.add_argument( - "-v", "--version", action="store_true", help="Show version and exit" - ) + ap.add_argument("-v", "--version", action="store_true", help="Show version and exit") sub = ap.add_subparsers(dest="command") # install (primary) + init (alias) - install_cmd = sub.add_parser( - "install", help="Register MCP server with AI coding platforms" - ) + install_cmd = sub.add_parser("install", help="Register MCP server with AI coding platforms") install_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") install_cmd.add_argument( - "--dry-run", action="store_true", + "--dry-run", + action="store_true", help="Show what would be done without writing files", ) install_cmd.add_argument( - "--no-skills", action="store_true", + "--no-skills", + action="store_true", help="Skip generating Claude Code skill files", ) install_cmd.add_argument( - "--no-hooks", action="store_true", + "--no-hooks", + action="store_true", help="Skip installing Claude Code hooks", ) # Legacy flags (kept for backwards compat, now no-ops since all is default) install_cmd.add_argument("--skills", action="store_true", help=argparse.SUPPRESS) install_cmd.add_argument("--hooks", action="store_true", help=argparse.SUPPRESS) - install_cmd.add_argument("--all", action="store_true", dest="install_all", - help=argparse.SUPPRESS) + install_cmd.add_argument( + "--all", action="store_true", dest="install_all", help=argparse.SUPPRESS + ) install_cmd.add_argument( "--platform", choices=[ - "claude", "claude-code", "cursor", "windsurf", "zed", - "continue", "opencode", "antigravity", "all", + "claude", + "claude-code", + "cursor", + "windsurf", + "zed", + "continue", + "opencode", + "antigravity", + "all", ], default="all", help="Target platform for MCP config (default: all detected)", ) - init_cmd = sub.add_parser( - "init", help="Alias for install" - ) + init_cmd = sub.add_parser("init", help="Alias for install") init_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") init_cmd.add_argument( - "--dry-run", action="store_true", + "--dry-run", + action="store_true", help="Show what would be done without writing files", ) init_cmd.add_argument( - "--no-skills", action="store_true", + "--no-skills", + action="store_true", help="Skip generating Claude Code skill files", ) init_cmd.add_argument( - "--no-hooks", action="store_true", + "--no-hooks", + action="store_true", help="Skip installing Claude Code hooks", ) init_cmd.add_argument("--skills", action="store_true", help=argparse.SUPPRESS) init_cmd.add_argument("--hooks", action="store_true", help=argparse.SUPPRESS) - init_cmd.add_argument("--all", action="store_true", dest="install_all", - help=argparse.SUPPRESS) + init_cmd.add_argument("--all", action="store_true", dest="install_all", help=argparse.SUPPRESS) init_cmd.add_argument( "--platform", choices=[ - "claude", "claude-code", "cursor", "windsurf", "zed", - "continue", "opencode", "antigravity", "all", + "claude", + "claude-code", + "cursor", + "windsurf", + "zed", + "continue", + "opencode", + "antigravity", + "all", ], default="all", help="Target platform for MCP config (default: all detected)", @@ -228,11 +250,13 @@ def main() -> None: build_cmd = sub.add_parser("build", help="Full graph build (re-parse all files)") build_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") build_cmd.add_argument( - "--skip-flows", action="store_true", + "--skip-flows", + action="store_true", help="Skip flow/community detection (signatures + FTS only)", ) build_cmd.add_argument( - "--skip-postprocess", action="store_true", + "--skip-postprocess", + action="store_true", help="Skip all post-processing (raw parse only)", ) @@ -241,11 +265,13 @@ def main() -> None: update_cmd.add_argument("--base", default="HEAD~1", help="Git diff base (default: HEAD~1)") update_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") update_cmd.add_argument( - "--skip-flows", action="store_true", + "--skip-flows", + action="store_true", help="Skip flow/community detection (signatures + FTS only)", ) update_cmd.add_argument( - "--skip-postprocess", action="store_true", + "--skip-postprocess", + action="store_true", help="Skip all post-processing (raw parse only)", ) @@ -277,7 +303,8 @@ def main() -> None: help="Rendering mode: auto (default), full, community, or file", ) vis_cmd.add_argument( - "--serve", action="store_true", + "--serve", + action="store_true", help="Start a local HTTP server to view the visualization (localhost:8765)", ) @@ -285,7 +312,8 @@ def main() -> None: wiki_cmd = sub.add_parser("wiki", help="Generate markdown wiki from community structure") wiki_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") wiki_cmd.add_argument( - "--force", action="store_true", + "--force", + action="store_true", help="Regenerate all pages even if content unchanged", ) @@ -308,9 +336,10 @@ def main() -> None: # eval eval_cmd = sub.add_parser("eval", help="Run evaluation benchmarks") eval_cmd.add_argument( - "--benchmark", default=None, + "--benchmark", + default=None, help="Comma-separated benchmarks to run (token_efficiency, impact_accuracy, " - "flow_completeness, search_quality, build_performance)", + "flow_completeness, search_quality, build_performance)", ) eval_cmd.add_argument("--repo", default=None, help="Comma-separated repo config names") eval_cmd.add_argument("--all", action="store_true", dest="run_all", help="Run all benchmarks") @@ -319,18 +348,89 @@ def main() -> None: # detect-changes detect_cmd = sub.add_parser("detect-changes", help="Analyze change impact") - detect_cmd.add_argument( - "--base", default="HEAD~1", help="Git diff base (default: HEAD~1)" - ) - detect_cmd.add_argument( - "--brief", action="store_true", help="Show brief summary only" - ) + detect_cmd.add_argument("--base", default="HEAD~1", help="Git diff base (default: HEAD~1)") + detect_cmd.add_argument("--brief", action="store_true", help="Show brief summary only") detect_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") # serve serve_cmd = sub.add_parser("serve", help="Start MCP server (stdio transport)") serve_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") + # daemon + daemon_cmd = sub.add_parser( + "daemon", + help="Multi-repo watch daemon (start/stop/status/add/remove)", + ) + daemon_sub = daemon_cmd.add_subparsers(dest="daemon_command") + + daemon_start = daemon_sub.add_parser( + "start", + help="Start the watch daemon", + ) + daemon_start.add_argument( + "--foreground", + action="store_true", + help="Run in foreground instead of daemonizing", + ) + + daemon_sub.add_parser( + "stop", + help="Stop the watch daemon", + ) + + daemon_restart = daemon_sub.add_parser( + "restart", + help="Restart the watch daemon", + ) + daemon_restart.add_argument( + "--foreground", + action="store_true", + help="Run in foreground instead of daemonizing", + ) + + daemon_sub.add_parser("status", help="Show daemon and watcher status") + + daemon_logs = daemon_sub.add_parser( + "logs", + help="View daemon or watcher logs", + ) + daemon_logs.add_argument( + "--repo", + default=None, + help="Show logs for a specific repo alias", + ) + daemon_logs.add_argument( + "--follow", + action="store_true", + help="Follow log output (tail -f)", + ) + daemon_logs.add_argument( + "--lines", + type=int, + default=50, + help="Number of lines to show (default: 50)", + ) + + daemon_add = daemon_sub.add_parser( + "add", + help="Add a repo to the watch config", + ) + daemon_add.add_argument("path", help="Path to the repository") + daemon_add.add_argument( + "--alias", + default=None, + help="Short alias for the repo", + ) + + daemon_remove = daemon_sub.add_parser( + "remove", + help="Remove a repo from the watch config", + ) + daemon_remove.add_argument( + "path_or_alias", + help="Repository path or alias to remove", + ) + args = ap.parse_args() if args.version: @@ -343,17 +443,44 @@ def main() -> None: if args.command == "serve": from .main import main as serve_main + serve_main(repo_root=args.repo) return + if args.command == "daemon": + if not args.daemon_command: + daemon_cmd.print_help() + return + from .daemon_cli import ( + _handle_add, + _handle_logs, + _handle_remove, + _handle_restart, + _handle_start, + _handle_status, + _handle_stop, + ) + + handlers = { + "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.daemon_command) + if handler: + handler(args) + return + if args.command == "eval": from .eval.reporter import generate_full_report, generate_readme_tables from .eval.runner import run_eval if getattr(args, "report", False): - output_dir = Path( - getattr(args, "output_dir", None) or "evaluate/results" - ) + output_dir = Path(getattr(args, "output_dir", None) or "evaluate/results") report = generate_full_report(output_dir) report_path = Path("evaluate/reports/summary.md") report_path.parent.mkdir(parents=True, exist_ok=True) @@ -365,9 +492,7 @@ def main() -> None: print(tables) else: repos = ( - [r.strip() for r in args.repo.split(",")] - if getattr(args, "repo", None) - else None + [r.strip() for r in args.repo.split(",")] if getattr(args, "repo", None) else None ) benchmarks = ( [b.strip() for b in args.benchmark.split(",")] @@ -439,6 +564,7 @@ def main() -> None: store = GraphStore(db_path) try: from .tools.build import run_postprocess + result = run_postprocess( flows=not getattr(args, "no_flows", False), communities=not getattr(args, "no_communities", False), @@ -475,32 +601,38 @@ def main() -> None: try: if args.command == "build": - pp = "none" if getattr(args, "skip_postprocess", False) else ( - "minimal" if getattr(args, "skip_flows", False) else "full" + pp = ( + "none" + if getattr(args, "skip_postprocess", False) + else ("minimal" if getattr(args, "skip_flows", False) else "full") ) from .tools.build import build_or_update_graph + result = build_or_update_graph( - full_rebuild=True, repo_root=str(repo_root), postprocess=pp, + full_rebuild=True, + repo_root=str(repo_root), + postprocess=pp, ) parsed = result.get("files_parsed", 0) nodes = result.get("total_nodes", 0) edges = result.get("total_edges", 0) - print( - f"Full build: {parsed} files, " - f"{nodes} nodes, {edges} edges" - f" (postprocess={pp})" - ) + print(f"Full build: {parsed} files, {nodes} nodes, {edges} edges (postprocess={pp})") if result.get("errors"): print(f"Errors: {len(result['errors'])}") elif args.command == "update": - pp = "none" if getattr(args, "skip_postprocess", False) else ( - "minimal" if getattr(args, "skip_flows", False) else "full" + pp = ( + "none" + if getattr(args, "skip_postprocess", False) + else ("minimal" if getattr(args, "skip_flows", False) else "full") ) from .tools.build import build_or_update_graph + result = build_or_update_graph( - full_rebuild=False, repo_root=str(repo_root), - base=args.base, postprocess=pp, + full_rebuild=False, + repo_root=str(repo_root), + base=args.base, + postprocess=pp, ) updated = result.get("files_updated", 0) nodes = result.get("total_nodes", 0) @@ -526,6 +658,7 @@ def main() -> None: if stored_sha: print(f"Built at commit: {stored_sha[:12]}") from .incremental import _git_branch_info + current_branch, current_sha = _git_branch_info(repo_root) if stored_branch and current_branch and stored_branch != current_branch: print( @@ -539,6 +672,7 @@ def main() -> None: elif args.command == "visualize": from .visualization import generate_html + html_path = repo_root / ".code-review-graph" / "graph.html" vis_mode = getattr(args, "mode", "auto") or "auto" generate_html(store, html_path, mode=vis_mode) @@ -565,6 +699,7 @@ def main() -> None: elif args.command == "wiki": from .wiki import generate_wiki + wiki_dir = repo_root / ".code-review-graph" / "wiki" result = generate_wiki(store, wiki_dir, force=args.force) total = result["pages_generated"] + result["pages_updated"] + result["pages_unchanged"] diff --git a/code_review_graph/daemon.py b/code_review_graph/daemon.py new file mode 100644 index 0000000..4675b15 --- /dev/null +++ b/code_review_graph/daemon.py @@ -0,0 +1,952 @@ +"""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 + + if not (repo_path / ".git").exists() and not (repo_path / ".code-review-graph").exists(): + logger.warning( + "Skipping repo %s — no .git 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}") + + if not (resolved / ".git").exists() and not (resolved / ".code-review-graph").exists(): + raise ValueError(f"No .git 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 + + +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 + try: + os.kill(pid, 0) # signal 0 = existence check + return True + except ProcessLookupError: + # Stale PID file — clean up + clear_pid(path) + return False + except PermissionError: + return True # process exists but owned by another user + + +# --------------------------------------------------------------------------- +# 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.""" + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True # exists but owned by another user + + +# --------------------------------------------------------------------------- +# 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(), + ) diff --git a/code_review_graph/daemon_cli.py b/code_review_graph/daemon_cli.py new file mode 100644 index 0000000..dc59e1c --- /dev/null +++ b/code_review_graph/daemon_cli.py @@ -0,0 +1,328 @@ +"""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 [--alias ALIAS] + crg-daemon remove +""" + +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, 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 [--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 = False + if child_pid is not None: + try: + os.kill(child_pid, 0) + alive = True + except ProcessLookupError: + alive = False + except PermissionError: + alive = True + 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() diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 7caae26..a76690b 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -250,9 +250,59 @@ code-review-graph register [--alias name] # Register a repository code-review-graph unregister # Remove from registry code-review-graph repos # List registered repositories +# Daemon (multi-repo watcher) — included with install, no extra dependencies +code-review-graph daemon start [--foreground] # Start the watch daemon +code-review-graph daemon stop # Stop the daemon +code-review-graph daemon restart [--foreground] # Restart the daemon +code-review-graph daemon status # Show daemon status and repos +code-review-graph daemon logs [--repo ALIAS] [-f] # View daemon or per-repo logs +code-review-graph daemon add [--alias NAME] # Add a repo to daemon config +code-review-graph daemon remove # Remove a repo from daemon config + # Evaluation code-review-graph eval # Run evaluation benchmarks # Server code-review-graph serve # Start MCP server (stdio) ``` + +## Standalone Daemon CLI (`crg-daemon`) + +The `crg-daemon` command is included with every `code-review-graph` installation — no +separate install required. It is also available as a standalone entry point. It mirrors the +`code-review-graph daemon` subcommands: + +```bash +crg-daemon start [--foreground] # Start the multi-repo watch daemon +crg-daemon stop # Stop the daemon and all watcher processes +crg-daemon restart [--foreground] # Restart (stop + start) +crg-daemon status # Show daemon status, repos, and process liveness +crg-daemon logs [--repo ALIAS] [-f] [-n N] # Tail daemon or per-repo log files +crg-daemon add [--alias NAME] # Add a repository to watch.toml +crg-daemon remove # Remove a repository from watch.toml +``` + +### Configuration + +The daemon reads its configuration from `~/.code-review-graph/watch.toml`: + +```toml +session_name = "crg-watch" # logical daemon name +log_dir = "~/.code-review-graph/logs" +poll_interval = 2 # seconds between config file polls + +[[repos]] +path = "/home/user/project-a" +alias = "project-a" + +[[repos]] +path = "/home/user/project-b" +alias = "project-b" +``` + +The daemon spawns one `code-review-graph watch` child process per repo, +managed via `subprocess.Popen`. It monitors the config file for changes and +automatically reconciles child processes (starting/stopping as repos are +added or removed). Health checks run every 30 seconds and automatically +restart dead watchers. No external dependencies (tmux, screen, etc.) are +required. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b020b98..2dc944d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,6 +2,16 @@ ## Shipped +### v2.2.0 +- Multi-repo watch daemon (`crg-daemon` / `code-review-graph daemon`) +- TOML-based daemon configuration (`~/.code-review-graph/watch.toml`) +- Child process management: one `code-review-graph watch` process per repo +- Config file watching with automatic reconciliation of watcher processes +- Daemonization with PID file management +- Health checking with automatic restart of dead watchers +- Standalone `crg-daemon` CLI entry point (7 subcommands) +- Integrated `daemon` subcommand group in main CLI + ### v2.0.0 - 22 MCP tools (up from 9) and 5 MCP prompts - 18 languages (added Dart, R, Perl) diff --git a/pyproject.toml b/pyproject.toml index 4d13fb3..440e776 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "tree-sitter-language-pack>=0.3.0,<1", "networkx>=3.2,<4", "watchdog>=4.0.0,<6", + "tomli>=2.0.0,<3; python_version < '3.11'", ] [project.urls] @@ -42,6 +43,7 @@ Issues = "https://github.com/tirth8205/code-review-graph/issues" [project.scripts] code-review-graph = "code_review_graph.cli:main" +crg-daemon = "code_review_graph.daemon_cli:main" [project.optional-dependencies] embeddings = [ diff --git a/tests/test_daemon.py b/tests/test_daemon.py new file mode 100644 index 0000000..852dac3 --- /dev/null +++ b/tests/test_daemon.py @@ -0,0 +1,942 @@ +"""Tests for daemon config, PID management, WatchDaemon, and CLI.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from code_review_graph.daemon import ( + DaemonConfig, + WatchDaemon, + WatchRepo, + add_repo_to_config, + clear_pid, + is_daemon_running, + load_config, + load_state, + read_pid, + remove_repo_from_config, + save_config, + write_pid, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def sample_config_file(tmp_path): + """Create a valid watch.toml with temp repos that have .git dirs.""" + repo_a = tmp_path / "repo-a" + repo_a.mkdir() + (repo_a / ".git").mkdir() + + repo_b = tmp_path / "repo-b" + repo_b.mkdir() + (repo_b / ".git").mkdir() + + config = tmp_path / "watch.toml" + config.write_text( + f"[daemon]\n" + f'session_name = "test-session"\n' + f'log_dir = "{tmp_path / "logs"}"\n' + f"poll_interval = 5\n" + f"\n" + f"[[repos]]\n" + f'path = "{repo_a}"\n' + f'alias = "alpha"\n' + f"\n" + f"[[repos]]\n" + f'path = "{repo_b}"\n' + f'alias = "beta"\n', + encoding="utf-8", + ) + return config + + +@pytest.fixture() +def pid_path(tmp_path): + """Return a temporary PID file path.""" + return tmp_path / "daemon.pid" + + +# =========================================================================== +# Config Parsing Tests +# =========================================================================== + + +class TestConfigParsing: + def test_load_config_valid(self, sample_config_file, tmp_path): + """Parse a complete watch.toml from a tmp file.""" + cfg = load_config(sample_config_file) + assert cfg.session_name == "test-session" + assert cfg.log_dir == tmp_path / "logs" + assert cfg.poll_interval == 5 + assert len(cfg.repos) == 2 + assert cfg.repos[0].alias == "alpha" + assert cfg.repos[1].alias == "beta" + + def test_load_config_defaults(self, tmp_path): + """Missing config file returns DaemonConfig with defaults.""" + missing = tmp_path / "nonexistent.toml" + cfg = load_config(missing) + assert cfg.session_name == "crg-watch" + assert cfg.poll_interval == 2 + assert cfg.repos == [] + + def test_load_config_missing_alias(self, tmp_path): + """Alias is derived from directory name when not specified.""" + repo = tmp_path / "my-project" + repo.mkdir() + (repo / ".git").mkdir() + + config_file = tmp_path / "watch.toml" + config_file.write_text( + f'[[repos]]\npath = "{repo}"\n', + encoding="utf-8", + ) + cfg = load_config(config_file) + assert len(cfg.repos) == 1 + assert cfg.repos[0].alias == "my-project" + + def test_load_config_invalid_path(self, tmp_path): + """Bad repo path is skipped with a warning.""" + config_file = tmp_path / "watch.toml" + config_file.write_text( + '[[repos]]\npath = "/no/such/directory/ever"\nalias = "gone"\n', + encoding="utf-8", + ) + cfg = load_config(config_file) + assert len(cfg.repos) == 0 + + def test_load_config_duplicate_alias(self, tmp_path): + """Duplicate aliases are rejected with a warning.""" + repo_a = tmp_path / "aaa" + repo_a.mkdir() + (repo_a / ".git").mkdir() + + repo_b = tmp_path / "bbb" + repo_b.mkdir() + (repo_b / ".git").mkdir() + + config_file = tmp_path / "watch.toml" + config_file.write_text( + f'[[repos]]\npath = "{repo_a}"\nalias = "dup"\n\n' + f'[[repos]]\npath = "{repo_b}"\nalias = "dup"\n', + encoding="utf-8", + ) + cfg = load_config(config_file) + assert len(cfg.repos) == 1 + assert cfg.repos[0].path == str(repo_a.resolve()) + + def test_load_config_no_git_dir(self, tmp_path): + """Repos without .git or .code-review-graph are skipped.""" + bare = tmp_path / "bare-dir" + bare.mkdir() + + config_file = tmp_path / "watch.toml" + config_file.write_text( + f'[[repos]]\npath = "{bare}"\nalias = "bare"\n', + encoding="utf-8", + ) + cfg = load_config(config_file) + assert len(cfg.repos) == 0 + + def test_serialize_roundtrip(self, tmp_path): + """save then load produces the same config.""" + repo = tmp_path / "roundtrip" + repo.mkdir() + (repo / ".git").mkdir() + + original = DaemonConfig( + session_name="rt-session", + log_dir=tmp_path / "rt-logs", + poll_interval=7, + repos=[WatchRepo(path=str(repo.resolve()), alias="rt")], + ) + config_file = tmp_path / "roundtrip.toml" + save_config(original, config_file) + loaded = load_config(config_file) + + assert loaded.session_name == original.session_name + assert loaded.log_dir == original.log_dir + assert loaded.poll_interval == original.poll_interval + assert len(loaded.repos) == 1 + assert loaded.repos[0].alias == "rt" + assert loaded.repos[0].path == str(repo.resolve()) + + def test_add_repo_to_config(self, tmp_path): + """add_repo_to_config adds a repo and saves.""" + repo = tmp_path / "new-repo" + repo.mkdir() + (repo / ".git").mkdir() + + config_file = tmp_path / "watch.toml" + # start empty + config_file.write_text("[daemon]\n", encoding="utf-8") + + cfg = add_repo_to_config(str(repo), alias="fresh", config_path=config_file) + assert len(cfg.repos) == 1 + assert cfg.repos[0].alias == "fresh" + + # Verify persisted + reloaded = load_config(config_file) + assert len(reloaded.repos) == 1 + + def test_add_repo_duplicate(self, tmp_path): + """Adding an existing repo path is a no-op.""" + repo = tmp_path / "dup-repo" + repo.mkdir() + (repo / ".git").mkdir() + + config_file = tmp_path / "watch.toml" + config_file.write_text("[daemon]\n", encoding="utf-8") + + add_repo_to_config(str(repo), alias="first", config_path=config_file) + cfg = add_repo_to_config(str(repo), alias="second", config_path=config_file) + assert len(cfg.repos) == 1 + assert cfg.repos[0].alias == "first" + + def test_add_repo_duplicate_alias(self, tmp_path): + """Adding a repo with an alias already in use raises ValueError.""" + repo_a = tmp_path / "repo-a" + repo_a.mkdir() + (repo_a / ".git").mkdir() + + repo_b = tmp_path / "repo-b" + repo_b.mkdir() + (repo_b / ".git").mkdir() + + config_file = tmp_path / "watch.toml" + config_file.write_text("[daemon]\n", encoding="utf-8") + + add_repo_to_config(str(repo_a), alias="taken", config_path=config_file) + with pytest.raises(ValueError, match="already in use"): + add_repo_to_config(str(repo_b), alias="taken", config_path=config_file) + + def test_remove_repo_by_path(self, sample_config_file): + """Removes a repo by its path.""" + cfg = load_config(sample_config_file) + path_to_remove = cfg.repos[0].path + + updated = remove_repo_from_config(path_to_remove, config_path=sample_config_file) + assert len(updated.repos) == 1 + assert updated.repos[0].alias == "beta" + + def test_remove_repo_by_alias(self, sample_config_file): + """Removes a repo by its alias.""" + updated = remove_repo_from_config("alpha", config_path=sample_config_file) + assert len(updated.repos) == 1 + assert updated.repos[0].alias == "beta" + + def test_remove_repo_not_found(self, sample_config_file): + """Removing a non-existent repo is a no-op with warning.""" + original = load_config(sample_config_file) + updated = remove_repo_from_config("nonexistent", config_path=sample_config_file) + assert len(updated.repos) == len(original.repos) + + +# =========================================================================== +# PID Management Tests +# =========================================================================== + + +class TestPIDManagement: + def test_write_read_pid(self, pid_path): + """write then read returns the same PID.""" + write_pid(42, pid_path) + assert read_pid(pid_path) == 42 + + def test_read_pid_missing(self, pid_path): + """Returns None when PID file does not exist.""" + assert read_pid(pid_path) is None + + def test_read_pid_invalid(self, pid_path): + """Corrupt file returns None.""" + pid_path.write_text("not-a-number", encoding="utf-8") + assert read_pid(pid_path) is None + + def test_clear_pid(self, pid_path): + """Removes the PID file.""" + write_pid(99, pid_path) + assert pid_path.exists() + clear_pid(pid_path) + assert not pid_path.exists() + + @patch("os.kill") + def test_is_daemon_running_alive(self, mock_kill, pid_path): + """os.kill(pid, 0) succeeds — daemon is running.""" + write_pid(1234, pid_path) + mock_kill.return_value = None # no exception = process exists + assert is_daemon_running(pid_path) is True + mock_kill.assert_called_once_with(1234, 0) + + @patch("os.kill", side_effect=ProcessLookupError) + def test_is_daemon_running_dead(self, mock_kill, pid_path): + """ProcessLookupError clears stale PID and returns False.""" + write_pid(9999, pid_path) + assert is_daemon_running(pid_path) is False + # Stale PID file should be cleaned up + assert not pid_path.exists() + + +# =========================================================================== +# WatchDaemon Tests (mock subprocess.Popen) +# =========================================================================== + + +class TestWatchDaemon: + @pytest.fixture() + def daemon_env(self, tmp_path): + """Set up a WatchDaemon with temp repos and graph.db stubs.""" + repo_a = tmp_path / "repo-a" + repo_a.mkdir() + (repo_a / ".git").mkdir() + (repo_a / ".code-review-graph").mkdir() + # Create graph.db so _initial_build is skipped + (repo_a / ".code-review-graph" / "graph.db").touch() + + repo_b = tmp_path / "repo-b" + repo_b.mkdir() + (repo_b / ".git").mkdir() + (repo_b / ".code-review-graph").mkdir() + (repo_b / ".code-review-graph" / "graph.db").touch() + + config = DaemonConfig( + session_name="test-sess", + log_dir=tmp_path / "logs", + poll_interval=1, + repos=[ + WatchRepo(path=str(repo_a), alias="alpha"), + WatchRepo(path=str(repo_b), alias="beta"), + ], + ) + config_file = tmp_path / "watch.toml" + save_config(config, config_file) + + daemon = WatchDaemon(config=config, config_path=config_file) + + return { + "daemon": daemon, + "config": config, + "tmp_path": tmp_path, + "repo_a": repo_a, + "repo_b": repo_b, + "config_file": config_file, + } + + @patch("code_review_graph.daemon.subprocess.Popen") + @patch("code_review_graph.registry.Registry") + def test_start_spawns_children(self, mock_registry_cls, mock_popen, daemon_env): + """start() spawns a Popen child per repo.""" + mock_proc = MagicMock() + mock_proc.pid = 12345 + mock_proc.poll.return_value = None + mock_popen.return_value = mock_proc + + daemon = daemon_env["daemon"] + daemon.start() + try: + # One Popen call per repo + assert mock_popen.call_count == 2 + # Children are tracked + assert len(daemon._children) == 2 + assert "alpha" in daemon._children + assert "beta" in daemon._children + finally: + daemon.stop() + + @patch("code_review_graph.daemon.subprocess.Popen") + @patch("code_review_graph.registry.Registry") + def test_start_registers_repos(self, mock_registry_cls, mock_popen, daemon_env): + """start() calls Registry.register for each repo.""" + mock_proc = MagicMock() + mock_proc.pid = 100 + mock_proc.poll.return_value = None + mock_popen.return_value = mock_proc + + daemon = daemon_env["daemon"] + mock_registry = mock_registry_cls.return_value + + daemon.start() + try: + assert mock_registry.register.call_count == 2 + aliases = {c.kwargs["alias"] for c in mock_registry.register.call_args_list} + assert aliases == {"alpha", "beta"} + finally: + daemon.stop() + + def test_reconcile_add(self, daemon_env): + """New repo in config is registered, built if needed, and spawned.""" + daemon = daemon_env["daemon"] + config = daemon_env["config"] + daemon._state_path = daemon_env["tmp_path"] / "daemon-state.json" + + # Simulate initial state with only alpha + mock_alpha = MagicMock() + mock_alpha.pid = 100 + mock_alpha.poll.return_value = None + daemon._current_repos = {"alpha": config.repos[0]} + daemon._children = {"alpha": mock_alpha} + + # Remove graph.db for beta so _initial_build is triggered + beta_db = Path(config.repos[1].path) / ".code-review-graph" / "graph.db" + beta_db.unlink() + + with ( + patch("code_review_graph.daemon.subprocess.Popen") as mock_popen, + patch("code_review_graph.daemon.subprocess.run") as mock_run, + patch("code_review_graph.registry.Registry") as mock_registry_cls, + ): + mock_new = MagicMock() + mock_new.pid = 999 + mock_popen.return_value = mock_new + + mock_run.return_value = MagicMock(returncode=0) + mock_registry = mock_registry_cls.return_value + + # Reconcile with full config (alpha + beta) + daemon.reconcile(config) + + # beta should have been registered in the registry + mock_registry.register.assert_called_once_with(config.repos[1].path, alias="beta") + + # beta should have been built (no graph.db) + assert mock_run.call_count == 1 + + # beta should have been spawned + assert mock_popen.call_count == 1 + assert "beta" in daemon._children + + def test_reconcile_add_skips_build_when_db_exists(self, daemon_env): + """New repo with existing graph.db is registered and spawned without building.""" + daemon = daemon_env["daemon"] + config = daemon_env["config"] + daemon._state_path = daemon_env["tmp_path"] / "daemon-state.json" + + # Simulate initial state with only alpha + mock_alpha = MagicMock() + mock_alpha.pid = 100 + mock_alpha.poll.return_value = None + daemon._current_repos = {"alpha": config.repos[0]} + daemon._children = {"alpha": mock_alpha} + + # beta already has graph.db (from fixture) — build should be skipped + + with ( + patch("code_review_graph.daemon.subprocess.Popen") as mock_popen, + patch("code_review_graph.daemon.subprocess.run") as mock_run, + patch("code_review_graph.registry.Registry") as mock_registry_cls, + ): + mock_new = MagicMock() + mock_new.pid = 999 + mock_popen.return_value = mock_new + + mock_registry = mock_registry_cls.return_value + + # Reconcile with full config (alpha + beta) + daemon.reconcile(config) + + # beta should have been registered + mock_registry.register.assert_called_once_with(config.repos[1].path, alias="beta") + + # No build should have been triggered (graph.db exists) + mock_run.assert_not_called() + + # beta should have been spawned + assert mock_popen.call_count == 1 + assert "beta" in daemon._children + + def test_reconcile_remove(self, daemon_env): + """Removed repo from config terminates the child process.""" + daemon = daemon_env["daemon"] + config = daemon_env["config"] + daemon._state_path = daemon_env["tmp_path"] / "daemon-state.json" + + # Current state has both repos + mock_alpha = MagicMock() + mock_alpha.pid = 100 + mock_alpha.poll.return_value = None + mock_beta = MagicMock() + mock_beta.pid = 200 + mock_beta.poll.return_value = None + daemon._current_repos = {r.alias: r for r in config.repos} + daemon._children = {"alpha": mock_alpha, "beta": mock_beta} + + # Reconcile with only alpha + new_config = DaemonConfig( + session_name=config.session_name, + log_dir=config.log_dir, + poll_interval=config.poll_interval, + repos=[config.repos[0]], + ) + daemon.reconcile(new_config) + + mock_beta.terminate.assert_called_once() + assert "beta" not in daemon._children + assert "beta" not in daemon._current_repos + + def test_reconcile_noop(self, daemon_env): + """No changes means no processes started or stopped.""" + daemon = daemon_env["daemon"] + config = daemon_env["config"] + daemon._state_path = daemon_env["tmp_path"] / "daemon-state.json" + + mock_alpha = MagicMock() + mock_alpha.pid = 100 + mock_alpha.poll.return_value = None + mock_beta = MagicMock() + mock_beta.pid = 200 + mock_beta.poll.return_value = None + daemon._current_repos = {r.alias: r for r in config.repos} + daemon._children = {"alpha": mock_alpha, "beta": mock_beta} + + with patch("code_review_graph.daemon.subprocess.Popen") as mock_popen: + daemon.reconcile(config) + mock_popen.assert_not_called() + mock_alpha.terminate.assert_not_called() + mock_beta.terminate.assert_not_called() + + def test_reconcile_update_path(self, daemon_env, tmp_path): + """Same alias but different path = register, build if needed, terminate + new child.""" + daemon = daemon_env["daemon"] + config = daemon_env["config"] + daemon._state_path = daemon_env["tmp_path"] / "daemon-state.json" + + mock_alpha = MagicMock() + mock_alpha.pid = 100 + mock_alpha.poll.return_value = None + mock_beta = MagicMock() + mock_beta.pid = 200 + mock_beta.poll.return_value = None + daemon._current_repos = {r.alias: r for r in config.repos} + daemon._children = {"alpha": mock_alpha, "beta": mock_beta} + + # Create a new repo directory for alpha with a different path (no graph.db) + new_repo = tmp_path / "repo-a-v2" + new_repo.mkdir() + (new_repo / ".git").mkdir() + + updated_config = DaemonConfig( + session_name=config.session_name, + log_dir=config.log_dir, + poll_interval=config.poll_interval, + repos=[ + WatchRepo(path=str(new_repo), alias="alpha"), + config.repos[1], + ], + ) + + with ( + patch("code_review_graph.daemon.subprocess.Popen") as mock_popen, + patch("code_review_graph.daemon.subprocess.run") as mock_run, + patch("code_review_graph.registry.Registry") as mock_registry_cls, + ): + mock_new = MagicMock() + mock_new.pid = 777 + mock_popen.return_value = mock_new + + mock_run.return_value = MagicMock(returncode=0) + mock_registry = mock_registry_cls.return_value + + daemon.reconcile(updated_config) + + # alpha should be registered at the new path + mock_registry.register.assert_called_once_with(str(new_repo), alias="alpha") + + # alpha should be built (new path has no graph.db) + assert mock_run.call_count == 1 + + # alpha should be terminated then respawned + mock_alpha.terminate.assert_called_once() + assert mock_popen.call_count == 1 + assert daemon._children["alpha"] is mock_new + + def test_status_with_children(self, daemon_env): + """status() returns correct dict with child process info.""" + daemon = daemon_env["daemon"] + config = daemon_env["config"] + + mock_alpha = MagicMock() + mock_alpha.pid = 111 + mock_alpha.poll.return_value = None # alive + mock_beta = MagicMock() + mock_beta.pid = 222 + mock_beta.poll.return_value = 1 # dead + + daemon._current_repos = {r.alias: r for r in config.repos} + daemon._children = {"alpha": mock_alpha, "beta": mock_beta} + + result = daemon.status() + assert result["session_name"] == "test-sess" + assert result["running"] is True + assert len(result["repos"]) == 2 + + repo_map = {r["alias"]: r for r in result["repos"]} + assert repo_map["alpha"]["alive"] is True + assert repo_map["alpha"]["pid"] == 111 + assert repo_map["beta"]["alive"] is False + assert repo_map["beta"]["pid"] == 222 + + def test_check_health_restarts_dead(self, daemon_env): + """_check_health restarts a child whose poll() returns non-None.""" + daemon = daemon_env["daemon"] + config = daemon_env["config"] + daemon._state_path = daemon_env["tmp_path"] / "daemon-state.json" + + mock_alpha = MagicMock() + mock_alpha.pid = 100 + mock_alpha.poll.return_value = 1 # dead + mock_beta = MagicMock() + mock_beta.pid = 200 + mock_beta.poll.return_value = None # alive + + daemon._current_repos = {r.alias: r for r in config.repos} + daemon._children = {"alpha": mock_alpha, "beta": mock_beta} + + with patch("code_review_graph.daemon.subprocess.Popen") as mock_popen: + mock_new = MagicMock() + mock_new.pid = 555 + mock_popen.return_value = mock_new + + daemon._check_health() + + # alpha should be restarted, beta untouched + assert mock_popen.call_count == 1 + assert daemon._children["alpha"] is mock_new + assert daemon._children["beta"] is mock_beta + + def test_stop_terminates_all_children(self, daemon_env): + """stop() calls terminate on all children.""" + daemon = daemon_env["daemon"] + config = daemon_env["config"] + + mock_alpha = MagicMock() + mock_alpha.poll.return_value = None + mock_beta = MagicMock() + mock_beta.poll.return_value = None + + daemon._current_repos = {r.alias: r for r in config.repos} + daemon._children = {"alpha": mock_alpha, "beta": mock_beta} + + daemon.stop() + + mock_alpha.terminate.assert_called_once() + mock_beta.terminate.assert_called_once() + assert len(daemon._children) == 0 + assert len(daemon._current_repos) == 0 + + @patch("code_review_graph.daemon.subprocess.Popen") + @patch("code_review_graph.registry.Registry") + def test_start_persists_state(self, mock_registry_cls, mock_popen, daemon_env): + """start() writes child PIDs to the state file on disk.""" + mock_proc_a = MagicMock() + mock_proc_a.pid = 1001 + mock_proc_a.poll.return_value = None + mock_proc_b = MagicMock() + mock_proc_b.pid = 1002 + mock_proc_b.poll.return_value = None + mock_popen.side_effect = [mock_proc_a, mock_proc_b] + + daemon = daemon_env["daemon"] + state_path = daemon_env["tmp_path"] / "daemon-state.json" + daemon._state_path = state_path + + daemon.start() + try: + state = load_state(state_path) + assert state["alpha"]["pid"] == 1001 + assert state["beta"]["pid"] == 1002 + finally: + daemon.stop() + + def test_health_check_updates_state(self, daemon_env): + """_check_health persists updated PIDs after restarting a dead child.""" + daemon = daemon_env["daemon"] + config = daemon_env["config"] + state_path = daemon_env["tmp_path"] / "daemon-state.json" + daemon._state_path = state_path + + mock_alpha = MagicMock() + mock_alpha.pid = 2001 + mock_alpha.poll.return_value = 1 # dead + mock_beta = MagicMock() + mock_beta.pid = 2002 + mock_beta.poll.return_value = None # alive + + daemon._current_repos = {r.alias: r for r in config.repos} + daemon._children = {"alpha": mock_alpha, "beta": mock_beta} + + with patch("code_review_graph.daemon.subprocess.Popen") as mock_popen: + mock_new = MagicMock() + mock_new.pid = 3001 + mock_popen.return_value = mock_new + + daemon._check_health() + + state = load_state(state_path) + assert state["alpha"]["pid"] == 3001 + assert state["beta"]["pid"] == 2002 + + def test_status_from_state_reports_alive(self, daemon_env, tmp_path): + """A fresh WatchDaemon can report status from persisted state file.""" + config = daemon_env["config"] + state_path = tmp_path / "daemon-state.json" + + import json + import os + + # Simulate a running daemon that persisted state with our own PID + # (so os.kill(pid, 0) will succeed) + our_pid = os.getpid() + state = { + "alpha": {"pid": our_pid, "path": config.repos[0].path}, + "beta": {"pid": our_pid, "path": config.repos[1].path}, + } + state_path.write_text(json.dumps(state), encoding="utf-8") + + # Create a *fresh* WatchDaemon (like _handle_status does) with + # the state path pointing to our persisted file + fresh_daemon = WatchDaemon(config=config, config_path=daemon_env["config_file"]) + fresh_daemon._state_path = state_path + + result = fresh_daemon.status() + repo_map = {r["alias"]: r for r in result["repos"]} + + # Bug: without the fix, both would show alive=False because + # _children is empty on the fresh daemon instance + assert repo_map["alpha"]["alive"] is True + assert repo_map["beta"]["alive"] is True + assert repo_map["alpha"]["pid"] == our_pid + assert repo_map["beta"]["pid"] == our_pid + + +# =========================================================================== +# CLI Handler Tests +# =========================================================================== + + +class TestDaemonCLI: + def test_handle_add_success(self, tmp_path): + """_handle_add adds a repo and prints confirmation.""" + from code_review_graph.daemon_cli import _handle_add + + repo = tmp_path / "cli-repo" + repo.mkdir() + (repo / ".git").mkdir() + + args = MagicMock() + args.path = str(repo) + args.alias = "cli-alias" + + with ( + patch( + "code_review_graph.daemon.add_repo_to_config", + ) as mock_add, + patch( + "code_review_graph.daemon.is_daemon_running", + return_value=False, + ), + patch("builtins.print") as mock_print, + ): + _handle_add(args) + mock_add.assert_called_once_with(str(repo), alias="cli-alias") + # Verify confirmation printed + printed = " ".join(str(c) for c in mock_print.call_args_list) + assert "cli-alias" in printed + + def test_handle_remove_success(self): + """_handle_remove removes a repo and prints confirmation.""" + from code_review_graph.daemon_cli import _handle_remove + + args = MagicMock() + args.path_or_alias = "some-alias" + + repo = WatchRepo(path="/tmp/r", alias="some-alias") + cfg_before = DaemonConfig(repos=[repo]) + cfg_after = DaemonConfig(repos=[]) + + with ( + patch( + "code_review_graph.daemon.load_config", + return_value=cfg_before, + ), + patch( + "code_review_graph.daemon.remove_repo_from_config", + return_value=cfg_after, + ), + patch( + "code_review_graph.daemon.is_daemon_running", + return_value=False, + ), + patch("builtins.print") as mock_print, + ): + _handle_remove(args) + printed = " ".join(str(c) for c in mock_print.call_args_list) + assert "some-alias" in printed + + def test_handle_stop_not_running(self): + """_handle_stop exits when daemon is not running.""" + from code_review_graph.daemon_cli import _handle_stop + + args = MagicMock() + + with ( + patch( + "code_review_graph.daemon.is_daemon_running", + return_value=False, + ), + patch("builtins.print"), + pytest.raises(SystemExit) as exc_info, + ): + _handle_stop(args) + + assert exc_info.value.code == 1 + + def test_handle_status_not_running(self): + """_handle_status displays 'not running' when daemon is down.""" + from code_review_graph.daemon_cli import _handle_status + + args = MagicMock() + cfg = DaemonConfig(repos=[]) + + with ( + patch( + "code_review_graph.daemon.is_daemon_running", + return_value=False, + ), + patch( + "code_review_graph.daemon.load_config", + return_value=cfg, + ), + patch( + "code_review_graph.daemon.read_pid", + return_value=None, + ), + patch("builtins.print") as mock_print, + ): + _handle_status(args) + printed = " ".join(str(c) for c in mock_print.call_args_list) + assert "not running" in printed + + def test_handle_status_shows_alive_for_running_watchers(self, tmp_path): + """_handle_status reports 'alive' for watchers whose PIDs are running. + + Regression test: previously _handle_status created a fresh WatchDaemon + with an empty _children dict, so all repos appeared dead even when + watcher processes were running. + """ + import os + + from code_review_graph.daemon_cli import _handle_status + + repo = tmp_path / "my-repo" + repo.mkdir() + (repo / ".git").mkdir() + + args = MagicMock() + our_pid = os.getpid() + cfg = DaemonConfig( + repos=[WatchRepo(path=str(repo), alias="myrepo")], + log_dir=tmp_path / "logs", + ) + + state = {"myrepo": {"pid": our_pid, "path": str(repo)}} + + with ( + patch( + "code_review_graph.daemon.is_daemon_running", + return_value=True, + ), + patch( + "code_review_graph.daemon.load_config", + return_value=cfg, + ), + patch( + "code_review_graph.daemon.read_pid", + return_value=our_pid, + ), + patch( + "code_review_graph.daemon.load_state", + return_value=state, + ), + patch("builtins.print") as mock_print, + ): + _handle_status(args) + printed = " ".join(str(c) for c in mock_print.call_args_list) + assert "alive" in printed + assert "dead" not in printed + + def test_handle_start_already_running(self): + """_handle_start exits with error when daemon is already running.""" + from code_review_graph.daemon_cli import _handle_start + + args = MagicMock() + args.foreground = False + + with ( + patch( + "code_review_graph.daemon.is_daemon_running", + return_value=True, + ), + patch("builtins.print"), + pytest.raises(SystemExit) as exc_info, + ): + _handle_start(args) + + assert exc_info.value.code == 1 + + def test_handle_logs_missing_file(self, tmp_path): + """_handle_logs exits when log file does not exist.""" + from code_review_graph.daemon_cli import _handle_logs + + args = MagicMock() + args.repo = None + args.follow = False + args.lines = 50 + + cfg = DaemonConfig(log_dir=tmp_path / "no-logs") + + with ( + patch( + "code_review_graph.daemon.load_config", + return_value=cfg, + ), + patch("builtins.print"), + pytest.raises(SystemExit) as exc_info, + ): + _handle_logs(args) + + assert exc_info.value.code == 1 + + def test_handle_logs_reads_lines(self, tmp_path): + """_handle_logs reads last N lines from log file.""" + from code_review_graph.daemon_cli import _handle_logs + + log_dir = tmp_path / "logs" + log_dir.mkdir() + log_file = log_dir / "daemon.log" + log_file.write_text("line1\nline2\nline3\nline4\nline5\n", encoding="utf-8") + + args = MagicMock() + args.repo = None + args.follow = False + args.lines = 3 + + cfg = DaemonConfig(log_dir=log_dir) + + with ( + patch( + "code_review_graph.daemon.load_config", + return_value=cfg, + ), + patch("builtins.print") as mock_print, + ): + _handle_logs(args) + # Should have printed last 3 lines + assert mock_print.call_count == 3 + printed_lines = [str(c.args[0]) for c in mock_print.call_args_list] + assert printed_lines == ["line3", "line4", "line5"] diff --git a/uv.lock b/uv.lock index cac70c0..9faa072 100644 --- a/uv.lock +++ b/uv.lock @@ -260,13 +260,14 @@ wheels = [ [[package]] name = "code-review-graph" -version = "2.0.0" +version = "2.2.2" source = { editable = "." } dependencies = [ { name = "fastmcp" }, { name = "mcp" }, { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tree-sitter" }, { name = "tree-sitter-language-pack" }, { name = "watchdog" }, @@ -325,6 +326,7 @@ requires-dist = [ { name = "pyyaml", marker = "extra == 'eval'", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0,<1" }, { name = "sentence-transformers", marker = "extra == 'embeddings'", specifier = ">=3.0.0,<4" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0,<3" }, { name = "tree-sitter", specifier = ">=0.23.0,<1" }, { name = "tree-sitter-language-pack", specifier = ">=0.3.0,<1" }, { name = "watchdog", specifier = ">=4.0.0,<6" },