"""Test project-scoped rclone commands.""" from __future__ import annotations from pathlib import Path import pytest from basic_memory.cli.commands.cloud.rclone_commands import ( MIN_RCLONE_VERSION_EMPTY_DIRS, RcloneError, SyncProject, TransferPlan, _conflict_copy_name, _parse_check_combined, bisync_initialized, check_rclone_installed, get_project_bisync_state, get_project_remote, get_rclone_version, project_bisync, project_check, project_copy, project_copy_file, project_diff, project_ls, project_sync, project_transfer, supports_create_empty_src_dirs, ) class _RunResult: def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = ""): self.returncode = returncode self.stdout = stdout self.stderr = stderr class _Runner: def __init__(self, *, returncode: int = 0, stdout: str = "", stderr: str = ""): self.calls: list[tuple[list[str], dict]] = [] self._returncode = returncode self._stdout = stdout self._stderr = stderr def __call__(self, cmd: list[str], **kwargs): self.calls.append((cmd, kwargs)) return _RunResult(returncode=self._returncode, stdout=self._stdout, stderr=self._stderr) def _assert_has_consistency_headers(cmd: list[str]) -> None: """Assert the rclone command includes Tigris consistency headers. Uses --header (global flag) so the header applies to ALL HTTP transactions, including S3 list requests that bisync issues before any download/upload. """ assert "--header" in cmd header_idx = cmd.index("--header") assert cmd[header_idx + 1] == "X-Tigris-Consistent: true" def _write_filter_file(tmp_path: Path) -> Path: p = tmp_path / "filters.txt" p.write_text("- .git/**\n", encoding="utf-8") return p def test_sync_project_dataclass(): project = SyncProject( name="research", path="app/data/research", local_sync_path="/Users/test/research" ) assert project.name == "research" assert project.path == "app/data/research" assert project.local_sync_path == "/Users/test/research" def test_sync_project_optional_local_path(): project = SyncProject(name="research", path="app/data/research") assert project.name == "research" assert project.path == "app/data/research" assert project.local_sync_path is None def test_get_project_remote(): project = SyncProject(name="research", path="/research") assert get_project_remote(project, "my-bucket") == "basic-memory-cloud:my-bucket/research" def test_get_project_remote_uses_workspace_remote(): # Non-default/team workspaces route through their own tenant-scoped remote. project = SyncProject(name="research", path="/research", remote_name="basic-memory-cloud-acme") assert ( get_project_remote(project, "acme-bucket") == "basic-memory-cloud-acme:acme-bucket/research" ) def test_sync_project_remote_name_defaults_to_legacy_remote(): project = SyncProject(name="research", path="/research") assert project.remote_name == "basic-memory-cloud" def test_get_project_remote_strips_app_data_prefix(): project = SyncProject(name="research", path="/app/data/research") assert get_project_remote(project, "my-bucket") == "basic-memory-cloud:my-bucket/research" def test_get_project_bisync_state(monkeypatch): monkeypatch.delenv("BASIC_MEMORY_CONFIG_DIR", raising=False) state_path = get_project_bisync_state("research") expected = Path.home() / ".basic-memory" / "bisync-state" / "research" assert state_path == expected def test_get_project_bisync_state_honors_basic_memory_config_dir(tmp_path, monkeypatch): """Regression guard for #742: bisync state dir follows BASIC_MEMORY_CONFIG_DIR.""" custom_dir = tmp_path / "instance-w" / "state" monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(custom_dir)) assert get_project_bisync_state("research") == custom_dir / "bisync-state" / "research" def test_bisync_initialized_false_when_not_exists(tmp_path, monkeypatch): monkeypatch.setattr( "basic_memory.cli.commands.cloud.rclone_commands.get_project_bisync_state", lambda project_name: tmp_path / project_name, ) assert bisync_initialized("research") is False def test_bisync_initialized_false_when_empty(tmp_path, monkeypatch): state_dir = tmp_path / "research" state_dir.mkdir() monkeypatch.setattr( "basic_memory.cli.commands.cloud.rclone_commands.get_project_bisync_state", lambda project_name: tmp_path / project_name, ) assert bisync_initialized("research") is False def test_bisync_initialized_true_when_has_files(tmp_path, monkeypatch): state_dir = tmp_path / "research" state_dir.mkdir() (state_dir / "state.lst").touch() monkeypatch.setattr( "basic_memory.cli.commands.cloud.rclone_commands.get_project_bisync_state", lambda project_name: tmp_path / project_name, ) assert bisync_initialized("research") is True def test_project_sync_success(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") result = project_sync( project, "my-bucket", dry_run=True, run=runner, is_installed=lambda: True, filter_path=filter_path, ) assert result is True assert len(runner.calls) == 1 cmd, kwargs = runner.calls[0] assert cmd[:2] == ["rclone", "sync"] assert Path(cmd[2]) == Path("/tmp/research") assert cmd[3] == "basic-memory-cloud:my-bucket/research" _assert_has_consistency_headers(cmd) assert "--filter-from" in cmd assert str(filter_path) in cmd assert "--dry-run" in cmd assert kwargs["text"] is True def test_project_sync_with_verbose(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) project_sync( project, "my-bucket", verbose=True, run=runner, is_installed=lambda: True, filter_path=filter_path, ) cmd, _ = runner.calls[0] assert "--verbose" in cmd assert "--progress" not in cmd def test_project_sync_with_progress(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) project_sync( project, "my-bucket", run=runner, is_installed=lambda: True, filter_path=filter_path ) cmd, _ = runner.calls[0] assert "--progress" in cmd assert "--verbose" not in cmd def test_project_sync_no_local_path(): project = SyncProject(name="research", path="app/data/research") with pytest.raises(RcloneError) as exc_info: project_sync(project, "my-bucket", is_installed=lambda: True) assert "no local_sync_path configured" in str(exc_info.value) def test_project_sync_checks_rclone_installed(): project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) with pytest.raises(RcloneError) as exc_info: project_sync(project, "my-bucket", is_installed=lambda: False) assert "rclone is not installed" in str(exc_info.value) def test_project_bisync_success(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) state_path = tmp_path / "state" project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) result = project_bisync( project, "my-bucket", run=runner, is_installed=lambda: True, version=(1, 64, 2), filter_path=filter_path, state_path=state_path, is_initialized=lambda _name: True, ) assert result is True cmd, _ = runner.calls[0] assert cmd[:2] == ["rclone", "bisync"] _assert_has_consistency_headers(cmd) assert "--resilient" in cmd assert "--conflict-resolve=newer" in cmd assert "--max-delete=25" in cmd assert "--compare=modtime" in cmd assert "--workdir" in cmd assert str(state_path) in cmd def test_project_bisync_requires_resync_first_time(tmp_path): filter_path = _write_filter_file(tmp_path) state_path = tmp_path / "state" project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) with pytest.raises(RcloneError) as exc_info: project_bisync( project, "my-bucket", is_installed=lambda: True, version=(1, 64, 2), filter_path=filter_path, state_path=state_path, is_initialized=lambda _name: False, ) assert "requires --resync" in str(exc_info.value) def test_project_bisync_with_resync_flag(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) state_path = tmp_path / "state" project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) result = project_bisync( project, "my-bucket", resync=True, run=runner, is_installed=lambda: True, version=(1, 64, 2), filter_path=filter_path, state_path=state_path, is_initialized=lambda _name: False, ) assert result is True cmd, _ = runner.calls[0] assert "--resync" in cmd def test_project_bisync_dry_run_skips_init_check(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) state_path = tmp_path / "state" project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) result = project_bisync( project, "my-bucket", dry_run=True, run=runner, is_installed=lambda: True, version=(1, 64, 2), filter_path=filter_path, state_path=state_path, is_initialized=lambda _name: False, ) assert result is True cmd, _ = runner.calls[0] assert "--dry-run" in cmd def test_project_bisync_no_local_path(): project = SyncProject(name="research", path="app/data/research") with pytest.raises(RcloneError) as exc_info: project_bisync(project, "my-bucket", is_installed=lambda: True) assert "no local_sync_path configured" in str(exc_info.value) def test_project_bisync_checks_rclone_installed(tmp_path): project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) with pytest.raises(RcloneError) as exc_info: project_bisync( project, "my-bucket", is_installed=lambda: False, filter_path=_write_filter_file(tmp_path), state_path=tmp_path / "state", is_initialized=lambda _name: True, ) assert "rclone is not installed" in str(exc_info.value) def test_project_bisync_includes_empty_dirs_flag_when_supported(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) state_path = tmp_path / "state" project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) project_bisync( project, "my-bucket", run=runner, is_installed=lambda: True, version=(1, 64, 2), filter_path=filter_path, state_path=state_path, is_initialized=lambda _name: True, ) cmd, _ = runner.calls[0] assert "--create-empty-src-dirs" in cmd def test_project_bisync_excludes_empty_dirs_flag_when_not_supported(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) state_path = tmp_path / "state" project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) project_bisync( project, "my-bucket", run=runner, is_installed=lambda: True, version=(1, 60, 1), filter_path=filter_path, state_path=state_path, is_initialized=lambda _name: True, ) cmd, _ = runner.calls[0] assert "--create-empty-src-dirs" not in cmd def test_project_check_success(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) result = project_check( project, "my-bucket", run=runner, is_installed=lambda: True, filter_path=filter_path ) assert result is True cmd, kwargs = runner.calls[0] assert cmd[:2] == ["rclone", "check"] _assert_has_consistency_headers(cmd) assert kwargs["capture_output"] is True assert kwargs["text"] is True def test_project_check_with_one_way(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) project_check( project, "my-bucket", one_way=True, run=runner, is_installed=lambda: True, filter_path=filter_path, ) cmd, _ = runner.calls[0] assert "--one-way" in cmd def test_project_check_checks_rclone_installed(): project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) with pytest.raises(RcloneError) as exc_info: project_check(project, "my-bucket", is_installed=lambda: False) assert "rclone is not installed" in str(exc_info.value) def test_project_ls_success(): runner = _Runner(returncode=0, stdout="file1.md\nfile2.md\nsubdir/file3.md\n") 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_has_consistency_headers(cmd) def test_project_ls_with_subpath(): runner = _Runner(returncode=0, stdout="") project = SyncProject(name="research", path="/research") project_ls(project, "my-bucket", path="subdir", run=runner, is_installed=lambda: True) cmd, kwargs = runner.calls[0] assert cmd[-1] == "basic-memory-cloud:my-bucket/research/subdir" assert kwargs["check"] is True def test_project_ls_checks_rclone_installed(): project = SyncProject(name="research", path="app/data/research") with pytest.raises(RcloneError) as exc_info: project_ls(project, "my-bucket", is_installed=lambda: False) assert "rclone is not installed" in str(exc_info.value) def test_check_rclone_installed_success(): check_rclone_installed(is_installed=lambda: True) def test_check_rclone_installed_not_found(): with pytest.raises(RcloneError) as exc_info: check_rclone_installed(is_installed=lambda: False) error_msg = str(exc_info.value) assert "rclone is not installed" in error_msg assert "bm cloud setup" in error_msg assert "https://rclone.org/downloads/" in error_msg def test_get_rclone_version_parses_standard_version(): get_rclone_version.cache_clear() runner = _Runner(stdout="rclone v1.64.2\n- os/version: darwin 23.0.0\n- os/arch: arm64\n") assert get_rclone_version(run=runner) == (1, 64, 2) def test_get_rclone_version_parses_dev_version(): get_rclone_version.cache_clear() runner = _Runner(stdout="rclone v1.60.1-DEV\n- os/version: linux 5.15.0\n") assert get_rclone_version(run=runner) == (1, 60, 1) def test_get_rclone_version_handles_invalid_output(): get_rclone_version.cache_clear() runner = _Runner(stdout="not a valid version string") assert get_rclone_version(run=runner) is None def test_get_rclone_version_handles_exception(): get_rclone_version.cache_clear() def bad_run(_cmd, **_kwargs): raise Exception("Command failed") assert get_rclone_version(run=bad_run) is None def test_get_rclone_version_handles_timeout(): get_rclone_version.cache_clear() from subprocess import TimeoutExpired def bad_run(_cmd, **_kwargs): raise TimeoutExpired(cmd="rclone version", timeout=10) assert get_rclone_version(run=bad_run) is None def test_supports_create_empty_src_dirs_true_for_new_version(): assert supports_create_empty_src_dirs((1, 64, 2)) is True def test_supports_create_empty_src_dirs_true_for_exact_min_version(): assert supports_create_empty_src_dirs((1, 64, 0)) is True def test_supports_create_empty_src_dirs_false_for_old_version(): assert supports_create_empty_src_dirs((1, 60, 1)) is False def test_supports_create_empty_src_dirs_false_for_unknown_version(): assert supports_create_empty_src_dirs(None) is False def test_min_rclone_version_constant(): assert MIN_RCLONE_VERSION_EMPTY_DIRS == (1, 64, 0) def test_project_sync_includes_no_preallocate_flag(tmp_path): """Sync command includes --local-no-preallocate to prevent NUL byte padding.""" runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") project_sync( project, "my-bucket", run=runner, is_installed=lambda: True, filter_path=filter_path, ) cmd, _ = runner.calls[0] assert "--local-no-preallocate" in cmd def test_project_bisync_includes_no_preallocate_flag(tmp_path): """Bisync command includes --local-no-preallocate to prevent NUL byte padding.""" runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) state_path = tmp_path / "state" project = SyncProject( name="research", path="app/data/research", local_sync_path="/tmp/research" ) project_bisync( project, "my-bucket", run=runner, is_installed=lambda: True, version=(1, 64, 2), filter_path=filter_path, state_path=state_path, is_initialized=lambda _name: True, ) cmd, _ = runner.calls[0] assert "--local-no-preallocate" in cmd # --- Directional transfer primitives (push / pull, issue #858) --- def test_parse_check_combined_classifies_lines(): output = "= same.md\n+ only-src.md\n- only-dst.md\n* differ.md\n! broken.md\n\n" plan = _parse_check_combined(output) assert plan.new == ["only-src.md"] assert plan.dest_only == ["only-dst.md"] assert plan.conflicts == ["differ.md"] assert plan.errors == ["broken.md"] def test_parse_check_combined_handles_paths_with_spaces(): plan = _parse_check_combined("* notes/my file.md\n") assert plan.conflicts == ["notes/my file.md"] def test_conflict_copy_name_inserts_marker_before_extension(): assert _conflict_copy_name("notes/x.md", "20260608-1030") == "notes/x.conflict-20260608-1030.md" assert _conflict_copy_name("top.md", "S") == "top.conflict-S.md" def test_project_diff_pull_uses_remote_as_source(tmp_path): runner = _Runner(returncode=1, stdout="+ a.md\n* b.md\n") filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") plan = project_diff( project, "my-bucket", "pull", run=runner, is_installed=lambda: True, filter_path=filter_path, ) cmd, kwargs = runner.calls[0] assert cmd[:2] == ["rclone", "check"] # pull: cloud is the source so "+" files (only on source) come down to local assert cmd[2] == "basic-memory-cloud:my-bucket/research" assert Path(cmd[3]) == Path("/tmp/research") assert "--combined" in cmd and cmd[cmd.index("--combined") + 1] == "-" _assert_has_consistency_headers(cmd) assert "--filter-from" in cmd assert kwargs["capture_output"] is True # rclone exits non-zero when files differ; we parse output rather than trust it assert plan.new == ["a.md"] assert plan.conflicts == ["b.md"] def test_project_diff_push_uses_local_as_source(tmp_path): runner = _Runner(returncode=0, stdout="") filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") project_diff( project, "my-bucket", "push", run=runner, is_installed=lambda: True, filter_path=filter_path, ) cmd, _ = runner.calls[0] assert Path(cmd[2]) == Path("/tmp/research") assert cmd[3] == "basic-memory-cloud:my-bucket/research" def test_project_copy_pull_new_only_adds_ignore_existing(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") result = project_copy( project, "my-bucket", "pull", overwrite=False, run=runner, is_installed=lambda: True, filter_path=filter_path, ) assert result is True cmd, _ = runner.calls[0] assert cmd[:2] == ["rclone", "copy"] assert cmd[2] == "basic-memory-cloud:my-bucket/research" assert Path(cmd[3]) == Path("/tmp/research") assert "--ignore-existing" in cmd assert "--local-no-preallocate" in cmd # new-only skips by existence, so no content comparison is needed assert "--checksum" not in cmd def test_project_copy_pull_overwrite_omits_ignore_existing(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") project_copy( project, "my-bucket", "pull", overwrite=True, run=runner, is_installed=lambda: True, filter_path=filter_path, ) cmd, _ = runner.calls[0] assert "--ignore-existing" not in cmd # overwrite compares by content so it matches hash-based conflict detection assert "--checksum" in cmd def test_project_copy_push_uses_local_as_source(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") project_copy( project, "my-bucket", "push", overwrite=False, run=runner, is_installed=lambda: True, filter_path=filter_path, ) cmd, _ = runner.calls[0] assert Path(cmd[2]) == Path("/tmp/research") assert cmd[3] == "basic-memory-cloud:my-bucket/research" def test_project_copy_file_pull_copyto_renames_on_dest(tmp_path): runner = _Runner(returncode=0) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") project_copy_file( project, "my-bucket", "pull", "notes/dup.md", "notes/dup.conflict-S.md", run=runner, is_installed=lambda: True, ) cmd, _ = runner.calls[0] assert cmd[:2] == ["rclone", "copyto"] assert cmd[2] == "basic-memory-cloud:my-bucket/research/notes/dup.md" # Compare the local dest via Path so the assertion holds on Windows, where the # local root renders with backslashes (rclone accepts the mixed separators). assert Path(cmd[3]) == Path("/tmp/research/notes/dup.conflict-S.md") # pull writes the conflict copy locally → must guard virtual-FS NUL padding assert "--local-no-preallocate" in cmd def test_project_transfer_keep_both_copies_conflicts_then_additive(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") plan = TransferPlan(new=["a.md"], conflicts=["dup.md"], dest_only=[], errors=[]) result = project_transfer( project, "my-bucket", "pull", plan, strategy="keep-both", conflict_suffix="S", run=runner, is_installed=lambda: True, filter_path=filter_path, ) assert result is True # First a copyto for the conflict file, written beside the local copy... first_cmd, _ = runner.calls[0] assert first_cmd[:2] == ["rclone", "copyto"] # Path comparison so this holds on Windows (backslash local root). assert Path(first_cmd[3]) == Path("/tmp/research/dup.conflict-S.md") # ...then an additive (new-only) copy that won't overwrite existing local files. second_cmd, _ = runner.calls[1] assert second_cmd[:2] == ["rclone", "copy"] assert "--ignore-existing" in second_cmd def test_project_transfer_keep_cloud_on_pull_overwrites_local(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") plan = TransferPlan(new=[], conflicts=["dup.md"], dest_only=[], errors=[]) project_transfer( project, "my-bucket", "pull", plan, strategy="keep-cloud", run=runner, is_installed=lambda: True, filter_path=filter_path, ) # keep-cloud + pull → cloud (source) overwrites local (dest): no --ignore-existing, # and --checksum so the overwrite decision matches the content-based conflict. assert len(runner.calls) == 1 cmd, _ = runner.calls[0] assert cmd[:2] == ["rclone", "copy"] assert "--ignore-existing" not in cmd assert "--checksum" in cmd def test_project_transfer_keep_local_on_push_overwrites_cloud(tmp_path): runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") plan = TransferPlan(new=[], conflicts=["dup.md"], dest_only=[], errors=[]) project_transfer( project, "my-bucket", "push", plan, strategy="keep-local", run=runner, is_installed=lambda: True, filter_path=filter_path, ) cmd, _ = runner.calls[0] assert "--ignore-existing" not in cmd def test_project_diff_requires_local_path(): project = SyncProject(name="research", path="/research") with pytest.raises(RcloneError): project_diff(project, "my-bucket", "pull", is_installed=lambda: True) def test_project_diff_raises_on_fatal_check_error(tmp_path): """A non-zero exit with no combined listing means the check failed, not 'no diffs'.""" runner = _Runner(returncode=7, stdout="", stderr="Failed to create file system: AccessDenied") filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") with pytest.raises(RcloneError) as exc_info: project_diff( project, "my-bucket", "pull", run=runner, is_installed=lambda: True, filter_path=filter_path, ) assert "AccessDenied" in str(exc_info.value) def test_project_diff_nonzero_with_differences_does_not_raise(tmp_path): """Differences make rclone check exit non-zero, but that is expected — no raise.""" runner = _Runner(returncode=1, stdout="* changed.md\n") filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") plan = project_diff( project, "my-bucket", "pull", run=runner, is_installed=lambda: True, filter_path=filter_path, ) assert plan.conflicts == ["changed.md"] def test_project_transfer_keep_local_on_pull_preserves_local(tmp_path): """keep-local + pull → local is the destination and must be preserved (--ignore-existing).""" runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") plan = TransferPlan(new=[], conflicts=["dup.md"], dest_only=[], errors=[]) project_transfer( project, "my-bucket", "pull", plan, strategy="keep-local", run=runner, is_installed=lambda: True, filter_path=filter_path, ) cmd, _ = runner.calls[0] assert "--ignore-existing" in cmd assert "--checksum" not in cmd def test_project_transfer_keep_cloud_on_push_preserves_cloud(tmp_path): """keep-cloud + push → cloud is the destination and must be preserved (--ignore-existing).""" runner = _Runner(returncode=0) filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") plan = TransferPlan(new=[], conflicts=["dup.md"], dest_only=[], errors=[]) project_transfer( project, "my-bucket", "push", plan, strategy="keep-cloud", run=runner, is_installed=lambda: True, filter_path=filter_path, ) cmd, _ = runner.calls[0] assert "--ignore-existing" in cmd def test_project_transfer_keep_both_returns_false_on_copy_failure(tmp_path): """A failed conflict-copy aborts the transfer before the additive pass.""" runner = _Runner(returncode=1) # every rclone invocation fails filter_path = _write_filter_file(tmp_path) project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research") plan = TransferPlan(new=["a.md"], conflicts=["dup.md"], dest_only=[], errors=[]) result = project_transfer( project, "my-bucket", "pull", plan, strategy="keep-both", conflict_suffix="S", run=runner, is_installed=lambda: True, filter_path=filter_path, ) assert result is False # Stopped after the first failed copyto — the additive copy never ran. assert len(runner.calls) == 1 assert runner.calls[0][0][:2] == ["rclone", "copyto"]