diff --git a/graphify/__main__.py b/graphify/__main__.py index 155501a98d..dd0c84dfb7 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -534,6 +534,7 @@ def _run_cli() -> None: print(" --contributor \"Name\" tag who added it to the corpus") print(" --dir target directory (default: ./raw)") print(" watch watch a folder and rebuild the graph on code changes") + print(" --debounce N seconds to wait after last change before updating (default: 3)") print(" update re-extract code files and update the graph (no LLM needed)") print(" --force overwrite graph.json even if the rebuild has fewer nodes") print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)") diff --git a/graphify/cli.py b/graphify/cli.py index cb30420473..bf6fe48ce8 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1730,14 +1730,59 @@ def dispatch_command(cmd: str) -> None: sys.exit(1) elif cmd == "watch": - watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".") + args = sys.argv[2:] + watch_path: Path | None = None + debounce: float | None = None + i = 0 + while i < len(args): + a = args[i] + if a == "--debounce": + if i + 1 >= len(args): + print("error: --debounce requires a value", file=sys.stderr) + sys.exit(2) + try: + debounce = float(args[i + 1]) + except ValueError: + print(f"error: invalid debounce value: {args[i + 1]}", file=sys.stderr) + sys.exit(2) + if debounce < 0: + print("error: debounce must be non-negative", file=sys.stderr) + sys.exit(2) + i += 2 + continue + if a.startswith("--debounce="): + val = a.split("=", 1)[1] + try: + debounce = float(val) + except ValueError: + print(f"error: invalid debounce value: {val}", file=sys.stderr) + sys.exit(2) + if debounce < 0: + print("error: debounce must be non-negative", file=sys.stderr) + sys.exit(2) + i += 1 + continue + if a.startswith("-"): + print(f"error: unknown watch option: {a}", file=sys.stderr) + sys.exit(2) + if watch_path is not None: + print("error: watch accepts at most one path argument", file=sys.stderr) + sys.exit(2) + watch_path = Path(a) + i += 1 + + if watch_path is None: + watch_path = Path(".") if not watch_path.exists(): print(f"error: path not found: {watch_path}", file=sys.stderr) sys.exit(1) from graphify.watch import watch as _watch try: - _watch(watch_path) + if debounce is not None: + _watch(watch_path, debounce=debounce) + else: + _watch(watch_path) except ImportError as exc: print(f"error: {exc}", file=sys.stderr) sys.exit(1) diff --git a/tests/test_cli_watch.py b/tests/test_cli_watch.py new file mode 100644 index 0000000000..24b66510a1 --- /dev/null +++ b/tests/test_cli_watch.py @@ -0,0 +1,79 @@ +"""#3061: graphify watch must parse --debounce and forward it to watch().""" +from __future__ import annotations + +import sys +from unittest.mock import patch + +import pytest + +from graphify.cli import dispatch_command + +PYTHON = sys.executable + + +def _run_watch(argv_tail: list[str], cwd: str) -> object: + """Invoke dispatch_command('watch') with a patched watch(); cwd must exist.""" + with patch("graphify.watch.watch") as mock_watch: + old_argv = sys.argv + sys.argv = ["graphify", "watch"] + argv_tail + try: + dispatch_command("watch") + finally: + sys.argv = old_argv + return mock_watch + + +def test_watch_debounce_space_form(tmp_path): + mock = _run_watch(["--debounce", "60", str(tmp_path)], str(tmp_path)) + mock.assert_called_once_with(tmp_path, debounce=60.0) + + +def test_watch_debounce_equals_form(tmp_path): + mock = _run_watch(["--debounce=60", str(tmp_path)], str(tmp_path)) + mock.assert_called_once_with(tmp_path, debounce=60.0) + + +def test_watch_debounce_zero_allowed(tmp_path): + mock = _run_watch(["--debounce", "0", str(tmp_path)], str(tmp_path)) + mock.assert_called_once_with(tmp_path, debounce=0.0) + + +def test_watch_without_debounce_uses_default_kwarg(tmp_path): + mock = _run_watch([str(tmp_path)], str(tmp_path)) + mock.assert_called_once_with(tmp_path) + + +def test_watch_debounce_missing_value(tmp_path): + with pytest.raises(SystemExit) as exc: + _run_watch(["--debounce", str(tmp_path)], str(tmp_path)) + assert exc.value.code == 2 + + +def test_watch_debounce_negative_rejected(tmp_path): + with pytest.raises(SystemExit) as exc: + _run_watch(["--debounce", "-1", str(tmp_path)], str(tmp_path)) + assert exc.value.code == 2 + + +def test_watch_debounce_non_numeric_rejected(tmp_path): + with pytest.raises(SystemExit) as exc: + _run_watch(["--debounce", "foo", str(tmp_path)], str(tmp_path)) + assert exc.value.code == 2 + + +def test_watch_unknown_option_rejected(tmp_path): + with pytest.raises(SystemExit) as exc: + _run_watch(["--bogus", str(tmp_path)], str(tmp_path)) + assert exc.value.code == 2 + + +def test_watch_help_lists_debounce(): + import subprocess + + r = subprocess.run( + [PYTHON, "-m", "graphify", "--help"], + capture_output=True, + text=True, + ) + assert r.returncode == 0 + assert "--debounce" in r.stdout