Files
code-review-graph/code-review-graph-main/tests/test_incremental.py
T

1772 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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/<user>/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 <repo>/.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 <repo>/.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()