Skip to content

Commit e233636

Browse files
committed
feat(cli): add Team-safe cloud push/pull and gate sync to Personal workspaces
Adds `bm cloud push` and `bm cloud pull` as git-style, fail-safe transfer primitives that are usable on Team workspaces (issue #858), and restricts the destructive `bm cloud sync` mirror to Personal workspaces. Why - `bm cloud sync` is a destructive local->cloud mirror; on a shared Team bucket it can delete a teammate's files. The only pull path was two-way `bisync`, which is already Personal-only (#849). - Teams need a safe way to fetch teammates' notes and add their own without one stale local tree becoming authoritative for shared cloud state. What - push = `rclone copy` local->cloud, pull = `rclone copy` cloud->local. Both are additive (never delete on the destination), so neither can damage shared state. - Conflicts (a file that differs on both sides) abort by default and list the paths, like git refusing to clobber local changes / rejecting a stale push. `--on-conflict {fail|keep-local|keep-cloud|keep-both}` lets the user decide; no vague --force, no silent winner. - `sync` now requires a Personal workspace; its guard and the bisync guard point Team users at push/pull. Limitations (surfaced in --help and command output, tracked by #862): - No sync baseline yet, so deletions are not propagated and every divergence is treated as a conflict rather than auto-resolved. Real three-way merge needs the per-client manifest + Tigris snapshot baseline designed in #862. Implementation - rclone_commands.py: shared `_build_transfer_cmd`/`_transfer_endpoints`; refactor `project_sync` onto them (no behavior change); add `project_diff` (conflict detection via `rclone check --combined`), `project_copy`, `project_copy_file`, and `project_transfer` (strategy dispatch). - project_sync.py: new `push`/`pull` commands (ungated, Team-safe) with `--on-conflict`/`--dry-run`; gate `sync` to Personal via a per-command guard message. - Tests at the rclone-argv and CLI-command levels; existing sync/bisync tests updated for the new gating and messages. Signed-off-by: phernandez <paul@basicmachines.co>
1 parent a8d034b commit e233636

5 files changed

Lines changed: 964 additions & 22 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,13 @@ See `.claude/commands/release/release.md` (and `beta.md`, `release-check.md`, `c
348348
- Manage snapshots: `basic-memory cloud snapshot [create|list|delete|show|browse]`
349349
- Restore from snapshot: `basic-memory cloud restore <path> --snapshot <id>`
350350

351+
**Cloud Sync Commands (Personal and Team workspaces):**
352+
- Fetch cloud changes (cloud -> local): `basic-memory cloud pull --name "name"` (Team-safe; additive, never deletes local)
353+
- Upload local changes (local -> cloud): `basic-memory cloud push --name "name"` (Team-safe; additive, never deletes cloud)
354+
- Resolve conflicts on push/pull: `--on-conflict [fail|keep-local|keep-cloud|keep-both]` (default `fail` lists conflicts and aborts, git-style)
355+
- One-way mirror (local -> cloud): `basic-memory cloud sync --name "name"` (Personal workspaces only; deletes cloud files missing locally)
356+
- Two-way mirror (local <-> cloud): `basic-memory cloud bisync --name "name"` (Personal workspaces only)
357+
351358
### MCP Capabilities
352359

353360
- Basic Memory exposes these MCP tools to LLMs:

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

Lines changed: 212 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import os
99
from datetime import datetime
10+
from enum import Enum
1011

1112
import typer
1213
from rich.console import Console
@@ -16,10 +17,14 @@
1617
from basic_memory.cli.commands.cloud.rclone_commands import (
1718
RcloneError,
1819
SyncProject,
20+
TransferDirection,
21+
TransferPlan,
1922
get_project_bisync_state,
2023
project_bisync,
2124
project_check,
25+
project_diff,
2226
project_sync,
27+
project_transfer,
2328
)
2429
from basic_memory.cli.commands.command_utils import run_with_cleanup
2530
from basic_memory.cli.commands.routing import force_routing
@@ -35,9 +40,30 @@
3540

3641
TEAM_WORKSPACE_BISYNC_UNSUPPORTED = (
3742
"The bisync operation is only supported on Personal workspaces.\n"
38-
"Use `bm cloud sync --name {name}` instead."
43+
"Use `bm cloud pull --name {name}` / `bm cloud push --name {name}` instead."
3944
)
4045

46+
TEAM_WORKSPACE_SYNC_UNSUPPORTED = (
47+
"The sync operation mirrors local onto the shared bucket and can delete a "
48+
"teammate's files, so it is only supported on Personal workspaces.\n"
49+
"Use `bm cloud pull --name {name}` (fetch) / `bm cloud push --name {name}` "
50+
"(additive upload) instead."
51+
)
52+
53+
54+
class ConflictStrategy(str, Enum):
55+
"""How push/pull resolves files that differ on both sides.
56+
57+
Default is ``fail``: surface the conflicts and abort before transferring,
58+
leaving the user to re-run with an explicit resolution — like git refusing
59+
to clobber local changes.
60+
"""
61+
62+
fail = "fail"
63+
keep_local = "keep-local"
64+
keep_cloud = "keep-cloud"
65+
keep_both = "keep-both"
66+
4167

4268
# --- Shared helpers ---
4369

@@ -92,16 +118,26 @@ async def _get_workspace_for_project(name: str, config: BasicMemoryConfig) -> Wo
92118
)
93119

94120

95-
def _require_personal_workspace(name: str, config: BasicMemoryConfig) -> WorkspaceInfo:
96-
"""Exit before bisync work when the target workspace is not personal."""
121+
def _require_personal_workspace(
122+
name: str,
123+
config: BasicMemoryConfig,
124+
*,
125+
unsupported_message: str = TEAM_WORKSPACE_BISYNC_UNSUPPORTED,
126+
) -> WorkspaceInfo:
127+
"""Exit before mirror work when the target workspace is not personal.
128+
129+
Used to gate the destructive mirror operations (`sync`, `bisync`) to
130+
Personal workspaces. ``unsupported_message`` lets each command point Team
131+
users at the right Team-safe alternative.
132+
"""
97133
try:
98134
workspace = run_with_cleanup(_get_workspace_for_project(name, config))
99135
except Exception as exc:
100136
console.print(f"[red]Error resolving workspace for project '{name}': {exc}[/red]")
101137
raise typer.Exit(1)
102138

103139
if workspace.workspace_type != "personal":
104-
console.print(f"[red]{TEAM_WORKSPACE_BISYNC_UNSUPPORTED.format(name=name)}[/red]")
140+
console.print(f"[red]{unsupported_message.format(name=name)}[/red]")
105141
raise typer.Exit(1)
106142

107143
return workspace
@@ -150,14 +186,19 @@ def sync_project_command(
150186
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
151187
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
152188
) -> None:
153-
"""One-way sync: local -> cloud (make cloud identical to local).
189+
"""One-way mirror: local -> cloud (make cloud identical to local).
190+
191+
Personal workspaces only. This deletes cloud files not present locally, so
192+
on Team workspaces use `bm cloud push` (additive upload) / `bm cloud pull`
193+
(fetch) instead.
154194
155195
Example:
156196
bm cloud sync --name research
157197
bm cloud sync --name research --dry-run
158198
"""
159199
config = ConfigManager().config
160200
_require_cloud_credentials(config)
201+
_require_personal_workspace(name, config, unsupported_message=TEAM_WORKSPACE_SYNC_UNSUPPORTED)
161202

162203
try:
163204
# Get tenant info for bucket name
@@ -191,6 +232,172 @@ def sync_project_command(
191232
raise typer.Exit(1)
192233

193234

235+
def _print_conflict_abort(name: str, direction: TransferDirection, plan: TransferPlan) -> None:
236+
"""Explain a conflict abort and how to resolve it (git-pull style)."""
237+
console.print(
238+
f"[red]{direction} aborted: {len(plan.conflicts)} file(s) differ between "
239+
f"local and cloud.[/red]"
240+
)
241+
for path in plan.conflicts:
242+
console.print(f" [yellow]*[/yellow] {path}")
243+
console.print("\nRe-run with one of:")
244+
console.print(" [dim]--on-conflict keep-cloud[/dim] take the cloud version")
245+
console.print(" [dim]--on-conflict keep-local[/dim] keep your local version")
246+
console.print(
247+
" [dim]--on-conflict keep-both[/dim] keep both (writes <name>.conflict-<date>)"
248+
)
249+
250+
251+
def _run_directional_transfer(
252+
name: str,
253+
direction: TransferDirection,
254+
*,
255+
on_conflict: ConflictStrategy,
256+
dry_run: bool,
257+
verbose: bool,
258+
) -> None:
259+
"""Shared orchestration for `bm cloud push` / `bm cloud pull`.
260+
261+
Detects conflicts first, then aborts (the default) or applies the chosen
262+
resolution. Uses additive `rclone copy`, so it never deletes on the
263+
destination — safe for Team workspaces and therefore not gated.
264+
"""
265+
config = ConfigManager().config
266+
_require_cloud_credentials(config)
267+
268+
try:
269+
# Get tenant info for bucket name
270+
tenant_info = run_with_cleanup(get_mount_info())
271+
bucket_name = tenant_info.bucket_name
272+
273+
# Get project info
274+
with force_routing(cloud=True):
275+
project_data = run_with_cleanup(_get_cloud_project(name))
276+
if not project_data:
277+
console.print(f"[red]Error: Project '{name}' not found[/red]")
278+
raise typer.Exit(1)
279+
280+
sync_project, _ = _get_sync_project(name, config, project_data)
281+
282+
# --- Detect before transferring ---
283+
plan = project_diff(sync_project, bucket_name, direction)
284+
285+
# Trigger: rclone could not read/hash some files.
286+
# Why: comparing is the whole basis for a safe transfer — never guess.
287+
# Outcome: abort before moving any bytes.
288+
if plan.errors:
289+
console.print(
290+
f"[red]{direction} aborted: rclone could not compare "
291+
f"{len(plan.errors)} file(s)[/red]"
292+
)
293+
for path in plan.errors:
294+
console.print(f" [red]![/red] {path}")
295+
raise typer.Exit(1)
296+
297+
# Trigger: files differ on both sides and the user chose no resolution.
298+
# Why: "no surprises" — never silently pick a winner.
299+
# Outcome: list the conflicts and exit, like git refusing to clobber.
300+
if plan.conflicts and on_conflict is ConflictStrategy.fail:
301+
_print_conflict_abort(name, direction, plan)
302+
raise typer.Exit(1)
303+
304+
# --- Transfer ---
305+
arrow = "cloud -> local" if direction == "pull" else "local -> cloud"
306+
console.print(f"[blue]{direction.capitalize()} {name} ({arrow})...[/blue]")
307+
308+
conflict_suffix = datetime.now().strftime("%Y%m%d-%H%M%S")
309+
success = project_transfer(
310+
sync_project,
311+
bucket_name,
312+
direction,
313+
plan,
314+
strategy=on_conflict.value,
315+
conflict_suffix=conflict_suffix,
316+
dry_run=dry_run,
317+
verbose=verbose,
318+
)
319+
320+
if not success:
321+
console.print(f"[red]{name} {direction} failed[/red]")
322+
raise typer.Exit(1)
323+
324+
console.print(f"[green]{name} {direction} completed successfully[/green]")
325+
326+
# Without a sync baseline (see #862) we cannot tell an intentional delete
327+
# from a file the other side simply never had, so deletions never sync.
328+
if plan.dest_only:
329+
kept_on = "local" if direction == "pull" else "cloud"
330+
console.print(
331+
f"[dim]{len(plan.dest_only)} file(s) exist only on {kept_on} and were left "
332+
"untouched (deletions are not propagated).[/dim]"
333+
)
334+
335+
except RcloneError as e:
336+
console.print(f"[red]{direction.capitalize()} error: {e}[/red]")
337+
raise typer.Exit(1)
338+
except typer.Exit:
339+
# Already-handled exits (not found, conflicts, errors) propagate cleanly.
340+
raise
341+
except Exception as e:
342+
console.print(f"[red]Error: {e}[/red]")
343+
raise typer.Exit(1)
344+
345+
346+
@cloud_app.command("pull")
347+
def pull_project_command(
348+
name: str = typer.Option(..., "--name", "--project", help="Project name to pull"),
349+
on_conflict: ConflictStrategy = typer.Option(
350+
ConflictStrategy.fail,
351+
"--on-conflict",
352+
help="Resolve files that differ on both sides (default: fail and list them)",
353+
),
354+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without pulling"),
355+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
356+
) -> None:
357+
"""Fetch cloud changes into local (cloud -> local), git-pull style.
358+
359+
Additive and Team-safe: downloads new/changed cloud files and never deletes
360+
local files. A file that differs on both sides is a conflict; by default
361+
pull aborts and lists them. Deletions are not propagated (see #862).
362+
363+
Examples:
364+
bm cloud pull --name research
365+
bm cloud pull --name research --dry-run
366+
bm cloud pull --name research --on-conflict keep-cloud
367+
"""
368+
_run_directional_transfer(
369+
name, "pull", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose
370+
)
371+
372+
373+
@cloud_app.command("push")
374+
def push_project_command(
375+
name: str = typer.Option(..., "--name", "--project", help="Project name to push"),
376+
on_conflict: ConflictStrategy = typer.Option(
377+
ConflictStrategy.fail,
378+
"--on-conflict",
379+
help="Resolve files that differ on both sides (default: fail and list them)",
380+
),
381+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without pushing"),
382+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
383+
) -> None:
384+
"""Upload local changes to cloud (local -> cloud), additive and Team-safe.
385+
386+
Uploads new/changed local files and never deletes cloud files. A file that
387+
differs on both sides is a conflict; by default push aborts and lists them
388+
(like git rejecting a push when the remote is ahead — pull first). Deletions
389+
are not propagated (see #862).
390+
391+
Examples:
392+
bm cloud push --name research
393+
bm cloud push --name research --dry-run
394+
bm cloud push --name research --on-conflict keep-local
395+
"""
396+
_run_directional_transfer(
397+
name, "push", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose
398+
)
399+
400+
194401
@cloud_app.command("bisync")
195402
def bisync_project_command(
196403
name: str = typer.Option(..., "--name", "--project", help="Project name to bisync"),

0 commit comments

Comments
 (0)