Repository navigation
feat(build): opt-in following of symlinked folders, inside or outside the repo - #1179
3 commits merged into
Conversation
Symlinked folders give no nodes today, by design. This adds an opt-in, off by default: --follow-symlinks on build, update and watch, or CRG_FOLLOW_SYMLINKS=1 (the flag wins; --no-follow-symlinks turns it off for one run). When on, a link is followed only if it points inside the repository root. A link that points outside it, or at a folder that holds it, is skipped and logged once. Each real folder is listed once, by device and inode, so links that point at each other cannot loop. Each file is stored once, under its real path, so a link to a folder the graph already holds adds no copies. Ignore rules apply to the link path and the real path. Symlinked files are still never followed. Files that are in the graph only through a followed folder are recorded in metadata. A reconciling update compares that record with what the links reach now, so it adds new files behind a link and drops files whose link went (or when the option is turned off), matching a full build. The watcher keeps the set of followed links and runs that same reconciliation when a batch adds, removes or moves one. A graph that never used the option pays nothing: no extra inventory, no metadata row. Part of #269. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
code-review-graph reviewOverall risk: 0.55 (MEDIUM) — 106 changed function(s)/class(es), 3 affected flow(s), 45 test gap(s) (20 with no tested caller found, 25 reached only through a caller) Risk-scored changes
Affected execution flows
Test gaps
Reached only through a callerNo test names these directly, but a tested caller reaches them along the call path shown. That is a path in the call graph, not a record of execution -- the caller's tests may never take this branch. Treat it as where to look, not as coverage: these count as gaps and are scored as untested.
Token savings: this graph-backed report used ~170,933 fewer tokens (~54%) than reading every changed file in full (estimated, chars/4 approximation). Powered by code-review-graph — local-first analysis; no code leaves the CI runner. |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved symlink and junction handling gaps can bypass ignore and containment checks and leave followed-target files in the graph after link removal or disabling link following.
3 open issues
What changed in this PR
Adds opt-in indexing of symlinked folders within the repository, partially addressing #269 while keeping external targets unsupported and following disabled by default.
Changes:
- Adds
--follow-symlinks/--no-follow-symlinksandCRG_FOLLOW_SYMLINKSconfiguration. - Introduces directory traversal, deduplication, and followed-file tracking for build, update, and watch.
- Adds regression tests and documents configuration and limitations.
| File | Description |
|---|---|
| tests/test_follow_symlinks.py | Tests configuration, traversal, exclusions, updates, and watch behavior. |
| README.zh-CN.md | Documents the environment variable in Chinese. |
| README.md | Documents the environment variable. |
| README.ko-KR.md | Documents the environment variable in Korean. |
| README.ja-JP.md | Documents the environment variable in Japanese. |
| README.hi-IN.md | Documents the environment variable in Hindi. |
| docs/USAGE.md | Explains opt-in behavior and symlink limitations. |
| docs/COMMANDS.md | Adds a CLI usage example. |
| code_review_graph/tools/build.py | Passes the option through build and update operations. |
| code_review_graph/incremental.py | Implements traversal, followed-file metadata, and reconciliation. |
| code_review_graph/cli.py | Adds and forwards the CLI flags. |
| CHANGELOG.md | Announces opt-in symlinked-folder support. |
Review effort: Balanced
| normalize_file_path(candidate) | ||
| for candidate in candidates | ||
| if os.path.islink(repo_root / candidate) |
There was a problem hiding this comment.
No change: Git for Windows treats a junction as a plain folder, so files under one were already listed by their own path before this PR, with the option on or off; changing that is a wider Windows change than this PR.
| relative = current.relative_to(root).as_posix() | ||
| prefix = "" if relative == "." else relative + "/" | ||
| for entry in entries: | ||
| entry_rel = prefix + entry.name |
There was a problem hiding this comment.
Fixed in 3e27791: below a followed link, ignore rules now check the path through the link as well as the stored path, in build, update and watch, with tests.
| if follow and ( | ||
| ignore_patterns is not patterns_before | ||
| or self._touches_followed_links(events) | ||
| ): | ||
| files_updated += self._reconcile_followed_links() |
There was a problem hiding this comment.
Fixed in 3e27791: adding or removing a file in a followed folder inside the repo now reconciles the followed files, so it is recorded and goes with the link or the option, with a test.
With --follow-symlinks on, links to folders outside the repository are followed too: the user made the link and asked for it. This is the case #269 describes, a project folder whose code is links to folders kept elsewhere, which built an empty graph. Files behind an outside link are stored under the link's path inside the repository, never the outside real path. Files inside the repo keep their own path, so each file is stored once. A link to a folder that holds it or holds the repository is skipped and logged; each real folder is listed once by device and inode, so links cannot loop. CRG_MAX_FOLLOWED_FILES (default 10,000) caps the files taken through links, with one warning when it is hit. The parser resolves symlinks during import resolution, so edges are pointed back at the in-repo path, both as files are parsed and after the resolvers run. The outside folders are recorded in metadata for that. Watch keeps a recursive watch on each followed folder outside the repo and, on a change there or to a link, compares the followed files with the graph the way update does, reading only those files. Every place that reads a file back now goes through one guard, path_within_repo(): search, review context, detect-changes and flow snippets, and apply_refactor. It reads followed files through their in-repo path and still refuses any other path outside the repository. That also stops the review context reading ../ paths. Incremental update treats a path that leaves the repo or goes through a link it did not follow as gone. _validate_repo_root() is unchanged. Fixes #269. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lowed files Below a followed link to a folder inside the repository, files are stored under their own path, so a rule written for the path through the link, such as /app/shared/private/**, was never checked. Both paths are now checked, in the build, the update and the watcher. A file made in a followed folder inside the repository was indexed by the watcher but never recorded as reached through the link, so it stayed in the graph after the link went or the option was turned off. Adding or removing a file there now reconciles the followed files, which records it, and drops any file in such a folder that a build would leave out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

What
Fixes #269. The reporter asked for "folder softlink" support "so I can easily manage the input files", with no body and no reply to the follow-up question. That points to a project folder whose code is links to folders kept somewhere else. Today that folder builds an empty graph.
This adds an opt-in, off by default, so nothing changes for anyone who does not ask:
--follow-symlinks(and--no-follow-symlinks) onbuild,updateandwatchCRG_FOLLOW_SYMLINKS=1, the same kind of switch asCRG_RECURSE_SUBMODULES. The flag wins when given. Hooks, the MCP server and the daemon pick up the env var.When it is on:
inputs/projA/app.py), never its outside real path. A file inside keeps its own path, so a link to a folder the graph already holds adds no copies.CRG_MAX_FOLLOWED_FILES(default 10,000) files are taken through links. When the cap is hit, one warning says so:Stopped following symlinked folders after N files (CRG_MAX_FOLLOWED_FILES); files past that point are left out of the graph..code-review-graphignoreand the defaults apply to the in-repo path. Symlinked files are still never followed.Update and watch agree with full build. Two metadata rows record what came through links: the files, and each outside folder's real path mapped to its in-repo path. A reconciling
updatecompares the record with what the links reach now. It adds new files (Git never reports them), re-reads changed ones, and drops files whose link went, or all of them when the option is off.watchkeeps a recursive watch on each followed folder outside the repo, since the OS reports those changes under the real path. A link or file change there runs the same comparison over the followed files only. A graph that never used the option pays nothing (a test pins this).Reading files back and path guards:
root / path, so../xor a path through any link leakeddetect_changes/get_flowsnippetsapply_refactorforget, incremental hashingAll of these use one helper,
path_within_repo(root, path, followed)._validate_repo_root()is unchanged.Risk to note: a link committed by someone else can point anywhere on the machine. With the option on, its files are indexed and served to AI tools. The docs say to turn it on only for repositories whose links you trust.
Proof
The reporter's case: a plain folder
project/holding onlyinputs/projA -> elsewhere/projA(Python),inputs/projB -> elsewhere/projB(TypeScript) andinputs/all -> <folder holding project>. InsideprojAthere is alsoup -> ...Cap,
CRG_MAX_FOLLOWED_FILES=3on a linked folder of 6 files:A Git repo with
app/lib -> ../vendored/lib(Git-ignored),app/ext -> <outside>, and cyclesapp/loop -> ..andvendored/lib/self -> .: with the option off it is identical to staging (1 file, 2 nodes, 3 edges). With it on:app/ext/secret.py,app/main.py,vendored/lib/helpers.py, and both cycles are logged. Removingapp/liband runningupdatedropsvendored/lib/*.tests/test_follow_symlinks.py(32 tests) covers:build,updateandwatchforgetand rename on followed filesOn staging the module fails to import because the option does not exist.
Checks
uv run ruff check code_review_graph/: cleanuv run --extra dev python -m mypy code_review_graph/ --ignore-missing-imports --no-strict-optional: cleanuv run pytest tests/ --tb=short -q: 6422 passed, 901 skipped, 3 xfailed, 4 xpassed, 0 failed🤖 Generated with Claude Code