feat(detect): auto-detect symlinked children when follow_symlinks is unset

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.
This commit is contained in:
Alpha Nury
2026-05-16 02:23:02 +02:00
parent d14e8a72d1
commit 5e178b9cd4
2 changed files with 66 additions and 3 deletions
+25 -3
View File
@@ -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)
+41
View File
@@ -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."""