diff --git a/graphify/detect.py b/graphify/detect.py index 5351a57f0..fe93d997a 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -662,8 +662,29 @@ def _could_contain_included_path(path: Path, root: Path, patterns: list[tuple[Pa return False -def detect(root: Path, *, follow_symlinks: bool = False, google_workspace: bool | None = None) -> dict: +def _auto_follow_symlinks(root: Path) -> bool: + """Auto-detect: ``True`` if ``root`` has any direct symlinked child. + + Allows "fake working dir" patterns (e.g. a folder full of symlinks pointing + at scattered source dirs across the user's machine) to work transparently + without the caller having to know to pass ``follow_symlinks=True``. + + Override is always possible by passing an explicit ``follow_symlinks=True`` + or ``follow_symlinks=False`` to :func:`detect` / :func:`detect_incremental`. + """ + try: + for p in root.iterdir(): + if p.is_symlink(): + return True + except (OSError, PermissionError): + pass + return False + + +def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: bool | None = None) -> dict: root = root.resolve() + if follow_symlinks is None: + follow_symlinks = _auto_follow_symlinks(root) google_workspace = google_workspace_enabled() if google_workspace is None else google_workspace files: dict[FileType, list[str]] = { FileType.CODE: [], @@ -869,7 +890,7 @@ def detect_incremental( root: Path, manifest_path: str = _MANIFEST_PATH, *, - follow_symlinks: bool = False, + follow_symlinks: bool | None = None, google_workspace: bool | None = None, kind: str = "semantic", ) -> dict: @@ -893,7 +914,8 @@ def detect_incremental( The ``follow_symlinks`` flag is forwarded to :func:`detect` so corpora that rely on symlinked sub-trees (e.g. a ``state_of_truth/`` symlink pointing to a directory outside the scan root) are scanned consistently between full and - incremental runs. + incremental runs. ``None`` (default) means auto-detect: ``True`` when ``root`` + contains at least one direct symlinked child, ``False`` otherwise. """ full = detect(root, follow_symlinks=follow_symlinks, google_workspace=google_workspace) manifest = load_manifest(manifest_path) diff --git a/tests/test_detect.py b/tests/test_detect.py index 40fa21a0a..a7067d70a 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -226,6 +226,47 @@ def test_detect_handles_circular_symlinks(tmp_path): assert any("main.py" in f for f in result["files"]["code"]) +def test_detect_auto_detects_direct_symlink_child(tmp_path): + """When ``root`` has a direct symlinked child, default (None) follows symlinks + so the user does not have to know to pass follow_symlinks=True for "fake + working dir" patterns (folder of symlinks pointing at scattered sources).""" + real_dir = tmp_path / "real_lib" + real_dir.mkdir() + (real_dir / "util.py").write_text("x = 1") + (tmp_path / "linked_lib").symlink_to(real_dir) + + # Default (no kwarg): auto-detect → follows because of linked_lib symlink + result = detect(tmp_path) + assert any("linked_lib" in f for f in result["files"]["code"]) + + +def test_detect_default_does_not_follow_when_no_symlinks(tmp_path): + """When ``root`` has no direct symlinks, the auto-detect default stays False + (legacy behaviour preserved for ordinary scans).""" + (tmp_path / "main.py").write_text("x = 1") + sub = tmp_path / "sub" + sub.mkdir() + (sub / "other.py").write_text("y = 2") + + # Smoke: no symlinks anywhere → auto-detect returns False, scan succeeds + result = detect(tmp_path) + assert any("main.py" in f for f in result["files"]["code"]) + assert any("other.py" in f for f in result["files"]["code"]) + + +def test_detect_explicit_false_overrides_auto_detect(tmp_path): + """An explicit follow_symlinks=False overrides the auto-detect, even when + root contains symlinks. Lets callers opt out of the new behaviour.""" + real_dir = tmp_path / "real_lib" + real_dir.mkdir() + (real_dir / "util.py").write_text("x = 1") + (tmp_path / "linked_lib").symlink_to(real_dir) + + # Explicit False overrides auto-detect; symlink contents must NOT appear. + result = detect(tmp_path, follow_symlinks=False) + assert not any("linked_lib" in f for f in result["files"]["code"]) + + def test_detect_incremental_propagates_follow_symlinks(tmp_path, monkeypatch): """detect_incremental must forward follow_symlinks so symlinked sub-trees appear in incremental scans the same way they appear in full scans."""