feat(vault): opt-in empty-folder pruning for delete and move#163
Conversation
Add a `prune_empty_folders` flag (default false) to `vault_delete_note` and `vault_move_note`. When set, removes the note's now-empty parent folders, walking up to but never including the vault root. Default-off matches Obsidian, which leaves empty folders in place when the last note is moved or deleted — so the existing behavior is unchanged unless a caller opts in. A shared `pruneEmptyParents` helper in vault-filesystem.ts does the walk. It only removes a directory with zero entries (a stray .DS_Store or any file blocks removal — it never deletes a file) and is best-effort: a failed readdir/rmdir is logged at warn naming the folder and ends the walk, so pruning never fails the delete/move that already succeeded. The count of pruned folders rides the completion log and the tool response. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvNLNUJBq1LdWGbEbvXVY1
The prune-off tests asserted only that the folder was retained, so they would pass even if the delete/move silently no-op'd. Add assertions that the note was actually deleted/moved (and, for the vault-root case, that the root note was removed) so each test fails when its behavior breaks — per the AGENTS.md test convention. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvNLNUJBq1LdWGbEbvXVY1
A traversal path like "decoy/../About Me/Principles.md" did not literally start with a protected prefix, so the guard passed, yet resolveSafePath collapsed the ".." and the note under the protected folder was deleted — defeating the protected-path guarantee. (Pre-existing; surfaced while adding empty-folder pruning to deleteNote.) deleteNote now canonicalizes the path with toVaultRelativePath before the protected check, resolveSafePath, log, and prune call — mirroring how moveNote already normalizes. The helper is relocated from note-mover.ts to vault-filesystem.ts (beside resolveSafePath) so both modules share it without a circular import; note-mover and tool-definitions import it from the new home. Adds a regression test (mutation-verified: it fails when the check uses the raw, non-normalized path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvNLNUJBq1LdWGbEbvXVY1
Two retention tests asserted the folder survived and pruned==0 but never confirmed the triggering delete/move actually happened, so they would have passed for an unintended reason (a silent no-op). Add assertions that the note was deleted (hidden-file case) and that the note actually moved (another-note-remains case), so each test passes only because the intended behavior occurred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvNLNUJBq1LdWGbEbvXVY1
The existing convention covered only that a test must fail when the behavior breaks. Add the complementary bar: a test must pass *because* the intended behavior occurred, not via a silent no-op, a wrong-error rejection, or an early-return path that coincidentally leaves the asserted state. Includes the three concrete traps and the mutate-to- confirm check, drawn from the prune/protected-path test review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvNLNUJBq1LdWGbEbvXVY1
|
✅ Action performedReview finished.
|
📝 WalkthroughWalkthroughAdds an optional ChangesPrune Empty Folders Feature
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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.
Inline comments:
In `@src/vault-mcp/vault-operations/__tests__/note-mover.test.ts`:
- Around line 552-605: The prune-path test cases are missing explicit assertions
that verify the source notes were actually removed during the move operation. In
the test "walks up removing nested empty source parents", add a noteExists check
to verify that "A/B/note.md" no longer exists after the move. Similarly, in the
tests "does not prune on an in-place rename within the same folder" and "does
not prune when moving into a subfolder of the source", add noteExists assertions
for the old note paths ("Notes/old.md" and "Parent/note.md" respectively) to
ensure the move actually occurred. This strengthens the tests so they fail if
the move operation doesn't execute but the pruning logic still produces the
expected folder state, following the guideline that tests must assert side
effects unique to the intended code path.
In `@src/vault-mcp/vault-operations/vault-filesystem.ts`:
- Around line 24-25: The toVaultRelativePath function on line 24-25 uses
posix.normalize which does not convert Windows backslash separators to forward
slashes, allowing paths like About Me\Principles.md to bypass the protected-path
checks in deleteNote and moveNote by failing the startsWith comparison. Modify
the toVaultRelativePath function to replace all backslashes with forward slashes
in the input string before applying posix.normalize to ensure consistent path
normalization across all platforms and prevent protected-path bypass
vulnerabilities.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 425cf318-20df-4090-b727-a88d3c616b6f
📒 Files selected for processing (6)
AGENTS.mdsrc/vault-mcp/tool-definitions.tssrc/vault-mcp/vault-operations/__tests__/note-mover.test.tssrc/vault-mcp/vault-operations/__tests__/vault-filesystem.test.tssrc/vault-mcp/vault-operations/note-mover.tssrc/vault-mcp/vault-operations/vault-filesystem.ts
Two review items on deleteNote's path handling and return shape:
- Separator canonicalization: toVaultRelativePath now converts "\\" to
"/" before normalizing, so a Windows-style separator can't slip a path
past the protected-path prefix check (the check compares against "/"
forms). Squarely within the helper's stated purpose. Regression test
added (mutation-verified: fails with ENOENT on the raw helper).
- deleteNote now returns a named `DeleteResult` ({ prunedEmptyFolders })
instead of a bare number, so the value is self-documenting at the call
site rather than an unlabeled count from a function called deleteNote.
Handler and tests updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvNLNUJBq1LdWGbEbvXVY1
Three move prune tests asserted folder state and pruned count but did not confirm the source note actually moved, so a move regression that left folder state coincidentally correct could pass. Add oldPath-absence (and destination-presence where missing) assertions so a failure localizes to move-vs-prune. Same convention as the earlier two. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvNLNUJBq1LdWGbEbvXVY1
A more specific name states what was deleted, so the type docstring is no longer needed and is removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvNLNUJBq1LdWGbEbvXVY1
Summary
Adds an opt-in
prune_empty_foldersflag (defaultfalse) tovault_delete_noteandvault_move_note. When set, removes the note's now-empty parent folders, walking up to but never including the vault root.Why default-off
The original task framed this as "match Obsidian," but an empirical check shows Obsidian leaves empty folders when the last note is moved/deleted — reliable auto-removal is exactly what the community "Remove Empty Folders" plugins exist to add. So default-off is Obsidian parity; pruning is an opt-in cleanup, and existing callers see no behavior change.
What's inside
pruneEmptyParentshelper invault-filesystem.ts— walks child→parent, stops at (never removes) the vault root..DS_Storeor any file blocks removal; it never deletes a file.readdir/rmdiris logged atwarnnaming the folder and ends the walk, so pruning never fails the delete/move that already succeeded.MoveResult.pruned_empty_folders; delete confirmation notes it when > 0).vault_delete_noteholds 5.0,vault_move_noteholds ~4.8 — no regression).Test plan
npm test— 824 pass (13 new prune cases: default-off parity, multi-level walk, non-empty stop, vault-root guard, hidden-file, failure-isolation)🤖 Generated with Claude Code
Generated by Claude Code
Summary by CodeRabbit
New Features
Documentation