fix(vault-fs): list hidden entries during local traversal - #223
Merged
hesprs merged 4 commits intoAug 11, 2026
Conversation
Obsidian's in-memory file tree omits dot-prefixed entries, so local traversal missed every hidden file and folder inside non-root folders and the decider planned removeRemote for them. Opt traversal out of the cached LIST path so it always reads the adapter. Closes hesprs#222
This case is meaningless since other cases already covers this.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #222.
Problem
Hidden (dot-prefixed) entries inside non-root folders are invisible to local traversal, so the decider sees them as locally deleted and plans
removeRemotefor each one while the local copies stay untouched. Previously uploaded files are silently removed from the remote.Root cause
packages/plugin/src/fs/vault/request.ts,LISTbranch:TFolder.children(Obsidian's in-memory file tree) never contains dot-prefixed entries,adapter.list()does. The same folder therefore yields two different results depending on which path runs:key !== '/'fails, so the adapter runs and root-level hidden entries are seen.layoutReady: the cached path runs and hidden entries are dropped.layoutReady(startup sync): the adapter runs and hidden entries are seen.VaultFs.list()builds local stats from that result, so the dropped entries never enterlocalStats. The bidirectional decider unions local stats, remote stats and records; a hidden file with a record and a remote copy but no local entry lands inRECORD_REMOTE_NOLOCAL_REMOVE→removeRemote. It also oscillates: a startup sync (pre-layoutReady) uploads the entries, and a later sync deletes them again.Reproduction
In the Obsidian developer console, on any folder that contains hidden entries:
Every name in the third list is planned for
removeRemoteon the next sync; non-hidden siblings are unaffected.Fix
VaultFs.list()now sendscached: falsewith itsLISTrequest, so traversal always reads the adapter.The alternative was to keep the cached branch and merge the adapter result into it. That merge has to call
adapter.list()for every folder anyway — that call is exactly what surfaces the hidden entries — so it costs the same I/O as opting out, plus the tree walk and the merge, and it changes behaviour for everyLISTcaller instead of only traversal. Opting out at the call site is the smaller change and keeps the cached path intact for everything else, including modules that issueLISTthrough the local request middleware registry.canUseCache()is untouched.Cost
VaultFs.list('/')runs once per sync and previously issued exactly oneadapter.list()(for the root); it now issues one per folder. Measured on Linux with a warm page cache, 1051 folders / 20000 files: ~4 ms for the full set of concurrentreaddircalls. Per-filestatstill uses the cached path for non-hidden files, so the added cost is limited to directory reads, and it is negligible against the remote listing that runs concurrently with it. Mobile adapters pay bridge overhead per call, but the merge alternative pays the same calls, so it is not a reason to prefer it.Vaults gain visibility of hidden entries under subfolders, which is already the behaviour of any sync that runs before
layoutReady. They go through the normal inclusion/exclusion rules, and the defaults (.trash, the config dir,**/.git,**/.DS_Store, …) still apply.STATChecked, no change needed.
STAThas the samecanUseCache()guard, but for a hidden pathvault.getAbstractFileByPath()returns nothing for the same reasonTFolder.childrenomits it, so the cached branch cannot match and it falls through toadapter.stat(), which returns the correct stat. The cached branch cannot produce a wrong stat for a hidden path either, since it can never resolve one. The only effect of this PR is that hidden files now reachSTATat all, taking that adapter fallback — covered by the new traversal tests, whose hidden files resolve their mtime/size through it.Tests
packages/plugin/test/fs-vault.test.ts— the vault stub now models Obsidian's in-memory file tree (tree) separately from what the adapter reports (list), andlayoutReadyis configurable:layoutReadytrue and false yields identical results;LISTrequest withoutcached: falsestill uses the file tree, so other callers are unchanged.The first two fail on
mainand pass with this change.packages/plugin/test/bidirectional.test.ts— a hidden file present locally with a record and a remote copy produces noremoveRemote.bun check,bun fixandbun testsare clean.