|
7 | 7 |
|
8 | 8 | import os |
9 | 9 | from datetime import datetime |
| 10 | +from enum import Enum |
10 | 11 |
|
11 | 12 | import typer |
12 | 13 | from rich.console import Console |
|
16 | 17 | from basic_memory.cli.commands.cloud.rclone_commands import ( |
17 | 18 | RcloneError, |
18 | 19 | SyncProject, |
| 20 | + TransferDirection, |
| 21 | + TransferPlan, |
19 | 22 | get_project_bisync_state, |
20 | 23 | project_bisync, |
21 | 24 | project_check, |
| 25 | + project_diff, |
22 | 26 | project_sync, |
| 27 | + project_transfer, |
23 | 28 | ) |
24 | 29 | from basic_memory.cli.commands.command_utils import run_with_cleanup |
25 | 30 | from basic_memory.cli.commands.routing import force_routing |
|
35 | 40 |
|
36 | 41 | TEAM_WORKSPACE_BISYNC_UNSUPPORTED = ( |
37 | 42 | "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." |
39 | 44 | ) |
40 | 45 |
|
| 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 | + |
41 | 67 |
|
42 | 68 | # --- Shared helpers --- |
43 | 69 |
|
@@ -92,16 +118,26 @@ async def _get_workspace_for_project(name: str, config: BasicMemoryConfig) -> Wo |
92 | 118 | ) |
93 | 119 |
|
94 | 120 |
|
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 | + """ |
97 | 133 | try: |
98 | 134 | workspace = run_with_cleanup(_get_workspace_for_project(name, config)) |
99 | 135 | except Exception as exc: |
100 | 136 | console.print(f"[red]Error resolving workspace for project '{name}': {exc}[/red]") |
101 | 137 | raise typer.Exit(1) |
102 | 138 |
|
103 | 139 | 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]") |
105 | 141 | raise typer.Exit(1) |
106 | 142 |
|
107 | 143 | return workspace |
@@ -150,14 +186,19 @@ def sync_project_command( |
150 | 186 | dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"), |
151 | 187 | verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"), |
152 | 188 | ) -> 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. |
154 | 194 |
|
155 | 195 | Example: |
156 | 196 | bm cloud sync --name research |
157 | 197 | bm cloud sync --name research --dry-run |
158 | 198 | """ |
159 | 199 | config = ConfigManager().config |
160 | 200 | _require_cloud_credentials(config) |
| 201 | + _require_personal_workspace(name, config, unsupported_message=TEAM_WORKSPACE_SYNC_UNSUPPORTED) |
161 | 202 |
|
162 | 203 | try: |
163 | 204 | # Get tenant info for bucket name |
@@ -191,6 +232,172 @@ def sync_project_command( |
191 | 232 | raise typer.Exit(1) |
192 | 233 |
|
193 | 234 |
|
| 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 | + |
194 | 401 | @cloud_app.command("bisync") |
195 | 402 | def bisync_project_command( |
196 | 403 | name: str = typer.Option(..., "--name", "--project", help="Project name to bisync"), |
|
0 commit comments