Skip to content

fix: key File nodes on the link path so a deleted symlink is prunable (#1154) - #1155

Merged
vitali87 merged 1 commit into
mainfrom
fix/1154-symlink-deletion-identity
Aug 10, 2026
Merged

fix: key File nodes on the link path so a deleted symlink is prunable (#1154)#1155
vitali87 merged 1 commit into
mainfrom
fix/1154-symlink-deletion-identity

Conversation

@vitali87

@vitali87 vitali87 commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Closes #1154.

Problem

File.absolute_path was computed at ingest via cached_resolve_posix() = Path.resolve().as_posix(), which dereferences a leaf symlink to its target. Two consequences for a symlinked file:

  1. Deletion leaks the node. Once the link is removed from disk, resolve() can no longer follow it, so the delete key (CYPHER_DELETE_FILE matches absolute_path) never matches the stored target path — the File node is left behind (both the realtime path and graph_updater.py:1860).
  2. Prune skips it too. The batch prune only considers nodes whose absolute_path is inside repo_path. A link pointing outside the repo was stored under the external target path, so it failed the containment gate and was never swept.

Fix

Add cached_file_identity_posix() — resolves only the parent (which outlives the leaf) and keeps the leaf name, so ingest and deletion agree on the key, and a link stays inside the repo it lives in (consistent with the GHSA-85gg containment stance). Applied to the sole File-node creator (structure_processor.process_generic_file, node + CONTAINS_FILE edge) and to the contract_linking EXPOSES edge / index lookup that key on the same File identity.

Zero migration risk for regular files: when the leaf is not a symlink, parent.resolve()/name == resolve(), so identities are byte-identical. Only symlink leaves change — from broken to correct.

Scope

Test

test_symlinked_file_keyed_on_link_not_target (RED-verified): ingests a symlinked file, asserts the File node is keyed on the link’s own in-repo path (not the target), and that link.resolve() after unlink() recovers exactly that key — proving ingest/delete agreement. Reverting the one-line ingest change fails the test.

Summary by CodeRabbit

  • Bug Fixes
    • File indexing now preserves the original symlink path instead of replacing it with the target file’s path.
    • Stored file paths remain stable and continue to identify the link correctly even after the linked file is removed.
    • Contract and source references now consistently retain the indexed file’s original path identity.
  • Tests
    • Added coverage to verify symlink path preservation and stability.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds stable POSIX file identities that preserve symlink filenames. File nodes, containment relationships, contract indexing, and CONTRACT resources now use these identities. A regression test verifies identity persistence after symlink removal.

Changes

Symlink identity handling

Layer / File(s) Summary
File identity utility
codebase_rag/utils/path_utils.py
Adds cached_file_identity_posix, which resolves the parent directory and preserves the original leaf filename.
File node identity and regression coverage
codebase_rag/parsers/structure_processor.py, codebase_rag/tests/test_structure_processor.py
Generic file processing stores the stable identity on file nodes and containment relationships. The regression test verifies that a symlink path remains stored after unlinking.
Contract source and resource identity
codebase_rag/parsers/contract_linking.py
Contract source filtering and CONTRACT resource anchoring use stable file identities. Repository-prefix deletion continues to use resolved paths.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

  • Issue 1141: The change updates file identity semantics for File nodes and deletion-related path matching.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the primary fix: using the symlink path to make deleted File nodes prunable.
Description check ✅ Passed The description explains the problem, fix, scope, related issue, and regression test with sufficient detail.
Linked Issues check ✅ Passed The changes satisfy issue #1154 by preserving symlink identities for File nodes, relationships, lookups, and deletion recovery.
Out of Scope Changes check ✅ Passed All reported changes support the symlink identity fix and its regression coverage; no unrelated code changes are evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1154-symlink-deletion-identity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
codebase_rag/tests/test_structure_processor.py (1)

413-418: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Verify the containment target and post-unlink identity.

Add an assertion that the CONTAINS_FILE target uses stored.
Call cached_file_identity_posix(link) after link.unlink() and assert that it equals stored. This verifies both changed identity consumers.

Proposed test additions
+        rel_calls = [
+            c
+            for c in mock_ingestor.ensure_relationship_batch.call_args_list
+            if c[0][1] == "CONTAINS_FILE"
+        ]
+        assert len(rel_calls) == 1
+        assert rel_calls[0][0][2] == ("File", "absolute_path", stored)
+
         link.unlink()
-        assert link.resolve().as_posix() == stored
+        assert cached_file_identity_posix(link) == stored
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@codebase_rag/tests/test_structure_processor.py` around lines 413 - 418, Add
assertions in the test around the symlink target to verify the CONTAINS_FILE
target uses stored, then after link.unlink() call
cached_file_identity_posix(link) and assert it equals stored. Preserve the
existing resolve-based assertions while covering both identity consumers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@codebase_rag/tests/test_structure_processor.py`:
- Around line 413-418: Add assertions in the test around the symlink target to
verify the CONTAINS_FILE target uses stored, then after link.unlink() call
cached_file_identity_posix(link) and assert it equals stored. Preserve the
existing resolve-based assertions while covering both identity consumers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f7f02ee-8c14-436d-96e5-69ae2f942ce8

📥 Commits

Reviewing files that changed from the base of the PR and between 234816e and 86240df.

⛔ Files ignored due to path filters (1)
  • assets/Screenshot 2026-08-09 at 21.01.57.png is excluded by !**/*.png
📒 Files selected for processing (4)
  • codebase_rag/parsers/contract_linking.py
  • codebase_rag/parsers/structure_processor.py
  • codebase_rag/tests/test_structure_processor.py
  • codebase_rag/utils/path_utils.py

@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

File records now retain the in-repository symlink path as their stable identity, allowing incremental updates to match and remove records after a leaf symlink is deleted. Contract discovery and linking use the same identity, keeping contract relationships anchored to the corresponding File node.

Confidence Score: 5/5

No blocking failure remains; the change is safe to merge.

A real in-repository symlink was processed through the structure and contract-linking paths, deleted, and then checked again. The File node, containment relationship, contract lookup, contract exposure relationship, and post-deletion identity all used the preserved link-path key.

T-Rex T-Rex Logs

What T-Rex did

  • Ran a focused probe that created an in-repository leaf symlink and processed it through StructureProcessor.process_generic_file, _indexed_only, and _emit_contract, then deleted the link and recalculated its identity.
  • The probe exited successfully and confirmed the former target-path mismatch, and that the File key, CONTAINS_FILE relationship, contract-file lookup, EXPOSES relationship, and post-deletion lookup all use the preserved link path.
  • Uploaded two proof files and noted that no repository source files were modified.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (2): Last reviewed commit: "fix: key File nodes on the link path so ..." | Re-trigger Greptile

@sonarqubecloud

Copy link
Copy Markdown

@vitali87

Copy link
Copy Markdown
Owner Author

@greptileai Thanks — the mechanism is real, but I think "not merge-safe" over-scopes it. Breaking it down:

Full rebuild (--clean, the standard upgrade path): clean_database() wipes the graph before re-ingest (cli.py:300), so no legacy record can survive. Fully safe.

Incremental (--update-graph) over a legacy graph:

  • In-root symlinks self-heal: the real file MERGEs its own key, the link gets its new /repo/link key, both paths exist on disk so prune keeps both. No stale/duplicate node.
  • The genuine residual is an external symlink recorded in a pre-GHSA-85gg graph — its stored absolute_path is outside the repo, so the prune containment gate skips it. But post-0.0.589 those are skipped at ingest and never created, and the old code could neither delete nor prune them either.

So this change is strictly better than the status quo, not a regression — it fixes the common case and makes new symlink nodes prunable. The legacy-migration cleanup for that narrow incremental-over-old-graph case is a separate concern, tracked in #1156 with a concrete approach.

Please re-review with the --clean vs --update-graph distinction in mind; I don't think a strictly-improving correctness fix should be gated on retrofitting a migration for pre-existing legacy data.

@vitali87
vitali87 merged commit e8fc1c4 into main Aug 10, 2026
26 checks passed
@vitali87
vitali87 deleted the fix/1154-symlink-deletion-identity branch August 10, 2026 09:27
vitali87 added a commit that referenced this pull request Aug 10, 2026
chore: remove screenshot accidentally committed via #1155
pull Bot pushed a commit to codingwatching/code-graph-rag that referenced this pull request Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Realtime & batch File deletion lose symlink identity (resolve() can't follow a deleted link)

1 participant