"""Tests for the incremental graph update module.""" import hashlib import io import subprocess from pathlib import Path from unittest.mock import MagicMock, call, patch # noqa: F401 – used in tests import pytest import code_review_graph.incremental as incremental_module from code_review_graph.graph import GraphStore from code_review_graph.incremental import ( _create_watch_handler, _decode_name_status_paths, _is_binary, _load_ignore_patterns, _parse_single_file, _should_ignore, _single_hop_dependents, ensure_repo_gitignore_excludes_crg, find_dependents, find_project_root, find_repo_root, full_build, get_all_tracked_files, get_changed_files, get_db_path, get_staged_and_unstaged, incremental_update, start_watch_thread, watch, ) class TestParseExecutorSelection: def test_stdio_mcp_uses_threads_on_unix(self, monkeypatch): monkeypatch.delenv("CRG_PARSE_EXECUTOR", raising=False) monkeypatch.setattr( incremental_module, "_MCP_STDIO_ACTIVE", True, raising=False, ) monkeypatch.setattr(incremental_module.sys, "platform", "linux") monkeypatch.setattr(incremental_module.sys.stdin, "isatty", lambda: False) assert incremental_module._select_executor_kind() == "thread" def test_non_mcp_unix_automation_keeps_process_default(self, monkeypatch): monkeypatch.delenv("CRG_PARSE_EXECUTOR", raising=False) monkeypatch.setattr( incremental_module, "_MCP_STDIO_ACTIVE", False, raising=False, ) monkeypatch.setattr(incremental_module.sys, "platform", "linux") monkeypatch.setattr(incremental_module.sys.stdin, "isatty", lambda: False) assert incremental_module._select_executor_kind() == "process" def test_explicit_process_override_wins_in_stdio_mcp(self, monkeypatch): monkeypatch.setenv("CRG_PARSE_EXECUTOR", "process") monkeypatch.setattr( incremental_module, "_MCP_STDIO_ACTIVE", True, raising=False, ) assert incremental_module._select_executor_kind() == "process" class TestFindRepoRoot: def test_finds_git_dir(self, tmp_path): (tmp_path / ".git").mkdir() assert find_repo_root(tmp_path) == tmp_path def test_finds_parent_git_dir(self, tmp_path): (tmp_path / ".git").mkdir() sub = tmp_path / "a" / "b" sub.mkdir(parents=True) assert find_repo_root(sub) == tmp_path def test_returns_none_without_git(self, tmp_path): """No .git between ``sub`` and ``tmp_path`` -> None. Bounded with ``stop_at=tmp_path`` so the walk does not climb into ancestors outside the test sandbox. On Windows in particular, ``tmp_path`` lives under ``C:/Users//AppData/Local/Temp/...`` and if the user has ``git init`` anywhere under their home (dotfiles, chezmoi, etc.) the unbounded walk would find that ancestor .git and the test would fail for reasons unrelated to the product. See #241. """ sub = tmp_path / "no_git" sub.mkdir() assert find_repo_root(sub, stop_at=tmp_path) is None def test_stop_at_prevents_escape_to_outer_git(self, tmp_path): """Positive regression test for #241: ``stop_at`` must halt the walk even when an ancestor *does* contain ``.git``. Without ``stop_at`` the walk correctly finds the outer .git; with ``stop_at=inner`` the walk is bounded and returns None. """ outer = tmp_path / "outer" outer.mkdir() (outer / ".git").mkdir() inner = outer / "inner" inner.mkdir() # Unbounded walk finds the ancestor .git (existing behavior). assert find_repo_root(inner) == outer # Bounded walk stops at ``inner`` and never climbs to ``outer``. assert find_repo_root(inner, stop_at=inner) is None def test_stop_at_finds_git_at_boundary(self, tmp_path): """stop_at does not suppress a .git that lives *at* the boundary.""" boundary = tmp_path / "boundary" boundary.mkdir() (boundary / ".git").mkdir() inner = boundary / "inner" inner.mkdir() # The walk examines ``boundary`` and finds the .git before stopping. assert find_repo_root(inner, stop_at=boundary) == boundary class TestFindProjectRoot: def test_returns_git_root(self, tmp_path): (tmp_path / ".git").mkdir() assert find_project_root(tmp_path) == tmp_path def test_falls_back_to_start(self, tmp_path, monkeypatch): """With no .git and no env override, find_project_root returns ``sub``. Bounded with ``stop_at=tmp_path`` to prevent the ancestor walk from escaping the test sandbox (see #241), and ``CRG_REPO_ROOT`` is cleared so a developer env var cannot shadow the test expectation. """ monkeypatch.delenv("CRG_REPO_ROOT", raising=False) sub = tmp_path / "no_git" sub.mkdir() assert find_project_root(sub, stop_at=tmp_path) == sub def test_stop_at_forwarded_to_find_repo_root(self, tmp_path, monkeypatch): """Positive regression test for #241: find_project_root must forward stop_at to find_repo_root, not silently drop it.""" monkeypatch.delenv("CRG_REPO_ROOT", raising=False) outer = tmp_path / "outer" outer.mkdir() (outer / ".git").mkdir() inner = outer / "inner" inner.mkdir() # Without stop_at, find_project_root climbs to outer (existing behavior). assert find_project_root(inner) == outer # With stop_at=inner, the walk is bounded and find_project_root falls # back to its third resolution rule (the start path itself). assert find_project_root(inner, stop_at=inner) == inner class TestGetDbPath: def test_creates_directory_and_db_path(self, tmp_path): db_path = get_db_path(tmp_path) assert db_path == tmp_path / ".code-review-graph" / "graph.db" assert (tmp_path / ".code-review-graph").is_dir() def test_creates_gitignore(self, tmp_path): get_db_path(tmp_path) gi = tmp_path / ".code-review-graph" / ".gitignore" assert gi.exists() assert "*\n" in gi.read_text() def test_migrates_legacy_db(self, tmp_path): legacy = tmp_path / ".code-review-graph.db" legacy.write_text("legacy data") db_path = get_db_path(tmp_path) assert db_path.exists() assert not legacy.exists() assert db_path.read_text() == "legacy data" def test_cleans_legacy_side_files(self, tmp_path): legacy = tmp_path / ".code-review-graph.db" legacy.write_text("data") for suffix in ("-wal", "-shm", "-journal"): (tmp_path / f".code-review-graph.db{suffix}").write_text("side") get_db_path(tmp_path) for suffix in ("-wal", "-shm", "-journal"): assert not (tmp_path / f".code-review-graph.db{suffix}").exists() def test_read_only_resolution_does_not_create_migrate_or_clean(self, tmp_path): legacy = tmp_path / ".code-review-graph.db" legacy.write_text("legacy data") side_files = [ tmp_path / f".code-review-graph.db{suffix}" for suffix in ("-wal", "-shm", "-journal") ] for side_file in side_files: side_file.write_text("side") db_path = get_db_path(tmp_path, read_only=True) assert db_path == tmp_path / ".code-review-graph" / "graph.db" assert not db_path.parent.exists() assert legacy.read_text() == "legacy data" assert all(side_file.read_text() == "side" for side_file in side_files) class TestEnsureRepoGitignoreExcludesCrg: def test_creates_gitignore_when_missing(self, tmp_path): state = ensure_repo_gitignore_excludes_crg(tmp_path) assert state == "created" gitignore = tmp_path / ".gitignore" assert gitignore.exists() assert gitignore.read_text() == ( "# Added by code-review-graph\n" ".code-review-graph/\n" ) def test_appends_rule_when_missing(self, tmp_path): gitignore = tmp_path / ".gitignore" gitignore.write_text("node_modules/\n") state = ensure_repo_gitignore_excludes_crg(tmp_path) assert state == "updated" assert gitignore.read_text() == ( "node_modules/\n" "# Added by code-review-graph\n" ".code-review-graph/\n" ) def test_idempotent_when_present(self, tmp_path): gitignore = tmp_path / ".gitignore" gitignore.write_text(".code-review-graph/\n") state = ensure_repo_gitignore_excludes_crg(tmp_path) assert state == "already-present" assert gitignore.read_text() == ".code-review-graph/\n" def test_treats_wildcard_ignore_as_present(self, tmp_path): gitignore = tmp_path / ".gitignore" gitignore.write_text(".code-review-graph/**\n") state = ensure_repo_gitignore_excludes_crg(tmp_path) assert state == "already-present" class TestIgnorePatterns: def test_default_patterns_loaded(self, tmp_path): patterns = _load_ignore_patterns(tmp_path) assert "**/node_modules/**" in patterns assert "**/.git/**" in patterns assert "**/__pycache__/**" in patterns assert "/build/**" in patterns def test_custom_ignore_file(self, tmp_path): ignore = tmp_path / ".code-review-graphignore" ignore.write_text("custom/\n# comment\n\nvendor/**\n") patterns = _load_ignore_patterns(tmp_path) assert "**/custom/**" in patterns assert "**/vendor/**" in patterns # Comments and blanks should be skipped assert "# comment" not in patterns assert "" not in patterns def test_should_ignore_matches(self): patterns = ["node_modules/**", "*.pyc", ".git/**"] assert _should_ignore("node_modules/foo/bar.js", patterns) assert _should_ignore("test.pyc", patterns) assert _should_ignore(".git/HEAD", patterns) assert not _should_ignore("src/main.py", patterns) def test_should_ignore_directory_trailing_slash_pattern(self, tmp_path): ignore = tmp_path / ".code-review-graphignore" ignore.write_text("vendor/\n/generated/\n") patterns = _load_ignore_patterns(tmp_path) assert "**/vendor/**" in patterns assert "/generated/**" in patterns assert _should_ignore("vendor/autoload.php", patterns) assert _should_ignore("services/api/vendor/autoload.php", patterns) assert _should_ignore("generated/code.js", patterns) assert not _should_ignore("packages/app/generated/code.js", patterns) assert not _should_ignore("src/vendorized/file.php", patterns) def test_should_ignore_nested_dependency_dirs(self): """Nested node_modules / vendor / .gradle should be ignored (#91).""" patterns = [ "node_modules/**", "vendor/**", ".gradle/**", ".venv/**", ] # Monorepo: nested node_modules assert _should_ignore("packages/app/node_modules/react/index.js", patterns) assert _should_ignore("apps/web/node_modules/lodash/index.js", patterns) # PHP/Laravel: vendor at any depth assert _should_ignore("backend/vendor/autoload.php", patterns) # Gradle at any depth assert _should_ignore("android/app/.gradle/cache/metadata.bin", patterns) # Negative: similarly-named dirs that aren't a match assert not _should_ignore("src/node_modules_helper/foo.py", patterns) assert not _should_ignore("src/venv_tools/bar.py", patterns) def test_should_ignore_framework_defaults(self): """Default patterns should cover Laravel, Gradle, Flutter, and caches.""" from code_review_graph.incremental import DEFAULT_IGNORE_PATTERNS patterns = DEFAULT_IGNORE_PATTERNS # Laravel/PHP assert _should_ignore("vendor/autoload.php", patterns) assert _should_ignore("bootstrap/cache/packages.php", patterns) # Gradle/Java assert _should_ignore(".gradle/caches/jars.bin", patterns) assert _should_ignore("build/libs/app.jar", patterns) # Flutter/Dart assert _should_ignore(".dart_tool/package_config.json", patterns) # Coverage/cache assert _should_ignore("coverage/lcov.info", patterns) assert _should_ignore(".cache/webpack/index.pack", patterns) def test_root_output_defaults_do_not_hide_nested_source_directories(self): """Reviewed #92 semantics keep ambiguous output names root-relative.""" from code_review_graph.incremental import DEFAULT_IGNORE_PATTERNS patterns = DEFAULT_IGNORE_PATTERNS for directory in ("build", "dist", "bin", "obj", "target"): assert _should_ignore(f"{directory}/generated/output.js", patterns) assert not _should_ignore( f"packages/app/{directory}/source.py", patterns, ) def test_cdk_output_default_matches_at_any_depth(self): """AWS CDK synth output is generated in root and monorepo projects.""" from code_review_graph.incremental import DEFAULT_IGNORE_PATTERNS patterns = DEFAULT_IGNORE_PATTERNS assert _should_ignore("cdk.out/manifest.json", patterns) assert _should_ignore("packages/infra/cdk.out/asset.js", patterns) assert not _should_ignore("packages/infra/cdk.output/source.ts", patterns) def test_safe_dependency_defaults_still_match_at_any_depth(self): """The monorepo dependency case from #91 remains fixed.""" from code_review_graph.incremental import DEFAULT_IGNORE_PATTERNS patterns = DEFAULT_IGNORE_PATTERNS assert _should_ignore("packages/app/node_modules/pkg/index.js", patterns) assert _should_ignore("services/api/vendor/pkg/file.php", patterns) assert _should_ignore("src/lib/__pycache__/module.pyc", patterns) class TestDataDir: """Tests for get_data_dir / CRG_DATA_DIR / CRG_REPO_ROOT (#155).""" def test_default_uses_repo_subdir(self, tmp_path, monkeypatch): """Without CRG_DATA_DIR, graphs live at /.code-review-graph.""" monkeypatch.delenv("CRG_DATA_DIR", raising=False) from code_review_graph.incremental import get_data_dir result = get_data_dir(tmp_path) assert result == tmp_path / ".code-review-graph" assert result.is_dir() # Auto-generated gitignore must exist assert (result / ".gitignore").is_file() content = (result / ".gitignore").read_text(encoding="utf-8") assert content.strip().endswith("*") def test_auto_gitignore_is_valid_utf8(self, tmp_path, monkeypatch): """Regression guard for #239 bug 1: the auto-generated .gitignore must be written as UTF-8 on every platform. Before the fix, ``write_text()`` was called without an encoding argument. The header contains an em-dash (U+2014) which Python writes using the system default codepage on Windows (cp1252 → byte 0x97), producing a file that cannot be decoded as UTF-8. """ monkeypatch.delenv("CRG_DATA_DIR", raising=False) from code_review_graph.incremental import get_data_dir data_dir = get_data_dir(tmp_path) gi = data_dir / ".gitignore" assert gi.is_file() # The file must be valid UTF-8 — this is what actually broke. raw = gi.read_bytes() # The em-dash must be stored as the proper UTF-8 sequence (0xE2 0x80 0x94), # not as the cp1252 single byte 0x97. assert b"\xe2\x80\x94" in raw, ( "auto-generated .gitignore is missing the UTF-8 em-dash; it was " "probably written using the platform default codepage" ) assert b"\x97" not in raw, ( "auto-generated .gitignore contains cp1252 byte 0x97 — indicates " "write_text was called without encoding='utf-8'" ) # And it must round-trip cleanly under strict UTF-8 decoding. decoded = raw.decode("utf-8", errors="strict") assert "—" in decoded, "em-dash missing from decoded gitignore" def test_env_override_replaces_repo_subdir(self, tmp_path, monkeypatch): """CRG_DATA_DIR replaces the default /.code-review-graph.""" external = tmp_path / "external-graphs" repo = tmp_path / "project" repo.mkdir() monkeypatch.setenv("CRG_DATA_DIR", str(external)) from code_review_graph.incremental import get_data_dir result = get_data_dir(repo) assert result == external.resolve() assert result.is_dir() # The repo itself should NOT have a .code-review-graph dir now assert not (repo / ".code-review-graph").exists() def test_get_db_path_uses_data_dir(self, tmp_path, monkeypatch): """get_db_path should honor CRG_DATA_DIR too.""" external = tmp_path / "external" repo = tmp_path / "project" repo.mkdir() monkeypatch.setenv("CRG_DATA_DIR", str(external)) from code_review_graph.incremental import get_db_path db_path = get_db_path(repo) assert db_path == external.resolve() / "graph.db" assert db_path.parent.is_dir() def test_find_project_root_env_override(self, tmp_path, monkeypatch): """CRG_REPO_ROOT should override normal git-root resolution.""" from pathlib import Path as PathType external_repo = tmp_path / "elsewhere" external_repo.mkdir() monkeypatch.setenv("CRG_REPO_ROOT", str(external_repo)) from code_review_graph.incremental import find_project_root result = find_project_root(PathType.cwd()) assert result == external_repo.resolve() def test_find_project_root_env_override_missing_dir_falls_through( self, tmp_path, monkeypatch, ): """CRG_REPO_ROOT pointing at a non-existent path falls back to the usual resolution rather than crashing.""" monkeypatch.setenv( "CRG_REPO_ROOT", str(tmp_path / "does-not-exist-123"), ) from code_review_graph.incremental import find_project_root result = find_project_root(tmp_path) # Should NOT equal the bogus env value assert result != tmp_path / "does-not-exist-123" class TestDataDirRegistry: """Tests for registry-based data_dir resolution.""" def test_registry_data_dir_overrides_default(self, tmp_path, monkeypatch): """Registry data_dir should override default .code-review-graph.""" from code_review_graph.incremental import get_data_dir from code_review_graph.registry import Registry repo = tmp_path / "project" repo.mkdir() external = tmp_path / "external" monkeypatch.delenv("CRG_DATA_DIR", raising=False) # Set in registry registry = Registry() registry.set_data_dir(str(repo), str(external)) result = get_data_dir(repo) assert result == external.resolve() assert result.is_dir() assert not (repo / ".code-review-graph").exists() def test_registry_data_dir_overrides_env_var(self, tmp_path, monkeypatch): """Registry data_dir should override CRG_DATA_DIR.""" from code_review_graph.incremental import get_data_dir from code_review_graph.registry import Registry repo = tmp_path / "project" repo.mkdir() registry_dir = tmp_path / "registry-data" env_dir = tmp_path / "env-data" monkeypatch.setenv("CRG_DATA_DIR", str(env_dir)) # Set in registry registry = Registry() registry.set_data_dir(str(repo), str(registry_dir)) result = get_data_dir(repo) # Registry should win over env var assert result == registry_dir.resolve() assert not env_dir.exists() def test_registry_fallback_to_env_var(self, tmp_path, monkeypatch): """Fall back to CRG_DATA_DIR when registry has no entry.""" from code_review_graph.incremental import get_data_dir repo = tmp_path / "project" repo.mkdir() env_dir = tmp_path / "env-data" monkeypatch.setenv("CRG_DATA_DIR", str(env_dir)) # Don't set in registry result = get_data_dir(repo) assert result == env_dir.resolve() assert result.is_dir() def test_registry_fallback_to_default(self, tmp_path, monkeypatch): """Fall back to default when neither registry nor env var is set.""" from code_review_graph.incremental import get_data_dir repo = tmp_path / "project" repo.mkdir() monkeypatch.delenv("CRG_DATA_DIR", raising=False) # Don't set in registry result = get_data_dir(repo) assert result == repo / ".code-review-graph" assert result.is_dir() def test_data_dir_auto_creates_directory(self, tmp_path, monkeypatch): """get_data_dir should auto-create the data directory.""" from code_review_graph.incremental import get_data_dir from code_review_graph.registry import Registry repo = tmp_path / "project" repo.mkdir() data_dir = tmp_path / "nonexistent" / "nested" / "path" monkeypatch.delenv("CRG_DATA_DIR", raising=False) registry = Registry() registry.set_data_dir(str(repo), str(data_dir)) result = get_data_dir(repo) assert result.exists() assert result.is_dir() assert result == data_dir.resolve() class TestIsBinary: def test_text_file_is_not_binary(self, tmp_path): f = tmp_path / "text.py" f.write_text("print('hello')\n") assert not _is_binary(f) def test_binary_file_is_binary(self, tmp_path): f = tmp_path / "binary.bin" f.write_bytes(b"header\x00binary data") assert _is_binary(f) def test_missing_file_is_binary(self, tmp_path): f = tmp_path / "missing.txt" assert _is_binary(f) class TestGitOperations: @patch("code_review_graph.incremental.subprocess.run") def test_get_changed_files(self, mock_run, tmp_path): mock_run.return_value = MagicMock( returncode=0, stdout=b"M\0src/a.py\0A\0src/b.py\0", ) result = get_changed_files(tmp_path) assert result == ["src/a.py", "src/b.py"] mock_run.assert_called_once() call_args = mock_run.call_args assert "git" in call_args[0][0] assert "-z" in call_args[0][0] assert call_args[1].get("timeout") == 30 assert "text" not in call_args[1] @patch("code_review_graph.incremental.subprocess.run") def test_get_changed_files_fallback(self, mock_run, tmp_path): # First call fails, second succeeds mock_run.side_effect = [ MagicMock(returncode=1, stdout=b""), MagicMock(returncode=0, stdout=b"A\0staged.py\0"), ] result = get_changed_files(tmp_path) assert result == ["staged.py"] assert mock_run.call_count == 2 assert "-z" in mock_run.call_args_list[1].args[0] @patch("code_review_graph.incremental.subprocess.run") def test_get_changed_files_rejects_failed_fallback(self, mock_run, tmp_path): mock_run.side_effect = [ MagicMock(returncode=128, stdout=b""), MagicMock(returncode=128, stdout=b"A\0misleading.py\0"), ] assert get_changed_files(tmp_path) == [] @patch("code_review_graph.incremental.subprocess.run") def test_get_changed_files_timeout(self, mock_run, tmp_path): mock_run.side_effect = subprocess.TimeoutExpired("git", 30) result = get_changed_files(tmp_path) assert result == [] @patch("code_review_graph.incremental.subprocess.run") def test_get_changed_files_rejects_option_like_base(self, mock_run, tmp_path): assert get_changed_files(tmp_path, base="--no-index") == [] mock_run.assert_not_called() @patch("code_review_graph.incremental.subprocess.run") def test_get_staged_and_unstaged(self, mock_run, tmp_path): mock_run.return_value = MagicMock( returncode=0, stdout=( b" M src/a.py\0" b"?? new.py\0" b"R new_name.py\0old.py\0" b"C copied.py\0source.py\0" b" M path -> literal.py\0" b" M leading and trailing.py \0" ), ) result = get_staged_and_unstaged(tmp_path) assert "src/a.py" in result assert "new.py" in result assert "new_name.py" in result assert "copied.py" in result assert "path -> literal.py" in result assert " leading and trailing.py " in result # Rename/copy sources should NOT be in results (destination-only). assert "old.py" not in result assert "source.py" not in result command = mock_run.call_args.args[0] assert "--untracked-files=all" in command @patch("code_review_graph.incremental.subprocess.run") def test_get_staged_and_unstaged_rejects_failed_status( self, mock_run, tmp_path ): mock_run.return_value = MagicMock( returncode=128, stdout=b"?? misleading.py\0", stderr=b"fatal: not a git repository", ) assert get_staged_and_unstaged(tmp_path) == [] @patch("code_review_graph.incremental.subprocess.run") def test_get_all_tracked_files(self, mock_run, tmp_path): mock_run.return_value = MagicMock( returncode=0, stdout="a.py\nb.py\nc.go\n", ) result = get_all_tracked_files(tmp_path) assert result == ["a.py", "b.py", "c.go"] @patch("code_review_graph.incremental.subprocess.run") def test_get_all_tracked_files_recurse_submodules_param( self, mock_run, tmp_path ): mock_run.return_value = MagicMock( returncode=0, stdout="a.py\nsub/b.py\n", ) result = get_all_tracked_files(tmp_path, recurse_submodules=True) assert result == ["a.py", "sub/b.py"] cmd = mock_run.call_args[0][0] assert "--recurse-submodules" in cmd @patch("code_review_graph.incremental.subprocess.run") def test_get_all_tracked_files_no_recurse_by_default( self, mock_run, tmp_path ): mock_run.return_value = MagicMock( returncode=0, stdout="a.py\n", ) result = get_all_tracked_files(tmp_path) assert result == ["a.py"] cmd = mock_run.call_args[0][0] assert "--recurse-submodules" not in cmd @patch("code_review_graph.incremental.subprocess.run") @patch("code_review_graph.incremental._RECURSE_SUBMODULES", True) def test_get_all_tracked_files_env_var_fallback( self, mock_run, tmp_path ): mock_run.return_value = MagicMock( returncode=0, stdout="a.py\nsub/c.py\n", ) # None -> falls back to env var (_RECURSE_SUBMODULES=True) result = get_all_tracked_files(tmp_path, recurse_submodules=None) assert result == ["a.py", "sub/c.py"] cmd = mock_run.call_args[0][0] assert "--recurse-submodules" in cmd @patch("code_review_graph.incremental.subprocess.run") @patch("code_review_graph.incremental._RECURSE_SUBMODULES", True) def test_get_all_tracked_files_param_overrides_env( self, mock_run, tmp_path ): mock_run.return_value = MagicMock( returncode=0, stdout="a.py\n", ) # Explicit False overrides env var result = get_all_tracked_files(tmp_path, recurse_submodules=False) assert result == ["a.py"] cmd = mock_run.call_args[0][0] assert "--recurse-submodules" not in cmd class TestFullBuild: def test_full_build_parses_files(self, tmp_path): # Create a simple Python file py_file = tmp_path / "sample.py" py_file.write_text("def hello():\n pass\n") (tmp_path / ".git").mkdir() db_path = tmp_path / "test.db" store = GraphStore(db_path) try: mock_target = "code_review_graph.incremental.get_all_tracked_files" with patch(mock_target, return_value=["sample.py"]): result = full_build(tmp_path, store) assert result["files_parsed"] == 1 assert result["total_nodes"] > 0 assert result["errors"] == [] assert store.get_metadata("last_build_type") == "full" finally: store.close() def test_full_build_removes_offline_deleted_directory_tree(self, tmp_path): package = tmp_path / "package" package.mkdir() first = package / "first.py" second = package / "second.py" first.write_text("def first():\n pass\n") second.write_text("def second():\n pass\n") store = GraphStore(tmp_path / "test.db") try: with patch( "code_review_graph.incremental.get_all_tracked_files", return_value=["package/first.py", "package/second.py"], ): full_build(tmp_path, store) first.unlink() second.unlink() package.rmdir() with patch( "code_review_graph.incremental.get_all_tracked_files", return_value=[], ): result = full_build(tmp_path, store) assert result["stale_files_removed"] == 2 assert store.get_all_files() == [] finally: store.close() class TestIncrementalUpdate: def test_incremental_with_no_changes(self, tmp_path): db_path = tmp_path / "test.db" store = GraphStore(db_path) try: result = incremental_update(tmp_path, store, changed_files=[]) assert result["files_updated"] == 0 finally: store.close() def test_empty_change_list_reconciles_offline_deletion_idempotently(self, tmp_path): deleted = tmp_path / "offline.py" deleted.write_text("def offline():\n pass\n") store = GraphStore(tmp_path / "test.db") try: incremental_update(tmp_path, store, changed_files=["offline.py"]) deleted.unlink() first = incremental_update(tmp_path, store, changed_files=[]) second = incremental_update(tmp_path, store, changed_files=[]) assert first["stale_files_removed"] == 1 assert first["files_updated"] == 1 assert second["stale_files_removed"] == 0 assert second["files_updated"] == 0 assert store.get_all_files() == [] finally: store.close() def test_reconciliation_keeps_existing_untracked_files_in_git_repo(self, tmp_path): subprocess.run( ["git", "init", "-q"], cwd=tmp_path, check=True, capture_output=True, ) tracked = tmp_path / "tracked.py" tracked.write_text("def tracked():\n return 1\n") subprocess.run( ["git", "add", "tracked.py"], cwd=tmp_path, check=True, capture_output=True, ) untracked = tmp_path / "untracked.py" untracked.write_text("def untracked():\n return 2\n") store = GraphStore(tmp_path / "test.db") try: incremental_update( tmp_path, store, changed_files=["tracked.py", "untracked.py"], ) assert store.get_nodes_by_file(str(untracked)) tracked.write_text("def tracked():\n return 3\n") result = incremental_update( tmp_path, store, changed_files=["tracked.py"], ) assert result["stale_files_removed"] == 0 assert store.get_nodes_by_file(str(untracked)) finally: store.close() def test_incremental_with_changed_file(self, tmp_path): py_file = tmp_path / "mod.py" py_file.write_text("def greet():\n return 'hi'\n") db_path = tmp_path / "test.db" store = GraphStore(db_path) try: result = incremental_update( tmp_path, store, changed_files=["mod.py"] ) assert result["files_updated"] >= 1 assert result["total_nodes"] > 0 finally: store.close() def test_incremental_deleted_file(self, tmp_path): db_path = tmp_path / "test.db" store = GraphStore(db_path) try: # Pre-populate with a file py_file = tmp_path / "old.py" py_file.write_text("x = 1\n") result = incremental_update(tmp_path, store, changed_files=["old.py"]) assert result["total_nodes"] > 0 # Now delete the file and run incremental py_file.unlink() incremental_update(tmp_path, store, changed_files=["old.py"]) # File should have been removed from graph nodes = store.get_nodes_by_file(str(tmp_path / "old.py")) assert len(nodes) == 0 finally: store.close() class TestRacingSaveSnapshotCoherence: """Regression tests for #746: a file saved while it is being indexed. Every parse-and-store path must be a pure function of one byte snapshot: the stored ``file_hash`` is the hash of the bytes that were actually parsed, and no parse decision may come from a second read of the file. Otherwise a save racing the indexer can persist a partial (or empty) parse under the final file hash, and the file is silently under-indexed until it changes again. """ def test_save_racing_parse_probe_does_not_wipe_extensionless_script( self, tmp_path, monkeypatch, ): """A save racing the parse-stage shebang probe must not wipe an extension-less script from the graph. Timeline being simulated: the user edits ``tool`` (v1 -> v2); the watcher hands the file to ``incremental_update``; the change filter still sees the intact file, but by the time ``parse_bytes`` runs its shebang probe an editor save (truncate+rewrite) has momentarily emptied the file on disk; the save then completes with the same v2 bytes. Before the fix, the parse-stage probe re-read the empty disk file, detected no language, and a complete v2 snapshot parsed to zero nodes — leaving the graph silently missing the file while ``status`` reports it up to date. """ script = tmp_path / "tool" v1 = b"#!/usr/bin/env python3\n\ndef damaged():\n return 0\n" v2 = b"#!/usr/bin/env python3\n\ndef damaged_v2():\n return 1\n" script.write_bytes(v1) store = GraphStore(tmp_path / "test.db") try: incremental_update( tmp_path, store, changed_files=["tool"], reconcile_stale=False, ) names = { n.name for n in store.get_nodes_by_file(str(script)) if n.kind == "Function" } assert "damaged" in names script.write_bytes(v2) # the user's edit # First probe (the change filter) sees the intact file; every # later on-disk probe hits the mid-save empty window. real_open = Path.open probes = {"count": 0} def racing_open(path_self, *args, **kwargs): if path_self == script and args[:1] == ("rb",): probes["count"] += 1 if probes["count"] >= 2: return io.BytesIO(b"") return real_open(path_self, *args, **kwargs) monkeypatch.setattr(Path, "open", racing_open) incremental_update( tmp_path, store, changed_files=["tool"], reconcile_stale=False, ) monkeypatch.undo() nodes = store.get_nodes_by_file(str(script)) names = {n.name for n in nodes if n.kind == "Function"} assert "damaged_v2" in names, ( "a save racing the parse-stage shebang probe wiped the " "script from the graph" ) # The stored hash is the hash of the bytes actually parsed, # which here equal the final on-disk content. assert nodes[0].file_hash == hashlib.sha256(v2).hexdigest() finally: store.close() def test_mid_save_read_stores_hash_of_parsed_snapshot( self, tmp_path, monkeypatch, ): """The serial incremental path must store the hash of the bytes it actually parsed. When the parse-stage read races a save and captures a partial file, the stored hash is the partial content's hash, so the file still looks stale against the final on-disk bytes and the next update repairs it. """ py = tmp_path / "mod.py" prefix = b"def full_one():\n return 1\n" final = prefix + b"\n\ndef full_two():\n return 2\n" py.write_bytes(final) store = GraphStore(tmp_path / "test.db") try: reads = {"count": 0} real_read_bytes = Path.read_bytes def mid_save_read(path_self): if path_self == py: reads["count"] += 1 if reads["count"] == 2: # the parse-stage read return prefix return real_read_bytes(path_self) monkeypatch.setattr(Path, "read_bytes", mid_save_read) incremental_update(tmp_path, store, changed_files=["mod.py"]) monkeypatch.undo() nodes = store.get_nodes_by_file(str(py)) assert nodes, "racing read must not wipe the file from the graph" names = {n.name for n in nodes if n.kind == "Function"} assert names == {"full_one"} stored_hash = nodes[0].file_hash assert stored_hash == hashlib.sha256(prefix).hexdigest() assert stored_hash != hashlib.sha256(final).hexdigest() # The stale hash makes the next update re-index the file. incremental_update(tmp_path, store, changed_files=["mod.py"]) nodes = store.get_nodes_by_file(str(py)) names = {n.name for n in nodes if n.kind == "Function"} assert names == {"full_one", "full_two"} assert nodes[0].file_hash == hashlib.sha256(final).hexdigest() finally: store.close() class TestParallelParsing: def test_parse_single_file(self, tmp_path): py_file = tmp_path / "single.py" py_file.write_text("def foo():\n pass\n") rel_path, nodes, edges, error, fhash = _parse_single_file( ("single.py", str(tmp_path)) ) assert rel_path == "single.py" assert error is None assert len(nodes) > 0 assert fhash != "" def test_parse_single_file_missing(self, tmp_path): rel_path, nodes, edges, error, fhash = _parse_single_file( ("missing.py", str(tmp_path)) ) assert error is not None assert nodes == [] assert edges == [] def test_parse_single_file_reuses_parser_in_worker(self, tmp_path): (tmp_path / "first.py").write_text("first = 1\n") (tmp_path / "second.py").write_text("second = 2\n") with patch.object(incremental_module, "CodeParser") as parser_cls: parser_cls.return_value.parse_bytes.return_value = ([], []) _parse_single_file(("first.py", str(tmp_path))) _parse_single_file(("second.py", str(tmp_path))) parser_cls.assert_called_once_with(tmp_path) assert parser_cls.return_value.parse_bytes.call_count == 2 def test_parse_single_file_does_not_reuse_parser_across_repos(self, tmp_path): first_repo = tmp_path / "first" second_repo = tmp_path / "second" first_repo.mkdir() second_repo.mkdir() (first_repo / "mod.py").write_text("first = 1\n") (second_repo / "mod.py").write_text("second = 2\n") with patch.object(incremental_module, "CodeParser") as parser_cls: parser_cls.return_value.parse_bytes.return_value = ([], []) _parse_single_file(("mod.py", str(first_repo))) _parse_single_file(("mod.py", str(second_repo))) assert parser_cls.call_args_list == [ call(first_repo), call(second_repo), ] def test_parse_single_file_keeps_thread_worker_parsers_isolated(self, tmp_path): files = ["first.py", "second.py"] for filename in files: (tmp_path / filename).write_text(f"name = {filename!r}\n") barrier = incremental_module.threading.Barrier(len(files)) parser_instances = [] class BlockingParser: def __init__(self, repo_root): self.repo_root = repo_root parser_instances.append(self) def parse_bytes(self, path, raw): barrier.wait(timeout=5) return [], [] with patch.object(incremental_module, "CodeParser", BlockingParser): with incremental_module.concurrent.futures.ThreadPoolExecutor( max_workers=len(files) ) as executor: results = list( executor.map( _parse_single_file, [(filename, str(tmp_path)) for filename in files], ) ) assert len(parser_instances) == len(files) assert all(result[3] is None for result in results) def test_parallel_build_produces_same_results(self, tmp_path): """Serial and parallel builds produce identical node/edge counts.""" (tmp_path / ".git").mkdir() # Create several Python files for i in range(10): (tmp_path / f"mod{i}.py").write_text( f"def func_{i}():\n return {i}\n\n" f"class Cls{i}:\n pass\n" ) tracked = [f"mod{i}.py" for i in range(10)] mock_target = "code_review_graph.incremental.get_all_tracked_files" # Serial build db_serial = tmp_path / "serial.db" store_serial = GraphStore(db_serial) try: with patch(mock_target, return_value=tracked): with patch.dict("os.environ", {"CRG_SERIAL_PARSE": "1"}): result_serial = full_build(tmp_path, store_serial) serial_nodes = result_serial["total_nodes"] serial_edges = result_serial["total_edges"] serial_files = result_serial["files_parsed"] finally: store_serial.close() # Parallel build db_parallel = tmp_path / "parallel.db" store_parallel = GraphStore(db_parallel) try: with patch(mock_target, return_value=tracked): with patch.dict("os.environ", {"CRG_SERIAL_PARSE": ""}): result_parallel = full_build(tmp_path, store_parallel) parallel_nodes = result_parallel["total_nodes"] parallel_edges = result_parallel["total_edges"] parallel_files = result_parallel["files_parsed"] finally: store_parallel.close() assert serial_files == parallel_files assert serial_nodes == parallel_nodes assert serial_edges == parallel_edges class TestMultiHopDependents: """Tests for N-hop dependent discovery.""" def _make_chain_store(self, tmp_path): """Build A -> B -> C chain in the graph.""" from code_review_graph.parser import EdgeInfo, NodeInfo db_path = tmp_path / "chain.db" store = GraphStore(db_path) for name, path in [("a", "/a.py"), ("b", "/b.py"), ("c", "/c.py")]: store.upsert_node(NodeInfo( kind="File", name=path, file_path=path, line_start=1, line_end=10, language="python", )) store.upsert_node(NodeInfo( kind="Function", name=f"func_{name}", file_path=path, line_start=2, line_end=8, language="python", )) # A imports B, B imports C store.upsert_edge(EdgeInfo( kind="IMPORTS_FROM", source="/a.py::func_a", target="/b.py::func_b", file_path="/a.py", line=1, )) store.upsert_edge(EdgeInfo( kind="IMPORTS_FROM", source="/b.py::func_b", target="/c.py::func_c", file_path="/b.py", line=1, )) store.commit() return store def test_single_hop_finds_direct_only(self, tmp_path): store = self._make_chain_store(tmp_path) try: deps = _single_hop_dependents(store, "/c.py") assert "/b.py" in deps assert "/a.py" not in deps finally: store.close() def test_one_hop_finds_b_not_a(self, tmp_path): store = self._make_chain_store(tmp_path) try: deps = find_dependents(store, "/c.py", max_hops=1) assert "/b.py" in deps assert "/a.py" not in deps finally: store.close() def test_two_hops_finds_b_and_a(self, tmp_path): store = self._make_chain_store(tmp_path) try: deps = find_dependents(store, "/c.py", max_hops=2) assert "/b.py" in deps assert "/a.py" in deps finally: store.close() def test_cap_triggers_on_many_files(self, tmp_path): """The 500-file cap prevents runaway expansion.""" from code_review_graph.parser import EdgeInfo, NodeInfo db_path = tmp_path / "big.db" store = GraphStore(db_path) try: # Hub node that many files depend on store.upsert_node(NodeInfo( kind="File", name="/hub.py", file_path="/hub.py", line_start=1, line_end=10, language="python", )) store.upsert_node(NodeInfo( kind="Function", name="hub_func", file_path="/hub.py", line_start=2, line_end=8, language="python", )) for i in range(600): path = f"/dep{i}.py" store.upsert_node(NodeInfo( kind="File", name=path, file_path=path, line_start=1, line_end=10, language="python", )) store.upsert_node(NodeInfo( kind="Function", name=f"func_{i}", file_path=path, line_start=2, line_end=8, language="python", )) store.upsert_edge(EdgeInfo( kind="IMPORTS_FROM", source=f"{path}::func_{i}", target="/hub.py::hub_func", file_path=path, line=1, )) store.commit() # Even with high max_hops, cap should limit results deps = find_dependents(store, "/hub.py", max_hops=5) assert len(deps) <= 500 finally: store.close() def test_truncated_flag_set_when_capped(self, tmp_path): """Regression test for #261: find_dependents must set DependentList.truncated = True when the result is capped.""" from code_review_graph.parser import EdgeInfo, NodeInfo db_path = tmp_path / "trunc.db" store = GraphStore(db_path) try: store.upsert_node(NodeInfo( kind="File", name="/hub.py", file_path="/hub.py", line_start=1, line_end=10, language="python", )) store.upsert_node(NodeInfo( kind="Function", name="hub_func", file_path="/hub.py", line_start=2, line_end=8, language="python", )) for i in range(600): path = f"/dep{i}.py" store.upsert_node(NodeInfo( kind="File", name=path, file_path=path, line_start=1, line_end=10, language="python", )) store.upsert_node(NodeInfo( kind="Function", name=f"func_{i}", file_path=path, line_start=2, line_end=8, language="python", )) store.upsert_edge(EdgeInfo( kind="IMPORTS_FROM", source=f"{path}::func_{i}", target="/hub.py::hub_func", file_path=path, line=1, )) store.commit() deps = find_dependents(store, "/hub.py", max_hops=5) assert len(deps) <= 500 # The key assertion: truncated flag must be set. assert deps.truncated is True, ( "DependentList.truncated should be True when capped at " "_MAX_DEPENDENT_FILES, but it was False" ) finally: store.close() def test_truncated_flag_false_when_not_capped(self, tmp_path): """Regression test for #261: find_dependents must set DependentList.truncated = False when the result is complete.""" store = self._make_chain_store(tmp_path) try: deps = find_dependents(store, "/c.py", max_hops=2) assert deps.truncated is False, ( "DependentList.truncated should be False when the " "expansion completed without hitting the cap" ) finally: store.close() class TestStartWatchThread: @patch("code_review_graph.incremental.watch") def test_starts_background_thread(self, mock_watch, tmp_path): """start_watch_thread returns a running thread when watchdog is available.""" import threading barrier = threading.Event() mock_watch.side_effect = lambda *a, **kw: barrier.wait(timeout=5) db_path = tmp_path / "graph.db" store = GraphStore(db_path) try: thread = start_watch_thread(tmp_path, store, daemon=True) assert thread is not None assert thread.daemon is True assert thread.is_alive() finally: barrier.set() store.close() class TestWatchReconciliation: @pytest.mark.parametrize( "event_factory", [ pytest.param("FileOpenedEvent", id="file-opened"), pytest.param("FileClosedEvent", id="file-closed"), pytest.param("FileClosedNoWriteEvent", id="file-closed-no-write"), pytest.param("DirModifiedEvent", id="directory-modified"), ], ) def test_watch_dispatch_ignores_irrelevant_events(self, tmp_path, event_factory): from watchdog import events store = GraphStore(tmp_path / "graph.db") debouncer = MagicMock() with patch( "watchdog.utils.event_debouncer.EventDebouncer", return_value=debouncer, ): handler = _create_watch_handler(tmp_path, store, None) try: event_type = getattr(events, event_factory) handler.dispatch(event_type(str(tmp_path / "source.py"))) debouncer.handle_event.assert_not_called() finally: store.close() def test_watch_file_batch_skips_repository_inventory(self, tmp_path): source = tmp_path / "source.py" source.write_text("def source():\n return 1\n") store = GraphStore(tmp_path / "graph.db") handler = _create_watch_handler(tmp_path, store, None) try: from watchdog.events import FileModifiedEvent with patch( "code_review_graph.incremental.collect_all_files", side_effect=AssertionError("watch batch inventoried repository"), ): handler.process([FileModifiedEvent(str(source))]) assert store.get_nodes_by_file(str(source)) finally: store.close() def test_watch_reconciles_before_observer_startup(self, tmp_path): deleted = tmp_path / "offline.py" deleted.write_text("def offline():\n pass\n") store = GraphStore(tmp_path / "graph.db") incremental_update(tmp_path, store, changed_files=["offline.py"]) deleted.unlink() callback_count = 0 def on_files_updated(_store): nonlocal callback_count callback_count += 1 try: with ( patch("watchdog.observers.Observer") as observer, patch("time.sleep", side_effect=KeyboardInterrupt), ): watch(tmp_path, store, on_files_updated=on_files_updated) assert callback_count == 1 assert store.get_all_files() == [] observer.return_value.start.assert_called_once() finally: store.close() def test_watch_startup_postprocess_warning_prevents_observer_start(self, tmp_path): import sqlite3 from code_review_graph.postprocessing import run_post_processing deleted = tmp_path / "offline.py" deleted.write_text("def offline():\n pass\n") store = GraphStore(tmp_path / "graph.db") incremental_update(tmp_path, store, changed_files=["offline.py"]) deleted.unlink() try: with ( patch("watchdog.observers.Observer") as observer, patch("time.sleep", side_effect=KeyboardInterrupt), patch( "code_review_graph.search.rebuild_fts_index", side_effect=sqlite3.OperationalError("forced FTS failure"), ), pytest.raises( RuntimeError, match="post-processing reported warnings", ), ): watch(tmp_path, store, on_files_updated=run_post_processing) observer.assert_not_called() finally: store.close() def test_watch_file_move_runs_one_serialized_callback(self, tmp_path): source = tmp_path / "source.py" destination = tmp_path / "destination.py" source.write_text("def moved():\n pass\n") store = GraphStore(tmp_path / "graph.db") incremental_update(tmp_path, store, changed_files=["source.py"]) callback_count = 0 def on_files_updated(_store): nonlocal callback_count callback_count += 1 handler = _create_watch_handler(tmp_path, store, on_files_updated) try: from watchdog.events import FileMovedEvent source.rename(destination) handler.process([FileMovedEvent(str(source), str(destination))]) assert callback_count == 1 assert store.get_nodes_by_file(str(source)) == [] assert store.get_nodes_by_file(str(destination)) finally: store.close() def test_watch_noop_batch_does_not_call_callback(self, tmp_path): source = tmp_path / "source.py" source.write_text("def unchanged():\n pass\n") store = GraphStore(tmp_path / "graph.db") incremental_update(tmp_path, store, changed_files=["source.py"]) callback_count = 0 def on_files_updated(_store): nonlocal callback_count callback_count += 1 handler = _create_watch_handler(tmp_path, store, on_files_updated) try: from watchdog.events import FileModifiedEvent source.touch() handler.process([FileModifiedEvent(str(source))]) assert callback_count == 0 finally: store.close() def test_watch_pure_file_deletion_counts_change_and_calls_callback_once(self, tmp_path): source = tmp_path / "deleted.py" source.write_text("def deleted():\n pass\n") store = GraphStore(tmp_path / "graph.db") incremental_update(tmp_path, store, changed_files=["deleted.py"]) source.unlink() callback = MagicMock() handler = _create_watch_handler(tmp_path, store, callback) try: from watchdog.events import FileDeletedEvent handler.process([FileDeletedEvent(str(source))]) callback.assert_called_once_with(store) assert store.get_nodes_by_file(str(source)) == [] finally: store.close() def test_watch_directory_create_indexes_parseable_descendants(self, tmp_path): store = GraphStore(tmp_path / "graph.db") handler = _create_watch_handler(tmp_path, store, None) package = tmp_path / "package" package.mkdir() source = package / "created.py" source.write_text("def created():\n pass\n") try: from watchdog.events import DirCreatedEvent handler.process([DirCreatedEvent(str(package))]) assert store.get_nodes_by_file(str(source)) finally: store.close() def test_watch_directory_move_replaces_source_tree(self, tmp_path): source_dir = tmp_path / "source" source_dir.mkdir() source = source_dir / "moved.py" source.write_text("def moved():\n pass\n") store = GraphStore(tmp_path / "graph.db") incremental_update(tmp_path, store, changed_files=["source/moved.py"]) destination_dir = tmp_path / "destination" source_dir.rename(destination_dir) destination = destination_dir / "moved.py" handler = _create_watch_handler(tmp_path, store, None) try: from watchdog.events import DirMovedEvent with patch( "code_review_graph.incremental.collect_all_files", side_effect=AssertionError("directory move inventoried repository"), ): handler.process([DirMovedEvent(str(source_dir), str(destination_dir))]) assert store.get_nodes_by_file(str(source)) == [] assert store.get_nodes_by_file(str(destination)) finally: store.close() def test_watch_directory_delete_reconciles_descendants(self, tmp_path): package = tmp_path / "package" package.mkdir() source = package / "deleted.py" source.write_text("def deleted():\n pass\n") store = GraphStore(tmp_path / "graph.db") incremental_update(tmp_path, store, changed_files=["package/deleted.py"]) source.unlink() package.rmdir() handler = _create_watch_handler(tmp_path, store, None) try: from watchdog.events import DirDeletedEvent with patch( "code_review_graph.incremental.collect_all_files", side_effect=AssertionError("directory delete inventoried repository"), ): handler.process([DirDeletedEvent(str(package))]) assert store.get_nodes_by_file(str(source)) == [] finally: store.close() def test_watch_symlink_events_are_rejected(self, tmp_path): target = tmp_path / "target.py" target.write_text("def target():\n pass\n") linked_file = tmp_path / "linked.py" linked_file.symlink_to(target) real_dir = tmp_path / "real" real_dir.mkdir() (real_dir / "inside.py").write_text("def inside():\n pass\n") linked_dir = tmp_path / "linked_dir" linked_dir.symlink_to(real_dir, target_is_directory=True) store = GraphStore(tmp_path / "graph.db") handler = _create_watch_handler(tmp_path, store, None) try: from watchdog.events import DirCreatedEvent, FileCreatedEvent handler.process( [FileCreatedEvent(str(linked_file)), DirCreatedEvent(str(linked_dir))] ) assert store.get_all_files() == [] finally: store.close() def test_watch_rejects_file_below_symlinked_ancestor_outside_repo(self, tmp_path): outside = tmp_path.parent / f"{tmp_path.name}-outside" outside.mkdir() outside_file = outside / "escaped.py" outside_file.write_text("def escaped():\n pass\n") linked_dir = tmp_path / "linked" linked_dir.symlink_to(outside, target_is_directory=True) store = GraphStore(tmp_path / "graph.db") callback = MagicMock() handler = _create_watch_handler(tmp_path, store, callback) try: from watchdog.events import DirCreatedEvent, FileCreatedEvent handler.process( [ FileCreatedEvent(str(linked_dir / "escaped.py")), DirCreatedEvent(str(linked_dir)), ] ) callback.assert_not_called() assert store.get_all_files() == [] finally: store.close() outside_file.unlink() outside.rmdir() def test_watch_update_and_callback_never_overlap(self, tmp_path): source = tmp_path / "source.py" source.write_text("def source():\n pass\n") store = GraphStore(tmp_path / "graph.db") active = False phases = [] def callback(_store): nonlocal active assert active is False phases.append("callback") handler = _create_watch_handler(tmp_path, store, callback) original_update = incremental_module.incremental_update def tracked_update(*args, **kwargs): nonlocal active assert active is False active = True phases.append("update") try: return original_update(*args, **kwargs) finally: active = False try: from watchdog.events import FileCreatedEvent with patch("code_review_graph.incremental.incremental_update", tracked_update): handler.process([FileCreatedEvent(str(source))]) assert phases == ["update", "callback"] finally: store.close() def test_watch_update_failure_propagates_to_boundary(self, tmp_path): broken = tmp_path / "broken.py" broken.write_text("def broken():\n pass\n") store = GraphStore(tmp_path / "graph.db") handler = _create_watch_handler(tmp_path, store, None) try: from watchdog.events import FileCreatedEvent with patch( "code_review_graph.incremental.incremental_update", side_effect=RuntimeError("update failed"), ): handler.process([FileCreatedEvent(str(broken))]) with pytest.raises(RuntimeError, match="watch update failed"): handler.raise_if_failed() finally: store.close() def test_watch_incremental_error_result_propagates_to_boundary(self, tmp_path): source = tmp_path / "source.py" source.write_text("def source():\n return 1\n") store = GraphStore(tmp_path / "graph.db") handler = _create_watch_handler(tmp_path, store, None) try: from watchdog.events import FileCreatedEvent with patch( "code_review_graph.incremental.CodeParser.parse_bytes", side_effect=RuntimeError("forced parse failure"), ): handler.process([FileCreatedEvent(str(source))]) with pytest.raises(RuntimeError, match="watch update failed") as exc_info: handler.raise_if_failed() assert "source.py: forced parse failure" in str(exc_info.value.__cause__) finally: store.close() def test_watch_callback_failure_propagates_to_boundary(self, tmp_path): source = tmp_path / "source.py" source.write_text("def source():\n pass\n") store = GraphStore(tmp_path / "graph.db") def failing_callback(_store): raise RuntimeError("callback failed") handler = _create_watch_handler(tmp_path, store, failing_callback) try: from watchdog.events import FileCreatedEvent handler.process([FileCreatedEvent(str(source))]) with pytest.raises(RuntimeError, match="watch update failed"): handler.raise_if_failed() finally: store.close() def test_watch_parse_errors_propagate_to_boundary(self, tmp_path): broken = tmp_path / "broken.py" broken.write_text("def broken():\n pass\n") store = GraphStore(tmp_path / "graph.db") handler = _create_watch_handler(tmp_path, store, None) result = { "files_updated": 0, "errors": [{"file": "broken.py", "error": "parse failed"}], } try: from watchdog.events import FileCreatedEvent with patch( "code_review_graph.incremental.incremental_update", return_value=result, ): handler.process([FileCreatedEvent(str(broken))]) with pytest.raises(RuntimeError, match="watch update failed"): handler.raise_if_failed() finally: store.close() @pytest.mark.parametrize("filename", ["unsupported.txt", "binary.py"]) def test_watch_unsupported_or_binary_paths_skip_postprocessing(self, tmp_path, filename): source = tmp_path / filename content = b"plain text" if filename.endswith(".txt") else b"\x00binary" source.write_bytes(content) store = GraphStore(tmp_path / "graph.db") callback = MagicMock() handler = _create_watch_handler(tmp_path, store, callback) try: from watchdog.events import FileCreatedEvent handler.process([FileCreatedEvent(str(source))]) callback.assert_not_called() assert store.get_all_files() == [] finally: store.close() def test_watch_postprocess_warning_result_propagates_to_boundary(self, tmp_path): import sqlite3 from watchdog.events import FileCreatedEvent from code_review_graph.postprocessing import run_post_processing source = tmp_path / "source.py" source.write_text("def source():\n return 1\n") store = GraphStore(tmp_path / "graph.db") handler = _create_watch_handler(tmp_path, store, run_post_processing) try: with patch( "code_review_graph.search.rebuild_fts_index", side_effect=sqlite3.OperationalError("forced FTS failure"), ): handler.process([FileCreatedEvent(str(source))]) with pytest.raises(RuntimeError, match="watch update failed") as exc_info: handler.raise_if_failed() assert "FTS index rebuild failed" in str(exc_info.value.__cause__) finally: store.close() def test_returns_none_when_watchdog_unavailable(self, tmp_path): """start_watch_thread returns None when watchdog is not installed.""" db_path = tmp_path / "graph.db" store = GraphStore(db_path) try: with patch.dict("sys.modules", {"watchdog": None}): thread = start_watch_thread(tmp_path, store, daemon=True) assert thread is None finally: store.close() class TestRenamePurgeParity: """Issue #684: a rename must purge the old path so an incremental update converges to the same graph as a full rebuild.""" def _git(self, cwd, *args): subprocess.run( ["git", "-c", "user.email=t@test", "-c", "user.name=t", *args], cwd=str(cwd), check=True, capture_output=True, ) def test_decode_name_status_emits_both_rename_paths(self): out = b"M\0app.py\0R100\0old.py\0new.py\0A\0added.py\0" assert _decode_name_status_paths(out) == ["app.py", "old.py", "new.py", "added.py"] def test_decode_name_status_copy_records_and_dedupe(self): out = b"C75\0src/a.py\0src/b.py\0M\0src/a.py\0" assert _decode_name_status_paths(out) == ["src/a.py", "src/b.py"] def test_decode_name_status_empty(self): assert _decode_name_status_paths(b"") == [] @patch("code_review_graph.incremental.subprocess.run") def test_get_changed_files_reports_both_sides_of_rename(self, mock_run, tmp_path): mock_run.return_value = MagicMock( returncode=0, stdout=b"R100\0old.py\0new.py\0", ) assert get_changed_files(tmp_path) == ["old.py", "new.py"] def test_rename_purges_old_path_end_to_end(self, tmp_path): self._git(tmp_path, "init", "-q") (tmp_path / "a.py").write_text("def foo():\n return 1\n") self._git(tmp_path, "add", ".") self._git(tmp_path, "commit", "-qm", "init") store = GraphStore(tmp_path / "g.db") try: incremental_update(tmp_path, store, changed_files=["a.py"]) assert store.get_nodes_by_file(str(tmp_path / "a.py")) self._git(tmp_path, "mv", "a.py", "b.py") self._git(tmp_path, "commit", "-qm", "rename") changed = get_changed_files(tmp_path, base="HEAD~1") assert set(changed) == {"a.py", "b.py"} incremental_update(tmp_path, store, changed_files=changed) # Old path fully purged, new path present — full-rebuild parity. assert store.get_nodes_by_file(str(tmp_path / "a.py")) == [] assert store.get_nodes_by_file(str(tmp_path / "b.py")) finally: store.close()