From b70cba82fff8be3d27cc0a54e8560877ab3af98a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 00:05:59 +0000 Subject: [PATCH] fix(cloud): emit both `- pattern` and `- pattern/**` in rclone filter conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `convert_bmignore_to_rclone_filters()` had two bugs: 1. Patterns without wildcards (e.g. `config.json`) were emitted as `- config.json/**`, which only excludes contents of a *directory* named config.json — the actual file passed through unfiltered. 2. Wildcard-only patterns (e.g. `.*`) were emitted as `- .*` alone. rclone globs treat `*` as not crossing `/`, so hidden directory contents (e.g. `.obsidian/workspace.json`) were not excluded. Fix: always emit both `- pattern` (match the item itself) and `- pattern/**` (match directory contents recursively) for every gitignore line, unless it already ends with `/**`. Closes #819 Co-authored-by: Paul Hernandez Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- .../cli/commands/cloud/bisync_commands.py | 23 +++++++--- ...test_rclone_config_and_bmignore_filters.py | 43 ++++++++++++++++++- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/basic_memory/cli/commands/cloud/bisync_commands.py b/src/basic_memory/cli/commands/cloud/bisync_commands.py index c7ad9139..e904bb9d 100644 --- a/src/basic_memory/cli/commands/cloud/bisync_commands.py +++ b/src/basic_memory/cli/commands/cloud/bisync_commands.py @@ -78,14 +78,25 @@ def convert_bmignore_to_rclone_filters() -> Path: patterns.append(line) continue - # Convert gitignore pattern to rclone filter syntax - # gitignore: node_modules → rclone: - node_modules/** - # gitignore: *.pyc → rclone: - *.pyc - if "*" in line: - # Pattern already has wildcard, just add exclude prefix + # Convert gitignore pattern to rclone filter syntax. + # + # Two rules are always emitted per pattern: + # 1. `- pattern` — excludes the item itself (file or dir entry) + # 2. `- pattern/**` — excludes all contents when the item is a dir + # + # Why both? rclone globs treat `*` as not crossing `/`, so: + # - `- .*` matches hidden *files* but NOT hidden dir contents + # (e.g. `.obsidian/workspace.json` would pass through) + # - `- config.json/**` excludes contents of a *dir* named + # config.json, but NOT the file config.json itself + # Emitting both forms covers files, directories, and directory contents. + if line.endswith("/**"): + # Trigger: pattern already fully qualified as recursive dir exclude + # Why: avoid doubling up on /** suffix + # Outcome: written as-is patterns.append(f"- {line}") else: - # Directory pattern - add /** for recursive exclude + patterns.append(f"- {line}") patterns.append(f"- {line}/**") except Exception: diff --git a/tests/cli/cloud/test_rclone_config_and_bmignore_filters.py b/tests/cli/cloud/test_rclone_config_and_bmignore_filters.py index 064f29d4..715c2b74 100644 --- a/tests/cli/cloud/test_rclone_config_and_bmignore_filters.py +++ b/tests/cli/cloud/test_rclone_config_and_bmignore_filters.py @@ -32,13 +32,52 @@ def test_convert_bmignore_to_rclone_filters_creates_and_converts(config_home): # Comments/empties preserved assert "# comment" in content assert "" in content - # Directory pattern becomes recursive exclude + # Each pattern emits both a direct-match and a recursive-contents rule + assert "- node_modules" in content assert "- node_modules/**" in content - # Wildcard pattern becomes simple exclude assert "- *.pyc" in content + assert "- *.pyc/**" in content + assert "- .git" in content assert "- .git/**" in content +def test_convert_bmignore_file_pattern_not_mangled_as_directory(config_home): + """Regression: config.json must be excluded as a FILE, not as a dir content glob. + + Previously `config.json` (no wildcard) became `- config.json/**`, which only + excludes contents of a *directory* named config.json, leaving the actual file + unfiltered during bisync. + """ + bmignore = get_bmignore_path() + bmignore.parent.mkdir(parents=True, exist_ok=True) + bmignore.write_text("config.json\n", encoding="utf-8") + + content = convert_bmignore_to_rclone_filters().read_text(encoding="utf-8").splitlines() + + # Must match the file itself + assert "- config.json" in content + # Also emit the /** form for completeness (covers any hypothetical dir of that name) + assert "- config.json/**" in content + + +def test_convert_bmignore_hidden_glob_covers_directory_contents(config_home): + """Regression: `.*` must exclude hidden directory *contents*, not just hidden files. + + Previously `.*` became `- .*` only. rclone globs don't cross `/`, so + `.obsidian/workspace.json` would not be matched by `- .*` alone. + """ + bmignore = get_bmignore_path() + bmignore.parent.mkdir(parents=True, exist_ok=True) + bmignore.write_text(".*\n", encoding="utf-8") + + content = convert_bmignore_to_rclone_filters().read_text(encoding="utf-8").splitlines() + + # Must exclude hidden files at any level + assert "- .*" in content + # Must also exclude contents of hidden directories + assert "- .*/**" in content + + def test_convert_bmignore_to_rclone_filters_is_cached_when_up_to_date(config_home): bmignore = get_bmignore_path() bmignore.parent.mkdir(parents=True, exist_ok=True)