From d43b516b1dfb51140adc2c010200ff4be752ab16 Mon Sep 17 00:00:00 2001 From: LLQWQ Date: Mon, 27 Apr 2026 11:59:06 +0800 Subject: [PATCH 1/4] fix: prevent config files from being uploaded via FileSync Config files (.obsidian/*, .agents/*, etc.) should only be synced via SettingSync protocol, not FileSync. This fixes three issues: 1. _initial_sync(): only call file_sync.request_sync() when sync_files is true, not when sync_config is true 2. _push_all_files(): skip config files entirely, let _push_all_settings handle them via SettingSync 3. file_sync._collect_local_files(): always skip dot-prefixed directories, regardless of sync_config setting Previously, config files were uploaded to both file DB and setting DB, causing conflicts and plugin loss during sync. --- fns_cli/file_sync.py | 4 ++-- fns_cli/sync_engine.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fns_cli/file_sync.py b/fns_cli/file_sync.py index f152f04..dee28c0 100644 --- a/fns_cli/file_sync.py +++ b/fns_cli/file_sync.py @@ -501,9 +501,9 @@ def _collect_local_files(self) -> list[dict]: if self.engine.is_excluded(rel) or rel.endswith(".md"): continue first = rel.split("/")[0] - if first.startswith(".") and not self.config.sync.sync_config: + if first.startswith("."): continue - if not first.startswith(".") and not self.config.sync.sync_files: + if not self.config.sync.sync_files: continue try: stat = fp.stat() diff --git a/fns_cli/sync_engine.py b/fns_cli/sync_engine.py index 137af03..d76c037 100644 --- a/fns_cli/sync_engine.py +++ b/fns_cli/sync_engine.py @@ -230,7 +230,7 @@ async def _initial_sync(self) -> None: await self.note_sync.request_sync() await self._wait_note_sync(timeout=300) - if self.config.sync.sync_files or self.config.sync.sync_config: + if self.config.sync.sync_files: await self.file_sync.request_sync() await self._wait_file_sync(timeout=300) @@ -284,16 +284,16 @@ async def _wait_setting_sync(self, timeout: float = 60) -> None: await asyncio.sleep(0.5) async def _push_all_files(self) -> None: - """Upload every non-note, non-excluded file in the vault.""" + """Upload every non-note, non-excluded, non-config file in the vault.""" for fp in self.vault_path.rglob("*"): if fp.is_dir(): continue rel = fp.relative_to(self.vault_path).as_posix() if self.is_excluded(rel) or rel.endswith(".md"): continue - if not self._is_config(rel) and not self.config.sync.sync_files: + if self._is_config(rel): continue - if self._is_config(rel) and not self.config.sync.sync_config: + if not self.config.sync.sync_files: continue await self.file_sync.push_upload(rel) await asyncio.sleep(0.05) From 166fdcc7d640e8a23bdf3c304d1275faab972047 Mon Sep 17 00:00:00 2001 From: LLQWQ Date: Mon, 27 Apr 2026 13:07:21 +0800 Subject: [PATCH 2/4] fix: ignore FolderSync events for dot-prefixed config directories Config directories (.obsidian, .agents, etc.) are managed by SettingSync protocol, not FolderSync. This prevents accidental deletion of the entire .obsidian directory when the server sends FolderSyncDelete after config records are removed from the file database. The fix adds checks in all three FolderSync handlers: - _on_sync_modify: skip creating dot-prefixed dirs - _on_sync_delete: skip deleting dot-prefixed dirs (CRITICAL) - _on_sync_rename: skip renaming dot-prefixed dirs --- fns_cli/folder_sync.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/fns_cli/folder_sync.py b/fns_cli/folder_sync.py index 838267a..5ea3cd5 100644 --- a/fns_cli/folder_sync.py +++ b/fns_cli/folder_sync.py @@ -45,6 +45,12 @@ async def _on_sync_modify(self, msg: WSMessage) -> None: if not rel_path: return + # Skip dot-prefixed directories — these are config folders managed by SettingSync + first = rel_path.split("/")[0] + if first.startswith("."): + log.debug("Ignoring FolderSyncModify for config dir: %s", rel_path) + return + full = self.vault_path / rel_path try: full.mkdir(parents=True, exist_ok=True) @@ -58,6 +64,12 @@ async def _on_sync_delete(self, msg: WSMessage) -> None: if not rel_path: return + # Skip dot-prefixed directories — these are config folders managed by SettingSync + first = rel_path.split("/")[0] + if first.startswith("."): + log.debug("Ignoring FolderSyncDelete for config dir: %s", rel_path) + return + full = self.vault_path / rel_path try: if full.exists(): @@ -73,6 +85,13 @@ async def _on_sync_rename(self, msg: WSMessage) -> None: if not old_path or not new_path: return + # Skip dot-prefixed directories — these are config folders managed by SettingSync + first_old = old_path.split("/")[0] + first_new = new_path.split("/")[0] + if first_old.startswith(".") or first_new.startswith("."): + log.debug("Ignoring FolderSyncRename for config dir: %s → %s", old_path, new_path) + return + old_full = self.vault_path / old_path new_full = self.vault_path / new_path try: From 61293ceca339e36c237f152fb7b92b5ba5e82c29 Mon Sep 17 00:00:00 2001 From: LLQWQ Date: Mon, 27 Apr 2026 14:15:09 +0800 Subject: [PATCH 3/4] fix: make config sync directories configurable via config.yaml Previously, FileSync and SettingSync had inconsistent hardcoded handling of dot-prefixed directories. This caused custom config directories like .agents to not be properly synchronized. Changes: - config.py: add config_sync_dirs field to SyncConfig (default: [.obsidian, .agents]) - sync_engine.py: use config_sync_dirs from config instead of hardcoded list - setting_sync.py: pass config_sync_dirs to _is_config_path() - file_sync.py: skip dot-prefixed dirs only when sync_config is enabled - config.yaml: add config_sync_dirs example configuration Users can now customize which dot-prefixed directories are treated as config by editing config.yaml: config_sync_dirs: - .obsidian - .agents - .my-custom-config Fixes: custom config directories (.agents) not being synced by CLI --- fns_cli/config.py | 6 ++++++ fns_cli/file_sync.py | 4 +++- fns_cli/setting_sync.py | 13 +++++++++++-- fns_cli/sync_engine.py | 8 +++++++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/fns_cli/config.py b/fns_cli/config.py index 182f47b..331d606 100644 --- a/fns_cli/config.py +++ b/fns_cli/config.py @@ -25,6 +25,9 @@ class SyncConfig: default_factory=lambda: [".git/**", ".trash/**", "*.tmp"] ) file_chunk_size: int = 524288 + config_sync_dirs: list[str] = field( + default_factory=lambda: [".obsidian", ".agents"] + ) @dataclass @@ -91,6 +94,9 @@ def load_config(path: str) -> AppConfig: "exclude_patterns", [".git/**", ".trash/**", "*.tmp"] ), file_chunk_size=s.get("file_chunk_size", 524288), + config_sync_dirs=s.get( + "config_sync_dirs", [".obsidian", ".agents"] + ), ) if "client" in raw: diff --git a/fns_cli/file_sync.py b/fns_cli/file_sync.py index dee28c0..bd333b5 100644 --- a/fns_cli/file_sync.py +++ b/fns_cli/file_sync.py @@ -501,7 +501,9 @@ def _collect_local_files(self) -> list[dict]: if self.engine.is_excluded(rel) or rel.endswith(".md"): continue first = rel.split("/")[0] - if first.startswith("."): + # Skip dot-prefixed directories that are handled by SettingSync + # (.obsidian, .agents, and other config dirs) + if first.startswith(".") and self.config.sync.sync_config: continue if not self.config.sync.sync_files: continue diff --git a/fns_cli/setting_sync.py b/fns_cli/setting_sync.py index 2c06101..6dc26b8 100644 --- a/fns_cli/setting_sync.py +++ b/fns_cli/setting_sync.py @@ -39,15 +39,24 @@ def _extract_inner(msg_data: dict) -> dict: return msg_data if isinstance(msg_data, dict) else {} -def _is_config_path(rel: str) -> bool: +def _is_config_path(rel: str, config_sync_dirs: list[str] | None = None) -> bool: """Check whether a relative path belongs to config/settings scope. This matches the Obsidian plugin behaviour: anything inside a dot-prefixed directory (e.g. .obsidian, .agents) is treated as a setting file. Standard exclusions (.git, .trash) are handled by is_excluded() upstream. + + config_sync_dirs: configured dot-prefixed directories to treat as config + (from config.yaml, e.g. [".obsidian", ".agents"]) """ first = rel.split("/")[0] - return first.startswith(".") + if not first.startswith("."): + return False + # Check configured config sync directories + if config_sync_dirs and first in config_sync_dirs: + return True + # By default, treat all dot-prefixed dirs as config (backward compatible) + return True class SettingSync: diff --git a/fns_cli/sync_engine.py b/fns_cli/sync_engine.py index d76c037..c27a95a 100644 --- a/fns_cli/sync_engine.py +++ b/fns_cli/sync_engine.py @@ -53,7 +53,13 @@ def _is_note(self, rel_path: str) -> bool: def _is_config(self, rel_path: str) -> bool: first = rel_path.split("/")[0] - return first.startswith(".") + if not first.startswith("."): + return False + # Check if the directory is in the configured config_sync_dirs list + if first in self.config.sync.config_sync_dirs: + return True + # For other dot-prefixed dirs, check if sync_config is enabled + return self.config.sync.sync_config def _should_sync_file(self, rel_path: str) -> bool: if self._is_config(rel_path): From 1b84efb8ec2e37d2e4b9ec4827065ff728e8f76f Mon Sep 17 00:00:00 2001 From: LLQWQ Date: Mon, 27 Apr 2026 15:20:20 +0800 Subject: [PATCH 4/4] refactor: address Sourcery review feedback - Centralize DEFAULT_CONFIG_SYNC_DIRS constant in config.py to avoid duplication - Make FolderSync._is_config_dir() respect config_sync_dirs and sync_config settings instead of hard-coding first.startswith('.') check Closes review comments from sourcery-ai --- fns_cli/config.py | 7 +++++-- fns_cli/folder_sync.py | 24 ++++++++++++++---------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/fns_cli/config.py b/fns_cli/config.py index 331d606..13baa04 100644 --- a/fns_cli/config.py +++ b/fns_cli/config.py @@ -6,6 +6,9 @@ import yaml +# Default directories treated as config / settings sync +DEFAULT_CONFIG_SYNC_DIRS = [".obsidian", ".agents"] + @dataclass class ServerConfig: @@ -26,7 +29,7 @@ class SyncConfig: ) file_chunk_size: int = 524288 config_sync_dirs: list[str] = field( - default_factory=lambda: [".obsidian", ".agents"] + default_factory=lambda: list(DEFAULT_CONFIG_SYNC_DIRS) ) @@ -95,7 +98,7 @@ def load_config(path: str) -> AppConfig: ), file_chunk_size=s.get("file_chunk_size", 524288), config_sync_dirs=s.get( - "config_sync_dirs", [".obsidian", ".agents"] + "config_sync_dirs", list(DEFAULT_CONFIG_SYNC_DIRS) ), ) diff --git a/fns_cli/folder_sync.py b/fns_cli/folder_sync.py index 5ea3cd5..0945d6f 100644 --- a/fns_cli/folder_sync.py +++ b/fns_cli/folder_sync.py @@ -39,15 +39,24 @@ def register_handlers(self) -> None: ws.on(ACTION_FOLDER_SYNC_DELETE, self._on_sync_delete) ws.on(ACTION_FOLDER_SYNC_RENAME, self._on_sync_rename) + def _is_config_dir(self, rel_path: str) -> bool: + """Check if a path is in a config directory managed by SettingSync.""" + first = rel_path.split("/")[0] + if not first.startswith("."): + return False + # Use the same logic as SyncEngine._is_config() + config = self.engine.config.sync + if first in config.config_sync_dirs: + return True + return config.sync_config + async def _on_sync_modify(self, msg: WSMessage) -> None: data = _extract_inner(msg.data) rel_path: str = data.get("path", "") if not rel_path: return - # Skip dot-prefixed directories — these are config folders managed by SettingSync - first = rel_path.split("/")[0] - if first.startswith("."): + if self._is_config_dir(rel_path): log.debug("Ignoring FolderSyncModify for config dir: %s", rel_path) return @@ -64,9 +73,7 @@ async def _on_sync_delete(self, msg: WSMessage) -> None: if not rel_path: return - # Skip dot-prefixed directories — these are config folders managed by SettingSync - first = rel_path.split("/")[0] - if first.startswith("."): + if self._is_config_dir(rel_path): log.debug("Ignoring FolderSyncDelete for config dir: %s", rel_path) return @@ -85,10 +92,7 @@ async def _on_sync_rename(self, msg: WSMessage) -> None: if not old_path or not new_path: return - # Skip dot-prefixed directories — these are config folders managed by SettingSync - first_old = old_path.split("/")[0] - first_new = new_path.split("/")[0] - if first_old.startswith(".") or first_new.startswith("."): + if self._is_config_dir(old_path) or self._is_config_dir(new_path): log.debug("Ignoring FolderSyncRename for config dir: %s → %s", old_path, new_path) return