diff --git a/src/basic_memory/cli/commands/cloud/rclone_commands.py b/src/basic_memory/cli/commands/cloud/rclone_commands.py index c1991ec5..c284446f 100644 --- a/src/basic_memory/cli/commands/cloud/rclone_commands.py +++ b/src/basic_memory/cli/commands/cloud/rclone_commands.py @@ -212,6 +212,10 @@ def project_sync( remote_path, "--filter-from", str(filter_path), + "--header-download", + "X-Tigris-Consistent: true", + "--header-upload", + "X-Tigris-Consistent: true", ] if verbose: @@ -287,6 +291,10 @@ def project_bisync( str(filter_path), "--workdir", str(state_path), + "--header-download", + "X-Tigris-Consistent: true", + "--header-upload", + "X-Tigris-Consistent: true", ] # Add --create-empty-src-dirs if rclone version supports it (v1.64+) @@ -356,6 +364,10 @@ def project_check( remote_path, "--filter-from", str(filter_path), + "--header-download", + "X-Tigris-Consistent: true", + "--header-upload", + "X-Tigris-Consistent: true", ] if one_way: @@ -393,6 +405,12 @@ def project_ls( if path: remote_path = f"{remote_path}/{path}" - cmd = ["rclone", "ls", remote_path] + cmd = [ + "rclone", + "ls", + remote_path, + "--header-download", + "X-Tigris-Consistent: true", + ] result = run(cmd, capture_output=True, text=True, check=True) return result.stdout.splitlines() diff --git a/src/basic_memory/cli/commands/cloud/rclone_config.py b/src/basic_memory/cli/commands/cloud/rclone_config.py index 50edb878..dbffb4d6 100644 --- a/src/basic_memory/cli/commands/cloud/rclone_config.py +++ b/src/basic_memory/cli/commands/cloud/rclone_config.py @@ -7,6 +7,7 @@ Uses a single "basic-memory-cloud" remote for all operations. import configparser import os import shutil +import subprocess from pathlib import Path from typing import Optional @@ -22,7 +23,32 @@ class RcloneConfigError(Exception): def get_rclone_config_path() -> Path: - """Get the path to rclone configuration file.""" + """Get the path to rclone configuration file. + + Uses runtime detection via `rclone config file` to support different + installation methods (Scoop, Chocolatey, manual) across platforms. + Falls back to default location if rclone command fails. + """ + try: + # Use rclone to determine its actual config location + result = subprocess.run( + ["rclone", "config", "file"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + # Output format: "Configuration file is stored at:\n/path/to/rclone.conf" + lines = result.stdout.strip().split("\n") + if lines: + config_path = Path(lines[-1].strip()) + config_path.parent.mkdir(parents=True, exist_ok=True) + return config_path + except (subprocess.SubprocessError, FileNotFoundError): + # If rclone is not available or command fails, fall back to default + pass + + # Fallback to default location config_dir = Path.home() / ".config" / "rclone" config_dir.mkdir(parents=True, exist_ok=True) return config_dir / "rclone.conf" 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..44bb85fe 100644 --- a/tests/cli/cloud/test_rclone_config_and_bmignore_filters.py +++ b/tests/cli/cloud/test_rclone_config_and_bmignore_filters.py @@ -1,4 +1,7 @@ +import subprocess import time +from pathlib import Path +from unittest.mock import Mock from basic_memory.cli.commands.cloud.bisync_commands import convert_bmignore_to_rclone_filters from basic_memory.cli.commands.cloud.rclone_config import ( @@ -57,6 +60,38 @@ def test_convert_bmignore_to_rclone_filters_is_cached_when_up_to_date(config_hom assert second.stat().st_mtime >= first_mtime +def test_get_rclone_config_path_uses_runtime_detection(monkeypatch, tmp_path): + """Test that get_rclone_config_path uses rclone config file for runtime detection.""" + config_path = tmp_path / "custom" / "rclone.conf" + + def mock_run(cmd, **kwargs): + if cmd == ["rclone", "config", "file"]: + result = Mock() + result.returncode = 0 + result.stdout = f"Configuration file is stored at:\n{config_path}" + return result + raise NotImplementedError(f"Unexpected command: {cmd}") + + monkeypatch.setattr("subprocess.run", mock_run) + + detected_path = get_rclone_config_path() + assert detected_path == config_path + assert detected_path.parent.exists() + + +def test_get_rclone_config_path_falls_back_on_error(monkeypatch): + """Test that get_rclone_config_path falls back to default if rclone fails.""" + + def mock_run(cmd, **kwargs): + raise subprocess.SubprocessError("rclone not found") + + monkeypatch.setattr("subprocess.run", mock_run) + + fallback_path = get_rclone_config_path() + expected = Path.home() / ".config" / "rclone" / "rclone.conf" + assert fallback_path == expected + + def test_configure_rclone_remote_writes_config_and_backs_up_existing(config_home): cfg_path = get_rclone_config_path() cfg_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_rclone_commands.py b/tests/test_rclone_commands.py index 0af195b4..001b63bc 100644 --- a/tests/test_rclone_commands.py +++ b/tests/test_rclone_commands.py @@ -130,6 +130,9 @@ def test_project_sync_success(tmp_path): assert "--filter-from" in cmd assert str(filter_path) in cmd assert "--dry-run" in cmd + assert "--header-download" in cmd + assert "X-Tigris-Consistent: true" in cmd + assert "--header-upload" in cmd assert kwargs["text"] is True @@ -214,6 +217,9 @@ def test_project_bisync_success(tmp_path): assert "--compare=modtime" in cmd assert "--workdir" in cmd assert str(state_path) in cmd + assert "--header-download" in cmd + assert "X-Tigris-Consistent: true" in cmd + assert "--header-upload" in cmd def test_project_bisync_requires_resync_first_time(tmp_path): @@ -369,6 +375,9 @@ def test_project_check_success(tmp_path): assert result is True cmd, kwargs = runner.calls[0] assert cmd[:2] == ["rclone", "check"] + assert "--header-download" in cmd + assert "X-Tigris-Consistent: true" in cmd + assert "--header-upload" in cmd assert kwargs["capture_output"] is True assert kwargs["text"] is True @@ -407,6 +416,9 @@ def test_project_ls_success(): project = SyncProject(name="research", path="app/data/research") files = project_ls(project, "my-bucket", run=runner, is_installed=lambda: True) assert files == ["file1.md", "file2.md", "subdir/file3.md"] + cmd, _ = runner.calls[0] + assert "--header-download" in cmd + assert "X-Tigris-Consistent: true" in cmd def test_project_ls_with_subpath():