Skip to content

Commit 622d37e

Browse files
phernandezclaude
andauthored
fix: detect rclone version for --create-empty-src-dirs support (#473)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 916baf8 commit 622d37e

File tree

2 files changed

+218
-7
lines changed

2 files changed

+218
-7
lines changed

src/basic_memory/cli/commands/cloud/rclone_commands.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,24 @@
99
Replaces tenant-wide sync with project-scoped workflows.
1010
"""
1111

12+
import re
1213
import subprocess
1314
from dataclasses import dataclass
15+
from functools import lru_cache
1416
from pathlib import Path
1517
from typing import Optional
1618

19+
from loguru import logger
1720
from rich.console import Console
1821

1922
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
2023
from basic_memory.utils import normalize_project_path
2124

2225
console = Console()
2326

27+
# Minimum rclone version for --create-empty-src-dirs support
28+
MIN_RCLONE_VERSION_EMPTY_DIRS = (1, 64, 0)
29+
2430

2531
class RcloneError(Exception):
2632
"""Exception raised for rclone command errors."""
@@ -43,6 +49,42 @@ def check_rclone_installed() -> None:
4349
)
4450

4551

52+
@lru_cache(maxsize=1)
53+
def get_rclone_version() -> tuple[int, int, int] | None:
54+
"""Get rclone version as (major, minor, patch) tuple.
55+
56+
Returns:
57+
Version tuple like (1, 64, 2), or None if version cannot be determined.
58+
59+
Note:
60+
Result is cached since rclone version won't change during runtime.
61+
"""
62+
try:
63+
result = subprocess.run(["rclone", "version"], capture_output=True, text=True, timeout=10)
64+
# Parse "rclone v1.64.2" or "rclone v1.60.1-DEV"
65+
match = re.search(r"v(\d+)\.(\d+)\.(\d+)", result.stdout)
66+
if match:
67+
version = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
68+
logger.debug(f"Detected rclone version: {version}")
69+
return version
70+
except Exception as e:
71+
logger.warning(f"Could not determine rclone version: {e}")
72+
return None
73+
74+
75+
def supports_create_empty_src_dirs() -> bool:
76+
"""Check if installed rclone supports --create-empty-src-dirs flag.
77+
78+
Returns:
79+
True if rclone version >= 1.64.0, False otherwise.
80+
"""
81+
version = get_rclone_version()
82+
if version is None:
83+
# If we can't determine version, assume older and skip the flag
84+
return False
85+
return version >= MIN_RCLONE_VERSION_EMPTY_DIRS
86+
87+
4688
@dataclass
4789
class SyncProject:
4890
"""Project configured for cloud sync.
@@ -218,7 +260,6 @@ def project_bisync(
218260
"bisync",
219261
str(local_path),
220262
remote_path,
221-
"--create-empty-src-dirs",
222263
"--resilient",
223264
"--conflict-resolve=newer",
224265
"--max-delete=25",
@@ -229,6 +270,10 @@ def project_bisync(
229270
str(state_path),
230271
]
231272

273+
# Add --create-empty-src-dirs if rclone version supports it (v1.64+)
274+
if supports_create_empty_src_dirs():
275+
cmd.append("--create-empty-src-dirs")
276+
232277
if verbose:
233278
cmd.append("--verbose")
234279
else:

tests/test_rclone_commands.py

Lines changed: 172 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@
66
import pytest
77

88
from basic_memory.cli.commands.cloud.rclone_commands import (
9+
MIN_RCLONE_VERSION_EMPTY_DIRS,
910
RcloneError,
1011
SyncProject,
1112
bisync_initialized,
1213
check_rclone_installed,
1314
get_project_bisync_state,
1415
get_project_remote,
16+
get_rclone_version,
1517
project_bisync,
1618
project_check,
1719
project_ls,
1820
project_sync,
21+
supports_create_empty_src_dirs,
1922
)
2023

2124

@@ -194,10 +197,12 @@ def test_project_sync_no_local_path(mock_is_installed):
194197
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
195198
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
196199
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
197-
def test_project_bisync_success(mock_bisync_init, mock_run, mock_is_installed):
200+
@patch("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs")
201+
def test_project_bisync_success(mock_supports_flag, mock_bisync_init, mock_run, mock_is_installed):
198202
"""Test successful project bisync."""
199203
mock_is_installed.return_value = True
200204
mock_bisync_init.return_value = True # Already initialized
205+
mock_supports_flag.return_value = True # Mock version check
201206
mock_run.return_value = MagicMock(returncode=0)
202207

203208
project = SyncProject(
@@ -221,9 +226,8 @@ def test_project_bisync_success(mock_bisync_init, mock_run, mock_is_installed):
221226

222227

223228
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
224-
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
225229
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
226-
def test_project_bisync_requires_resync_first_time(mock_bisync_init, mock_run, mock_is_installed):
230+
def test_project_bisync_requires_resync_first_time(mock_bisync_init, mock_is_installed):
227231
"""Test that first bisync requires --resync flag."""
228232
mock_is_installed.return_value = True
229233
mock_bisync_init.return_value = False # Not initialized
@@ -238,16 +242,19 @@ def test_project_bisync_requires_resync_first_time(mock_bisync_init, mock_run, m
238242
project_bisync(project, "my-bucket")
239243

240244
assert "requires --resync" in str(exc_info.value)
241-
mock_run.assert_not_called()
242245

243246

244247
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
245248
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
246249
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
247-
def test_project_bisync_with_resync_flag(mock_bisync_init, mock_run, mock_is_installed):
250+
@patch("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs")
251+
def test_project_bisync_with_resync_flag(
252+
mock_supports_flag, mock_bisync_init, mock_run, mock_is_installed
253+
):
248254
"""Test bisync with --resync flag for first time."""
249255
mock_is_installed.return_value = True
250256
mock_bisync_init.return_value = False # Not initialized
257+
mock_supports_flag.return_value = True # Mock version check
251258
mock_run.return_value = MagicMock(returncode=0)
252259

253260
project = SyncProject(
@@ -266,10 +273,14 @@ def test_project_bisync_with_resync_flag(mock_bisync_init, mock_run, mock_is_ins
266273
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
267274
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
268275
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
269-
def test_project_bisync_dry_run_skips_init_check(mock_bisync_init, mock_run, mock_is_installed):
276+
@patch("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs")
277+
def test_project_bisync_dry_run_skips_init_check(
278+
mock_supports_flag, mock_bisync_init, mock_run, mock_is_installed
279+
):
270280
"""Test that dry-run skips initialization check."""
271281
mock_is_installed.return_value = True
272282
mock_bisync_init.return_value = False # Not initialized
283+
mock_supports_flag.return_value = True # Mock version check
273284
mock_run.return_value = MagicMock(returncode=0)
274285

275286
project = SyncProject(
@@ -479,3 +490,158 @@ def test_project_ls_checks_rclone_installed(mock_is_installed):
479490

480491
assert "rclone is not installed" in str(exc_info.value)
481492
mock_is_installed.assert_called_once()
493+
494+
495+
# Tests for rclone version detection
496+
497+
498+
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
499+
def test_get_rclone_version_parses_standard_version(mock_run):
500+
"""Test parsing standard rclone version output."""
501+
# Clear the lru_cache before test
502+
get_rclone_version.cache_clear()
503+
504+
mock_run.return_value = MagicMock(
505+
stdout="rclone v1.64.2\n- os/version: darwin 23.0.0\n- os/arch: arm64\n"
506+
)
507+
508+
version = get_rclone_version()
509+
510+
assert version == (1, 64, 2)
511+
mock_run.assert_called_once()
512+
513+
514+
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
515+
def test_get_rclone_version_parses_dev_version(mock_run):
516+
"""Test parsing rclone dev version output like v1.60.1-DEV."""
517+
get_rclone_version.cache_clear()
518+
519+
mock_run.return_value = MagicMock(stdout="rclone v1.60.1-DEV\n- os/version: linux 5.15.0\n")
520+
521+
version = get_rclone_version()
522+
523+
assert version == (1, 60, 1)
524+
525+
526+
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
527+
def test_get_rclone_version_handles_invalid_output(mock_run):
528+
"""Test handling of invalid rclone version output."""
529+
get_rclone_version.cache_clear()
530+
531+
mock_run.return_value = MagicMock(stdout="not a valid version string")
532+
533+
version = get_rclone_version()
534+
535+
assert version is None
536+
537+
538+
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
539+
def test_get_rclone_version_handles_exception(mock_run):
540+
"""Test handling of subprocess exception."""
541+
get_rclone_version.cache_clear()
542+
543+
mock_run.side_effect = Exception("Command failed")
544+
545+
version = get_rclone_version()
546+
547+
assert version is None
548+
549+
550+
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
551+
def test_get_rclone_version_handles_timeout(mock_run):
552+
"""Test handling of subprocess timeout."""
553+
get_rclone_version.cache_clear()
554+
from subprocess import TimeoutExpired
555+
556+
mock_run.side_effect = TimeoutExpired(cmd="rclone version", timeout=10)
557+
558+
version = get_rclone_version()
559+
560+
assert version is None
561+
562+
563+
@patch("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version")
564+
def test_supports_create_empty_src_dirs_true_for_new_version(mock_get_version):
565+
"""Test supports_create_empty_src_dirs returns True for v1.64+."""
566+
mock_get_version.return_value = (1, 64, 2)
567+
568+
assert supports_create_empty_src_dirs() is True
569+
570+
571+
@patch("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version")
572+
def test_supports_create_empty_src_dirs_true_for_exact_min_version(mock_get_version):
573+
"""Test supports_create_empty_src_dirs returns True for exactly v1.64.0."""
574+
mock_get_version.return_value = (1, 64, 0)
575+
576+
assert supports_create_empty_src_dirs() is True
577+
578+
579+
@patch("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version")
580+
def test_supports_create_empty_src_dirs_false_for_old_version(mock_get_version):
581+
"""Test supports_create_empty_src_dirs returns False for v1.60."""
582+
mock_get_version.return_value = (1, 60, 1)
583+
584+
assert supports_create_empty_src_dirs() is False
585+
586+
587+
@patch("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version")
588+
def test_supports_create_empty_src_dirs_false_for_unknown_version(mock_get_version):
589+
"""Test supports_create_empty_src_dirs returns False when version unknown."""
590+
mock_get_version.return_value = None
591+
592+
assert supports_create_empty_src_dirs() is False
593+
594+
595+
def test_min_rclone_version_constant():
596+
"""Test MIN_RCLONE_VERSION_EMPTY_DIRS constant is set correctly."""
597+
assert MIN_RCLONE_VERSION_EMPTY_DIRS == (1, 64, 0)
598+
599+
600+
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
601+
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
602+
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
603+
@patch("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs")
604+
def test_project_bisync_includes_empty_dirs_flag_when_supported(
605+
mock_supports_flag, mock_bisync_init, mock_run, mock_is_installed
606+
):
607+
"""Test project_bisync includes --create-empty-src-dirs when supported."""
608+
mock_is_installed.return_value = True
609+
mock_bisync_init.return_value = True
610+
mock_supports_flag.return_value = True
611+
mock_run.return_value = MagicMock(returncode=0)
612+
613+
project = SyncProject(
614+
name="research",
615+
path="app/data/research",
616+
local_sync_path="/tmp/research",
617+
)
618+
619+
project_bisync(project, "my-bucket")
620+
621+
cmd = mock_run.call_args[0][0]
622+
assert "--create-empty-src-dirs" in cmd
623+
624+
625+
@patch("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed")
626+
@patch("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run")
627+
@patch("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized")
628+
@patch("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs")
629+
def test_project_bisync_excludes_empty_dirs_flag_when_not_supported(
630+
mock_supports_flag, mock_bisync_init, mock_run, mock_is_installed
631+
):
632+
"""Test project_bisync excludes --create-empty-src-dirs for older rclone."""
633+
mock_is_installed.return_value = True
634+
mock_bisync_init.return_value = True
635+
mock_supports_flag.return_value = False # Old rclone version
636+
mock_run.return_value = MagicMock(returncode=0)
637+
638+
project = SyncProject(
639+
name="research",
640+
path="app/data/research",
641+
local_sync_path="/tmp/research",
642+
)
643+
644+
project_bisync(project, "my-bucket")
645+
646+
cmd = mock_run.call_args[0][0]
647+
assert "--create-empty-src-dirs" not in cmd

0 commit comments

Comments
 (0)