Skip to content

fix(vault-fs): list hidden entries during local traversal - #223

Merged
hesprs merged 4 commits into
hesprs:mainfrom
pedrovillalobos:fix/hidden-entries-local-traversal
Aug 11, 2026
Merged

fix(vault-fs): list hidden entries during local traversal#223
hesprs merged 4 commits into
hesprs:mainfrom
pedrovillalobos:fix/hidden-entries-local-traversal

Conversation

@pedrovillalobos

Copy link
Copy Markdown
Contributor

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 removeRemote for each one while the local copies stay untouched. Previously uploaded files are silently removed from the remote.

INFO - Decider: remove remote Projects/.git/objects/1a/6d8fbc98050342ae0e1304f518ae486306326b, reason: remote exists, local deleted.

Root cause

packages/plugin/src/fs/vault/request.ts, LIST branch:

if (canUseCache() && (params.headers?.cached ?? true) && key !== '/') {
    const folder = vault.getAbstractFileByPath(path);
    if (folder instanceof TFolder) {
        folder.children.forEach(...);
        return children as never;
    }
}
const { files, folders } = await adapter.list(path);

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:

  • Vault root: key !== '/' fails, so the adapter runs and root-level hidden entries are seen.
  • Any subfolder after layoutReady: the cached path runs and hidden entries are dropped.
  • Any folder before layoutReady (startup sync): the adapter runs and hidden entries are seen.

VaultFs.list() builds local stats from that result, so the dropped entries never enter localStats. 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 in RECORD_REMOTE_NOLOCAL_REMOVEremoveRemote. 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:

const cached = app.vault.getAbstractFileByPath("Projects")?.children.map(c => c.name) ?? [];
const raw = await app.vault.adapter.list("Projects");
const rawAll = [...raw.folders, ...raw.files].map(p => p.split("/").pop());
console.log("missing from cache:", rawAll.filter(n => !cached.includes(n)));

Every name in the third list is planned for removeRemote on the next sync; non-hidden siblings are unaffected.

Fix

VaultFs.list() now sends cached: false with its LIST request, 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 every LIST caller 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 issue LIST through the local request middleware registry.

canUseCache() is untouched.

Cost

VaultFs.list('/') runs once per sync and previously issued exactly one adapter.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 concurrent readdir calls. Per-file stat still 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.

STAT

Checked, no change needed. STAT has the same canUseCache() guard, but for a hidden path vault.getAbstractFileByPath() returns nothing for the same reason TFolder.children omits it, so the cached branch cannot match and it falls through to adapter.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 reach STAT at 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), and layoutReady is configurable:

  • hidden entries that the file tree omits are reported by traversal, and the adapter is consulted for every folder;
  • the same vault listed with layoutReady true and false yields identical results;
  • root-level hidden entries are still reported (covered by both cases above);
  • a folder absent from the file tree still falls through to the adapter;
  • a direct LIST request without cached: false still uses the file tree, so other callers are unchanged.

The first two fail on main and pass with this change.

packages/plugin/test/bidirectional.test.ts — a hidden file present locally with a record and a remote copy produces no removeRemote.

bun check, bun fix and bun tests are clean.

pedrovillalobos and others added 4 commits August 10, 2026 19:54
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.
@hesprs
hesprs merged commit e19f0d2 into hesprs:main Aug 11, 2026
1 check passed
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.

[Bug]: Hidden files/folders inside non-root folders are invisible to local traversal, so they get deleted from remote

2 participants