Skip to content

Commit c850450

Browse files
committed
fix(cli): address push/pull review feedback (fatal-error guard, UX, tests)
From the Codex and claude-review automated reviews on PR #917: - project_diff: fail fast on fatal `rclone check` errors. A non-zero exit with no combined listing (auth/network/missing-remote) previously produced an empty plan, so the transfer ran as a no-op and reported success. Now raises RcloneError with rclone's stderr. (Codex P2 / claude-review #2) - sync-setup "Next steps": stop pointing every user at the now-Personal-only `bm cloud sync`; lead with the Team-safe `pull`/`push` and note bisync is Personal-only. (claude-review #1) - Cosmetic: capitalize the push/pull abort headlines for consistency; document the two ConflictStrategy definitions (Typer enum vs engine Literal). (nits) - Tests: cover keep-local+pull and keep-cloud+push (preserve-destination cases), project_transfer aborting on a mid-loop conflict-copy failure, and project_diff raising on a fatal check error vs not raising on a normal differences exit. Signed-off-by: phernandez <paul@basicmachines.co>
1 parent ad1f862 commit c850450

3 files changed

Lines changed: 140 additions & 8 deletions

File tree

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ class ConflictStrategy(str, Enum):
5757
Default is ``fail``: surface the conflicts and abort before transferring,
5858
leaving the user to re-run with an explicit resolution — like git refusing
5959
to clobber local changes.
60+
61+
This is the Typer-facing enum; the engine in ``rclone_commands`` accepts the
62+
same values as a ``ConflictStrategy`` Literal. ``_run_directional_transfer``
63+
bridges the two by passing ``on_conflict.value``. Keep the values in sync.
6064
"""
6165

6266
fail = "fail"
@@ -235,7 +239,7 @@ def sync_project_command(
235239
def _print_conflict_abort(name: str, direction: TransferDirection, plan: TransferPlan) -> None:
236240
"""Explain a conflict abort and how to resolve it (git-pull style)."""
237241
console.print(
238-
f"[red]{direction} aborted: {len(plan.conflicts)} file(s) differ between "
242+
f"[red]{direction.capitalize()} aborted: {len(plan.conflicts)} file(s) differ between "
239243
f"local and cloud.[/red]"
240244
)
241245
for path in plan.conflicts:
@@ -287,7 +291,7 @@ def _run_directional_transfer(
287291
# Outcome: abort before moving any bytes.
288292
if plan.errors:
289293
console.print(
290-
f"[red]{direction} aborted: rclone could not compare "
294+
f"[red]{direction.capitalize()} aborted: rclone could not compare "
291295
f"{len(plan.errors)} file(s)[/red]"
292296
)
293297
for path in plan.errors:
@@ -602,9 +606,15 @@ async def _create_local_project():
602606

603607
console.print(f"[green]Sync configured for project '{name}'[/green]")
604608
console.print(f"\nLocal sync path: {resolved_path}")
609+
# Lead with the Team-safe additive commands (work on any workspace); the
610+
# `sync`/`bisync` mirrors are Personal-workspace-only.
605611
console.print("\nNext steps:")
606-
console.print(f" 1. Preview: bm cloud sync --name {name} --dry-run")
607-
console.print(f" 2. Sync: bm cloud sync --name {name}")
612+
console.print(f" 1. Preview a pull: bm cloud pull --name {name} --dry-run")
613+
console.print(f" 2. Fetch from cloud: bm cloud pull --name {name}")
614+
console.print(f" 3. Upload local changes: bm cloud push --name {name}")
615+
console.print(
616+
f" Personal workspaces can also mirror with: bm cloud bisync --name {name} --resync"
617+
)
608618
except Exception as e:
609619
console.print(f"[red]Error configuring sync: {str(e)}[/red]")
610620
raise typer.Exit(1)

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
class RunResult(Protocol):
4343
returncode: int
4444
stdout: str
45+
stderr: str
4546

4647

4748
RunFunc = Callable[..., RunResult]
@@ -384,7 +385,19 @@ def project_diff(
384385
# rclone check exits non-zero when files differ — that's expected here, so we
385386
# parse the combined listing rather than trusting the return code.
386387
result = run(cmd, capture_output=True, text=True)
387-
return _parse_check_combined(result.stdout)
388+
plan = _parse_check_combined(result.stdout)
389+
390+
# Trigger: non-zero exit AND the combined listing produced no entries at all.
391+
# Why: a difference always yields +/-/*/! lines, so an empty listing on a
392+
# non-zero exit means the check itself failed (auth, missing remote, network,
393+
# bad filter) rather than finding zero differences. Without this guard the
394+
# caller would see an empty plan, transfer nothing, and report success.
395+
# Outcome: fail fast with rclone's stderr instead of a silent no-op.
396+
if result.returncode != 0 and not (plan.new or plan.conflicts or plan.dest_only or plan.errors):
397+
detail = result.stderr.strip() or f"rclone check exited with code {result.returncode}"
398+
raise RcloneError(f"Failed to compare {project.name} with cloud: {detail}")
399+
400+
return plan
388401

389402

390403
def project_copy(

tests/test_rclone_commands.py

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,22 @@
3131

3232

3333
class _RunResult:
34-
def __init__(self, returncode: int = 0, stdout: str = ""):
34+
def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = ""):
3535
self.returncode = returncode
3636
self.stdout = stdout
37+
self.stderr = stderr
3738

3839

3940
class _Runner:
40-
def __init__(self, *, returncode: int = 0, stdout: str = ""):
41+
def __init__(self, *, returncode: int = 0, stdout: str = "", stderr: str = ""):
4142
self.calls: list[tuple[list[str], dict]] = []
4243
self._returncode = returncode
4344
self._stdout = stdout
45+
self._stderr = stderr
4446

4547
def __call__(self, cmd: list[str], **kwargs):
4648
self.calls.append((cmd, kwargs))
47-
return _RunResult(returncode=self._returncode, stdout=self._stdout)
49+
return _RunResult(returncode=self._returncode, stdout=self._stdout, stderr=self._stderr)
4850

4951

5052
def _assert_has_consistency_headers(cmd: list[str]) -> None:
@@ -809,3 +811,110 @@ def test_project_diff_requires_local_path():
809811
project = SyncProject(name="research", path="/research")
810812
with pytest.raises(RcloneError):
811813
project_diff(project, "my-bucket", "pull", is_installed=lambda: True)
814+
815+
816+
def test_project_diff_raises_on_fatal_check_error(tmp_path):
817+
"""A non-zero exit with no combined listing means the check failed, not 'no diffs'."""
818+
runner = _Runner(returncode=7, stdout="", stderr="Failed to create file system: AccessDenied")
819+
filter_path = _write_filter_file(tmp_path)
820+
project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research")
821+
822+
with pytest.raises(RcloneError) as exc_info:
823+
project_diff(
824+
project,
825+
"my-bucket",
826+
"pull",
827+
run=runner,
828+
is_installed=lambda: True,
829+
filter_path=filter_path,
830+
)
831+
832+
assert "AccessDenied" in str(exc_info.value)
833+
834+
835+
def test_project_diff_nonzero_with_differences_does_not_raise(tmp_path):
836+
"""Differences make rclone check exit non-zero, but that is expected — no raise."""
837+
runner = _Runner(returncode=1, stdout="* changed.md\n")
838+
filter_path = _write_filter_file(tmp_path)
839+
project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research")
840+
841+
plan = project_diff(
842+
project,
843+
"my-bucket",
844+
"pull",
845+
run=runner,
846+
is_installed=lambda: True,
847+
filter_path=filter_path,
848+
)
849+
850+
assert plan.conflicts == ["changed.md"]
851+
852+
853+
def test_project_transfer_keep_local_on_pull_preserves_local(tmp_path):
854+
"""keep-local + pull → local is the destination and must be preserved (--ignore-existing)."""
855+
runner = _Runner(returncode=0)
856+
filter_path = _write_filter_file(tmp_path)
857+
project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research")
858+
plan = TransferPlan(new=[], conflicts=["dup.md"], dest_only=[], errors=[])
859+
860+
project_transfer(
861+
project,
862+
"my-bucket",
863+
"pull",
864+
plan,
865+
strategy="keep-local",
866+
run=runner,
867+
is_installed=lambda: True,
868+
filter_path=filter_path,
869+
)
870+
871+
cmd, _ = runner.calls[0]
872+
assert "--ignore-existing" in cmd
873+
assert "--checksum" not in cmd
874+
875+
876+
def test_project_transfer_keep_cloud_on_push_preserves_cloud(tmp_path):
877+
"""keep-cloud + push → cloud is the destination and must be preserved (--ignore-existing)."""
878+
runner = _Runner(returncode=0)
879+
filter_path = _write_filter_file(tmp_path)
880+
project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research")
881+
plan = TransferPlan(new=[], conflicts=["dup.md"], dest_only=[], errors=[])
882+
883+
project_transfer(
884+
project,
885+
"my-bucket",
886+
"push",
887+
plan,
888+
strategy="keep-cloud",
889+
run=runner,
890+
is_installed=lambda: True,
891+
filter_path=filter_path,
892+
)
893+
894+
cmd, _ = runner.calls[0]
895+
assert "--ignore-existing" in cmd
896+
897+
898+
def test_project_transfer_keep_both_returns_false_on_copy_failure(tmp_path):
899+
"""A failed conflict-copy aborts the transfer before the additive pass."""
900+
runner = _Runner(returncode=1) # every rclone invocation fails
901+
filter_path = _write_filter_file(tmp_path)
902+
project = SyncProject(name="research", path="/research", local_sync_path="/tmp/research")
903+
plan = TransferPlan(new=["a.md"], conflicts=["dup.md"], dest_only=[], errors=[])
904+
905+
result = project_transfer(
906+
project,
907+
"my-bucket",
908+
"pull",
909+
plan,
910+
strategy="keep-both",
911+
conflict_suffix="S",
912+
run=runner,
913+
is_installed=lambda: True,
914+
filter_path=filter_path,
915+
)
916+
917+
assert result is False
918+
# Stopped after the first failed copyto — the additive copy never ran.
919+
assert len(runner.calls) == 1
920+
assert runner.calls[0][0][:2] == ["rclone", "copyto"]

0 commit comments

Comments
 (0)