fix(sync): fall back to adapter read for symlinked files

Reading a symlinked file via Obsidian's cached vault.read(TFile) can fail
(notably on mobile), which broke both refresh and push: the status check
swallowed the error and mislabeled the file as 'unsynced', so the user
saw a Push button that then failed re-reading the symlink.

Fall back to vault.adapter.read/readBinary(path) when vault.read throws,
in both the status view and the sync manager. Also stop silently
swallowing the status-check error so the real cause is logged.

Add a regression test covering the adapter fallback on push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe
This commit is contained in:
Claude 2026-06-26 06:28:25 +00:00
parent d8d6f9dcb1
commit 76405cf2f7
No known key found for this signature in database
3 changed files with 47 additions and 8 deletions

View file

@ -352,13 +352,22 @@ export class SyncManager {
const binary = this.isBinary(path);
if (typeof fileOrPath === 'string') {
return binary
return binary
? await this.app.vault.adapter.readBinary(fileOrPath)
: await this.app.vault.adapter.read(fileOrPath);
}
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
try {
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
} catch (e) {
// Obsidian's cached vault.read can fail for symlinked files (notably
// on mobile); fall back to reading the path directly via the adapter.
logger.warn(`vault.read failed for ${path}; falling back to adapter`, e);
return binary
? await this.app.vault.adapter.readBinary(path)
: await this.app.vault.adapter.read(path);
}
}
private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise<boolean> {

View file

@ -449,8 +449,9 @@ export class SyncStatusView extends ItemView {
const { status, diff } = this.determineFileStatus(binary, localContent, remote);
this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha, diff });
} catch {
} catch (e) {
const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path;
logger.warn(`Failed to determine sync status for ${path}`, e);
this.fileStatuses.set(path, {
file: typeof fileOrPath === 'string' ? undefined : fileOrPath,
path,
@ -466,9 +467,18 @@ export class SyncStatusView extends ItemView {
: await this.app.vault.adapter.read(fileOrPath as string);
}
if (fileOrPath instanceof TFile) {
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
try {
return binary
? await this.app.vault.readBinary(fileOrPath)
: await this.app.vault.read(fileOrPath);
} catch (e) {
// Obsidian's cached vault.read can fail for symlinked files
// (notably on mobile); fall back to reading the path directly.
logger.warn(`vault.read failed for ${fileOrPath.path}; falling back to adapter`, e);
return binary
? await this.app.vault.adapter.readBinary(fileOrPath.path)
: await this.app.vault.adapter.read(fileOrPath.path);
}
}
// This should not happen if isStr is false and fileOrPath is TFile
throw new Error('Expected TFile when isStr is false');

View file

@ -88,6 +88,26 @@ describe('SyncManager', () => {
);
});
it('falls back to the adapter when vault.read fails (e.g. symlinked file)', async () => {
const mockFile = Object.assign(new TFile(), { path: 'link.md', name: 'link.md' });
const readSpy = vi.spyOn(mockApp.vault, 'read').mockRejectedValue(new Error('EINVAL: symlink'));
const adapterReadSpy = vi.spyOn(mockApp.vault.adapter, 'read').mockResolvedValue('linked content');
vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'different content', sha: 'old-sha' });
const pushSpy = vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'link.md', sha: 'new-sha' });
await manager.pushFile(mockFile);
expect(readSpy).toHaveBeenCalledWith(mockFile);
expect(adapterReadSpy).toHaveBeenCalledWith('link.md');
expect(pushSpy).toHaveBeenCalledWith(
'link.md',
'linked content',
'main',
'Update link.md from Obsidian',
'old-sha'
);
});
it('should detect conflict when remote SHA differs from last synced SHA', async () => {
const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' });