Skip to content

feat(build): opt-in following of symlinked folders, inside or outside the repo - #1179

Merged
3 commits merged into
stagingfrom
feat/269-follow-symlinks
Nov 9, 2026
Merged

3 commits merged into
stagingfrom
feat/269-follow-symlinks

Conversation

@tirth8205

@tirth8205 tirth8205 commented Nov 9, 2026 •

Copy link
Copy Markdown
Owner

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) on build, update and watch
  • CRG_FOLLOW_SYMLINKS=1, the same kind of switch as CRG_RECURSE_SUBMODULES. The flag wins when given. Hooks, the MCP server and the daemon pick up the env var.

When it is on:

  • Links to folders inside and outside the repo are followed. The user made the link and asked for it.
  • Each file is stored once, under a path inside the repo. A file outside is stored under the link's path (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.
  • Cycles: a link to a folder that holds it, or holds the repository, is skipped and logged once. Each real folder is listed once, keyed by device and inode, so links that point at each other cannot loop.
  • Cap: at most 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.
  • Ignore rules: .code-review-graphignore and 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 update compares 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. watch keeps 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:

Place Before Now
Search source snippets refused any path that resolves outside reads followed files through the in-repo path; refuses the rest
Review context snippets read any root / path, so ../x or a path through any link leaked reads followed files; refuses other paths outside the repo
detect_changes / get_flow snippets no guard same guard
apply_refactor refused anything resolving outside also edits followed files the preview named
forget, incremental hashing matched paths without resolving them still work through the in-repo path
Incremental parse parsed any existing file named to it a path that leaves the repo or goes through a link is parsed only if the followed folders reach it, else treated as gone
Import edges the parser resolves symlinks, so edges named the outside path pointed back at the in-repo path, in memory and after the resolvers

All 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 only inputs/projA -> elsewhere/projA (Python), inputs/projB -> elsewhere/projB (TypeScript) and inputs/all -> <folder holding project>. Inside projA there is also up -> ...

== 1. staging code (today): build
Full build: 0 files, 0 nodes, 0 edges
== 2. this branch, option off: build
Full build: 0 files, 0 nodes, 0 edges
== 3. this branch: build --follow-symlinks
INFO: Not following inputs/all: it points at a folder that holds it
INFO: Not following inputs/projA/up: it points at a folder that holds it
Full build: 4 files, 8 nodes, 8 edges
inputs/projA/app.py  inputs/projA/helpers.py  inputs/projB/index.ts  inputs/projB/util.ts
-- stored paths or edge targets naming the outside folder: 0
-- import edge from index.ts: inputs/projB/util.ts
== 4. query and source read back through the in-repo path
query file_summary inputs/projA/app.py -> 2 results
search source: inputs/projA/helpers.py -> def helper():
review snippet: ['inputs/projB/util.ts'] -> 1: export function x() { return 1; }
refused paths leak source: False      (inputs/all/elsewhere/..., ../elsewhere/...)
== 5. edit a file in the outside folder, then update
functions in inputs/projA/helpers.py: helper extra
== 6. real `watch --follow-symlinks` (macOS FSEvents)
after editing helpers.py and adding fresh.py outside: main helper watched fresh
after rm inputs/projB: inputs/projA/app.py inputs/projA/fresh.py inputs/projA/helpers.py
== 7. forget inputs/projA/fresh.py -> Forgot 1 file(s)
== 8. full build: inputs/projA/app.py inputs/projA/fresh.py inputs/projA/helpers.py  (same as watch held)

Cap, CRG_MAX_FOLLOWED_FILES=3 on a linked folder of 6 files:

WARNING: Stopped following symlinked folders after 3 files (CRG_MAX_FOLLOWED_FILES); files past that point are left out of the graph
Full build: 3 files, 6 nodes, 3 edges

A Git repo with app/lib -> ../vendored/lib (Git-ignored), app/ext -> <outside>, and cycles app/loop -> .. and vendored/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. Removing app/lib and running update drops vendored/lib/*.

tests/test_follow_symlinks.py (32 tests) covers:

  • off by default, the env var and flag precedence, and the CLI flag on build, update and watch
  • in-repo paths for every node and edge, no copies, links spelled in another case, two links to one outside folder
  • cycles and links above the repo, the cap and its log line, ignore rules
  • update matching a full build as files and links come and go, and update with the option off
  • refusing paths through unfollowed links, the read-back guard
  • search, review context, forget and rename on followed files
  • watch adding and dropping links, watch seeing edits in an outside folder, the supervisor's extra watches
  • the reporter's folder of links

On staging the module fails to import because the option does not exist.

Checks

  • uv run ruff check code_review_graph/: clean
  • uv run --extra dev python -m mypy code_review_graph/ --ignore-missing-imports --no-strict-optional: clean
  • uv run pytest tests/ --tb=short -q: 6422 passed, 901 skipped, 3 xfailed, 4 xpassed, 0 failed

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

code-review-graph review

Overall 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

Risk Level Symbol Location Tested
0.55 medium code_review_graph/tools/build.py::build_or_update_graph code_review_graph/tools/build.py:701 yes
0.50 medium code_review_graph/tools/query.py::_SourceBudget code_review_graph/tools/query.py:1194 indirect
0.50 medium code_review_graph/tools/query.py::_SourceBudget.__init__ code_review_graph/tools/query.py:1202 yes
0.50 medium code_review_graph/tools/query.py::_SourceBudget._lines code_review_graph/tools/query.py:1217 indirect
0.40 medium code_review_graph/cli.py::_add_follow_symlinks_arg code_review_graph/cli.py:514 indirect
0.40 medium code_review_graph/cli.py::_follow_symlinks_kwargs code_review_graph/cli.py:529 indirect
0.40 medium code_review_graph/incremental.py::_find_content_mismatches code_review_graph/incremental.py:1264 indirect
0.40 medium code_review_graph/incremental.py::_report_once code_review_graph/incremental.py:1729 no
0.40 medium code_review_graph/incremental.py::_path_key code_review_graph/incremental.py:1926 no
0.40 medium code_review_graph/incremental.py::_outermost code_review_graph/incremental.py:1976 no

Affected execution flows

  • build_or_update_graph_tool — criticality 0.73, 765 node(s) across 27 file(s)
  • main — criticality 0.72, 1112 node(s) across 45 file(s)
  • run_postprocess_tool — criticality 0.64, 68 node(s) across 14 file(s)

Test gaps

  • code_review_graph/cli.py::_FollowSymlinksKwargs (code_review_graph/cli.py:73)
  • code_review_graph/incremental.py::_report_once (code_review_graph/incremental.py:1729)
  • code_review_graph/incremental.py::_max_followed_files (code_review_graph/incremental.py:1742)
  • code_review_graph/incremental.py::_is_junction (code_review_graph/incremental.py:1747)
  • code_review_graph/incremental.py::_FollowedTree (code_review_graph/incremental.py:1763)
  • ...and 15 more with no direct test

Reached only through a caller

No 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.

  • code_review_graph/cli.py::_add_follow_symlinks_arg (code_review_graph/cli.py:514) — via code_review_graph/cli.py::build_parser, 1 hop(s)
  • code_review_graph/cli.py::_follow_symlinks_kwargs (code_review_graph/cli.py:529) — via code_review_graph/cli.py::main, 2 hop(s)
  • code_review_graph/cli.py::_handle_build_update (code_review_graph/cli.py:781) — via code_review_graph/cli.py::main, 2 hop(s)
  • code_review_graph/cli.py::_dispatch (code_review_graph/cli.py:1909) — via code_review_graph/cli.py::main, 1 hop(s)
  • code_review_graph/graph.py::GraphStore.relocate_edge_targets (code_review_graph/graph.py:1108) — via code_review_graph/incremental.py::full_build, 2 hop(s)
  • ...and 20 more reached only through a caller

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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-symlinks and CRG_FOLLOW_SYMLINKS configuration.
  • 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

Comment on lines +1773 to +1775
normalize_file_path(candidate)
for candidate in candidates
if os.path.islink(repo_root / candidate)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread code_review_graph/incremental.py Outdated
Comment on lines +1793 to +1796
relative = current.relative_to(root).as_posix()
prefix = "" if relative == "." else relative + "/"
for entry in entries:
entry_rel = prefix + entry.name

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread code_review_graph/incremental.py Outdated
Comment on lines +4039 to +4043
if follow and (
ignore_patterns is not patterns_before
or self._touches_followed_links(events)
):
files_updated += self._reconcile_followed_links()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@tirth8205 tirth8205 changed the title feat(build): opt-in following of symlinked folders inside the repo feat(build): opt-in following of symlinked folders, inside or outside the repo Nov 9, 2026
…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>
@tirth8205 tirth8205 closed this pull request by merging all changes into staging in d01c1fd Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] Wanna support folder softlink so I can easily manage the input files

2 participants