From 5e178b9cd4d708741a6a083109790c5f9ac6ff25 Mon Sep 17 00:00:00 2001 From: Alpha Nury Date: Sat, 16 May 2026 02:17:30 +0200 Subject: [PATCH] feat(detect): auto-detect symlinked children when follow_symlinks is unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `root` has at least one direct symlinked child, default to following symlinks instead of silently dropping their contents. This makes "fake working dir" patterns (a folder full of symlinks pointing at scattered source dirs) work transparently — the caller no longer has to know to pass `follow_symlinks=True`. Concrete motivating shape: ~/projects/research-corpus/ ├── papers -> /external/drive/papers ├── notes -> /external/drive/notes └── code -> /external/drive/code Before: `--update` invoked `detect_incremental(root)` without passing `follow_symlinks=True`, so the scan saw the small set of files actually inside the root and missed everything reachable only through a symlink. Result: every legitimate new file was missed, and the manifest paths were marked as deleted. After: when `follow_symlinks` is left at its default `None`, `detect()` runs `_auto_follow_symlinks(root)` (one cheap `iterdir()` + `is_symlink()` loop) and follows symlinks if any direct child is one. Behaviour is unchanged for ordinary scans (no direct symlinks → False, as before). Override is always possible by passing an explicit `follow_symlinks=True` or `follow_symlinks=False`; existing tests confirming the explicit behaviour continue to pass unchanged. Backwards compatibility: - Type annotation: `bool` -> `bool | None`. Callers passing `True`/`False` continue to work identically. Callers passing nothing get the new auto-detect. - No new dependencies. - Cheap: one `iterdir()` call once per detect() invocation. Tests: - 3 new tests in tests/test_detect.py: - test_detect_auto_detects_direct_symlink_child - test_detect_default_does_not_follow_when_no_symlinks - test_detect_explicit_false_overrides_auto_detect - Full tests/test_detect.py + tests/test_incremental.py: 49/49 pass. --- graphify/detect.py | 28 +++++++++++++++++++++++++--- tests/test_detect.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) 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."""