Skip to content

feat(vault): opt-in empty-folder pruning for delete and move#163

Merged
aliasunder merged 8 commits into
mainfrom
claude/vault-bootstrap-setup-2qf15r
Jun 21, 2026
Merged

feat(vault): opt-in empty-folder pruning for delete and move#163
aliasunder merged 8 commits into
mainfrom
claude/vault-bootstrap-setup-2qf15r

Conversation

@aliasunder

@aliasunder aliasunder commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Summary

Adds an opt-in 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.

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

  • Shared pruneEmptyParents helper in vault-filesystem.ts — walks child→parent, stops at (never removes) the vault root.
  • Only removes a zero-entry directory — a stray .DS_Store or any file blocks removal; it never deletes a file.
  • 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.
  • Pruned-folder count rides the completion log and the tool response (MoveResult.pruned_empty_folders; delete confirmation notes it when > 0).
  • Tool descriptions document the flag (TDQS self-score: vault_delete_note holds 5.0, vault_move_note holds ~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)
  • Build (server + CLI), ESLint, Prettier clean
  • Live e2e + Glama TDQS — require a deploy (default-off matches the currently deployed behavior)

🤖 Generated with Claude Code


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added optional "prune empty folders" parameter to note deletion and move operations for automatic cleanup of empty parent directories.
    • Move operation now returns the count of pruned empty folders.
  • Documentation

    • Updated test writing conventions to prevent common testing pitfalls including silent no-ops, incorrect error matching, and unintended early returns.

claude added 2 commits June 20, 2026 16:01
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
Comment thread src/vault-mcp/tool-definitions.ts
Comment thread src/vault-mcp/vault-operations/vault-filesystem.ts
claude added 3 commits June 20, 2026 23:47
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
@aliasunder

Copy link
Copy Markdown
Owner Author

/@CodeRabbit

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

@aliasunder Sure! Let me kick off a review of the PR changes.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an optional prune_empty_folders parameter to vault_delete_note and vault_move_note MCP tools. The core implementation introduces toVaultRelativePath and pruneEmptyParents in vault-filesystem.ts, updates deleteNote to return a pruned-folder count, and extends moveNote and MoveResult accordingly. New and updated tests cover all pruning scenarios. AGENTS.md gains test-quality guidance.

Changes

Prune Empty Folders Feature

Layer / File(s) Summary
vault-filesystem: toVaultRelativePath, pruneEmptyParents, deleteNote update
src/vault-mcp/vault-operations/vault-filesystem.ts
Exports toVaultRelativePath (POSIX path normalization) and pruneEmptyParents (recursive empty-dir walker returning a count). deleteNote now normalizes the path before protected-path checks, optionally calls pruneEmptyParents, and returns Promise<number> instead of Promise<void>.
vault-filesystem tests: updated call sites and new prune suite
src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts
All existing deleteNote calls gain pruneEmptyFolders: false. Adds a traversal-into-protected-folder test and a full empty-folder prune suite covering parent removal, vault-root guard, hidden-file preservation, and rmdir failure handling.
note-mover: MoveResult, import consolidation, pruning wiring
src/vault-mcp/vault-operations/note-mover.ts
MoveResult gains pruned_empty_folders: number. toVaultRelativePath is removed as a local export and imported from vault-filesystem. moveNote accepts pruneEmptyFolders and calls pruneEmptyParents on the old path after deletion.
note-mover tests: updated assertions and prune suite
src/vault-mcp/vault-operations/__tests__/note-mover.test.ts
Test harness forwards pruneEmptyFolders and exposes folderExists. All existing MoveResult equality assertions gain pruned_empty_folders: 0. New describe block tests pruning on/off, nested parent removal, note-remaining preservation, same-folder rename, and subfolder-move edge cases.
MCP tool definitions: prune_empty_folders for delete and move
src/vault-mcp/tool-definitions.ts
Adds prune_empty_folders boolean to Zod schemas, handler destructuring, request logging, and downstream calls for both vault_delete_note (pluralized confirmation message) and vault_move_note (return docs updated with pruned_empty_folders). Reorganizes imports.
AGENTS.md: test-quality conventions
AGENTS.md
Adds test-writing rules against silent no-ops, overly-broad toThrow() matches, and early-return false positives; instructs asserting specific side effects and error messages.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • aliasunder/vault-cortex#160: Introduced vault_move_note and the noteMover.moveNote implementation whose signature, MoveResult type, and call site in tool-definitions.ts are directly extended in this PR.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main feature: adding opt-in empty-folder pruning to delete and move operations. It is concise, actionable, and accurately summarizes the primary change across the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 claude/vault-bootstrap-setup-2qf15r

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 and usage tips.

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 0828891 and 229cc06.

📒 Files selected for processing (6)
  • AGENTS.md
  • src/vault-mcp/tool-definitions.ts
  • src/vault-mcp/vault-operations/__tests__/note-mover.test.ts
  • src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts
  • src/vault-mcp/vault-operations/note-mover.ts
  • src/vault-mcp/vault-operations/vault-filesystem.ts

Comment thread src/vault-mcp/vault-operations/__tests__/note-mover.test.ts
Comment thread src/vault-mcp/vault-operations/vault-filesystem.ts Outdated
Comment thread src/vault-mcp/vault-operations/vault-filesystem.ts Outdated
claude added 2 commits June 21, 2026 00:05
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
Comment thread src/vault-mcp/vault-operations/vault-filesystem.ts Outdated
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
@aliasunder
aliasunder merged commit 6d26362 into main Jun 21, 2026
15 checks passed
@aliasunder
aliasunder deleted the claude/vault-bootstrap-setup-2qf15r branch June 21, 2026 00:17
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.

2 participants