Compare commits

...

1 Commits

Author SHA1 Message Date
claude[bot] 4897f3501f fix: detect rclone version and conditionally use --create-empty-src-dirs flag
- Add get_rclone_version() function to parse rclone version from 'rclone version' output
- Modify project_bisync() to only include --create-empty-src-dirs for rclone v1.64+
- Handle version detection failures gracefully (omit flag when version unknown)
- Add comprehensive test coverage for version detection and conditional flag usage

Fixes #472

Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2025-12-23 20:01:06 +00:00
2 changed files with 181 additions and 9 deletions
@@ -9,6 +9,7 @@ This module provides simplified, project-scoped rclone operations:
Replaces tenant-wide sync with project-scoped workflows.
"""
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
@@ -43,6 +44,31 @@ def check_rclone_installed() -> None:
)
def get_rclone_version() -> tuple[int, int, int] | None:
"""Get rclone version as (major, minor, patch) tuple.
Returns:
Version tuple (major, minor, patch) or None if version cannot be determined
Example:
>>> version = get_rclone_version()
>>> if version and version >= (1, 64, 0):
... # Use features from rclone v1.64+
"""
try:
result = subprocess.run(
["rclone", "version"], capture_output=True, text=True, timeout=5
)
# Match version pattern like "v1.64.2" or "v1.60.1-DEV"
match = re.search(r"v(\d+)\.(\d+)\.(\d+)", result.stdout)
if match:
return int(match.group(1)), int(match.group(2)), int(match.group(3))
except Exception:
# If we can't determine version, return None (caller should handle gracefully)
pass
return None
@dataclass
class SyncProject:
"""Project configured for cloud sync.
@@ -218,17 +244,27 @@ def project_bisync(
"bisync",
str(local_path),
remote_path,
"--create-empty-src-dirs",
"--resilient",
"--conflict-resolve=newer",
"--max-delete=25",
"--compare=modtime", # Ignore size differences from line ending conversions
"--filter-from",
str(filter_path),
"--workdir",
str(state_path),
]
# Only include --create-empty-src-dirs if rclone version >= 1.64
# This flag was added in rclone v1.64 and causes errors in older versions
version = get_rclone_version()
if version and version >= (1, 64, 0):
cmd.append("--create-empty-src-dirs")
cmd.extend(
[
"--resilient",
"--conflict-resolve=newer",
"--max-delete=25",
"--compare=modtime", # Ignore size differences from line ending conversions
"--filter-from",
str(filter_path),
"--workdir",
str(state_path),
]
)
if verbose:
cmd.append("--verbose")
else:
+136
View File
@@ -12,6 +12,7 @@ from basic_memory.cli.commands.cloud.rclone_commands import (
check_rclone_installed,
get_project_bisync_state,
get_project_remote,
get_rclone_version,
project_bisync,
project_check,
project_ls,
@@ -479,3 +480,138 @@ def test_project_ls_checks_rclone_installed(mock_is_installed):
assert "rclone is not installed" in str(exc_info.value)
mock_is_installed.assert_called_once()
# Tests for rclone version detection
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_get_rclone_version_success(mock_run):
"""Test successful rclone version detection."""
mock_run.return_value = MagicMock(
returncode=0, stdout="rclone v1.64.2\n- os/version: darwin 14.1.1 (64 bit)\n"
)
version = get_rclone_version()
assert version == (1, 64, 2)
mock_run.assert_called_once()
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_get_rclone_version_dev_build(mock_run):
"""Test rclone version detection with DEV suffix."""
mock_run.return_value = MagicMock(
returncode=0, stdout="rclone v1.60.1-DEV\n- os/version: linux\n"
)
version = get_rclone_version()
assert version == (1, 60, 1)
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_get_rclone_version_old_version(mock_run):
"""Test rclone version detection with older version."""
mock_run.return_value = MagicMock(
returncode=0, stdout="rclone v1.55.0\n- os/version: linux\n"
)
version = get_rclone_version()
assert version == (1, 55, 0)
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_get_rclone_version_parse_failure(mock_run):
"""Test rclone version detection when version string cannot be parsed."""
mock_run.return_value = MagicMock(returncode=0, stdout="unknown output\n")
version = get_rclone_version()
assert version is None
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
def test_get_rclone_version_subprocess_error(mock_run):
"""Test rclone version detection when subprocess fails."""
mock_run.side_effect = Exception("Command failed")
version = get_rclone_version()
assert version is None
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
@patch("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version")
def test_project_bisync_includes_flag_for_new_version(
mock_version, mock_bisync_init, mock_run, mock_is_installed
):
"""Test that --create-empty-src-dirs is included for rclone v1.64+."""
mock_is_installed.return_value = True
mock_bisync_init.return_value = True
mock_version.return_value = (1, 64, 2) # Version supports flag
mock_run.return_value = MagicMock(returncode=0)
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/tmp/research",
)
project_bisync(project, "my-bucket")
cmd = mock_run.call_args[0][0]
assert "--create-empty-src-dirs" in cmd
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
@patch("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version")
def test_project_bisync_excludes_flag_for_old_version(
mock_version, mock_bisync_init, mock_run, mock_is_installed
):
"""Test that --create-empty-src-dirs is excluded for rclone < v1.64."""
mock_is_installed.return_value = True
mock_bisync_init.return_value = True
mock_version.return_value = (1, 60, 1) # Old version doesn't support flag
mock_run.return_value = MagicMock(returncode=0)
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/tmp/research",
)
project_bisync(project, "my-bucket")
cmd = mock_run.call_args[0][0]
assert "--create-empty-src-dirs" not in cmd
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
@patch("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version")
def test_project_bisync_excludes_flag_when_version_unknown(
mock_version, mock_bisync_init, mock_run, mock_is_installed
):
"""Test that --create-empty-src-dirs is excluded when version cannot be determined."""
mock_is_installed.return_value = True
mock_bisync_init.return_value = True
mock_version.return_value = None # Version detection failed
mock_run.return_value = MagicMock(returncode=0)
project = SyncProject(
name="research",
path="app/data/research",
local_sync_path="/tmp/research",
)
project_bisync(project, "my-bucket")
cmd = mock_run.call_args[0][0]
assert "--create-empty-src-dirs" not in cmd