Skip to content

Commit 2e90389

Browse files
committed
feat: Add --dry-run option to cloud upload command
Allows users to preview what files would be uploaded without actually uploading them. Useful for: - Checking which files will be uploaded before committing - Verifying ignore patterns are working correctly - Estimating upload size and file count Features: - Shows list of files that would be uploaded with their sizes - Works with --verbose to show detailed filtering info - Displays total file count and size - Skips actual upload and sync when enabled Usage: bm cloud upload ./files --project test --dry-run bm cloud upload ./notes --project work --dry-run --verbose Includes comprehensive test coverage for dry-run functionality.
1 parent 2d4fc71 commit 2e90389

3 files changed

Lines changed: 107 additions & 28 deletions

File tree

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

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212

1313

1414
async def upload_path(
15-
local_path: Path, project_name: str, verbose: bool = False, use_gitignore: bool = True
15+
local_path: Path,
16+
project_name: str,
17+
verbose: bool = False,
18+
use_gitignore: bool = True,
19+
dry_run: bool = False,
1620
) -> bool:
1721
"""
1822
Upload a file or directory to cloud project via WebDAV.
@@ -22,6 +26,7 @@ async def upload_path(
2226
project_name: Name of cloud project (destination)
2327
verbose: Show detailed information about filtering and upload
2428
use_gitignore: If False, skip .gitignore patterns (still use .bmignore)
29+
dry_run: If True, show what would be uploaded without uploading
2530
2631
Returns:
2732
True if upload succeeded, False otherwise
@@ -54,34 +59,50 @@ async def upload_path(
5459

5560
print(f"Found {len(files_to_upload)} file(s) to upload")
5661

57-
# Upload files using httpx
58-
total_bytes = 0
59-
60-
async with get_client() as client:
61-
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
62-
# Build remote path: /webdav/{project_name}/{relative_path}
63-
remote_path = f"/webdav/{project_name}/{relative_path}"
64-
print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")
65-
66-
# Read file content asynchronously
67-
async with aiofiles.open(file_path, "rb") as f:
68-
content = await f.read()
69-
70-
# Upload via HTTP PUT to WebDAV endpoint
71-
response = await call_put(client, remote_path, content=content)
72-
response.raise_for_status()
73-
74-
total_bytes += file_path.stat().st_size
75-
76-
# Format size based on magnitude
62+
# Calculate total size
63+
total_bytes = sum(file_path.stat().st_size for file_path, _ in files_to_upload)
64+
65+
# If dry run, just show what would be uploaded
66+
if dry_run:
67+
print("\nFiles that would be uploaded:")
68+
for file_path, relative_path in files_to_upload:
69+
size = file_path.stat().st_size
70+
if size < 1024:
71+
size_str = f"{size} bytes"
72+
elif size < 1024 * 1024:
73+
size_str = f"{size / 1024:.1f} KB"
74+
else:
75+
size_str = f"{size / (1024 * 1024):.1f} MB"
76+
print(f" {relative_path} ({size_str})")
77+
else:
78+
# Upload files using httpx
79+
async with get_client() as client:
80+
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
81+
# Build remote path: /webdav/{project_name}/{relative_path}
82+
remote_path = f"/webdav/{project_name}/{relative_path}"
83+
print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")
84+
85+
# Read file content asynchronously
86+
async with aiofiles.open(file_path, "rb") as f:
87+
content = await f.read()
88+
89+
# Upload via HTTP PUT to WebDAV endpoint
90+
response = await call_put(client, remote_path, content=content)
91+
response.raise_for_status()
92+
93+
# Format total size based on magnitude
7794
if total_bytes < 1024:
7895
size_str = f"{total_bytes} bytes"
7996
elif total_bytes < 1024 * 1024:
8097
size_str = f"{total_bytes / 1024:.1f} KB"
8198
else:
8299
size_str = f"{total_bytes / (1024 * 1024):.1f} MB"
83100

84-
print(f"✓ Upload complete: {len(files_to_upload)} file(s) ({size_str})")
101+
if dry_run:
102+
print(f"\nTotal: {len(files_to_upload)} file(s) ({size_str})")
103+
else:
104+
print(f"✓ Upload complete: {len(files_to_upload)} file(s) ({size_str})")
105+
85106
return True
86107

87108
except httpx.HTTPStatusError as e:

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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ def upload(
5454
"--no-gitignore",
5555
help="Skip .gitignore patterns (still respects .bmignore)",
5656
),
57+
dry_run: bool = typer.Option(
58+
False,
59+
"--dry-run",
60+
help="Show what would be uploaded without actually uploading",
61+
),
5762
) -> None:
5863
"""Upload local files or directories to cloud project via WebDAV.
5964
@@ -63,6 +68,7 @@ def upload(
6368
bm cloud upload ~/docs --project work --no-sync
6469
bm cloud upload ./history --project proto --verbose
6570
bm cloud upload ./notes --project work --no-gitignore
71+
bm cloud upload ./files --project test --dry-run
6672
"""
6773

6874
async def _upload():
@@ -85,19 +91,26 @@ async def _upload():
8591
)
8692
raise typer.Exit(1)
8793

88-
# Perform upload
89-
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
94+
# Perform upload (or dry run)
95+
if dry_run:
96+
console.print(f"[yellow]DRY RUN: Showing what would be uploaded to '{project}'[/yellow]")
97+
else:
98+
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
99+
90100
success = await upload_path(
91-
path, project, verbose=verbose, use_gitignore=not no_gitignore
101+
path, project, verbose=verbose, use_gitignore=not no_gitignore, dry_run=dry_run
92102
)
93103
if not success:
94104
console.print("[red]Upload failed[/red]")
95105
raise typer.Exit(1)
96106

97-
console.print(f"[green]✅ Successfully uploaded to '{project}'[/green]")
107+
if dry_run:
108+
console.print(f"[yellow]DRY RUN complete - no files were uploaded[/yellow]")
109+
else:
110+
console.print(f"[green]✅ Successfully uploaded to '{project}'[/green]")
98111

99-
# Sync project if requested
100-
if sync:
112+
# Sync project if requested (skip on dry run)
113+
if sync and not dry_run:
101114
console.print(f"[blue]Syncing project '{project}'...[/blue]")
102115
try:
103116
await sync_project(project)

tests/cli/test_upload.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,3 +414,48 @@ def test_wildcard_gitignore_filters_all_files(self, tmp_path):
414414
# With use_gitignore=False, should include files
415415
result = _get_files_to_upload(tmp_path, verbose=False, use_gitignore=False)
416416
assert len(result) == 2
417+
418+
@pytest.mark.asyncio
419+
async def test_dry_run_shows_files_without_uploading(self, tmp_path, capsys):
420+
"""Test that --dry-run shows what would be uploaded without uploading."""
421+
# Create test files
422+
(tmp_path / "file1.txt").write_text("content1")
423+
(tmp_path / "file2.txt").write_text("content2")
424+
425+
# Don't mock anything - we want to verify no actual upload happens
426+
result = await upload_path(tmp_path, "test-project", dry_run=True)
427+
428+
# Should return success
429+
assert result is True
430+
431+
# Check output shows dry run info
432+
captured = capsys.readouterr()
433+
assert "Found 2 file(s) to upload" in captured.out
434+
assert "Files that would be uploaded:" in captured.out
435+
assert "file1.txt" in captured.out
436+
assert "file2.txt" in captured.out
437+
assert "Total:" in captured.out
438+
439+
@pytest.mark.asyncio
440+
async def test_dry_run_with_verbose(self, tmp_path, capsys):
441+
"""Test that --dry-run works with --verbose."""
442+
# Create test files
443+
(tmp_path / "keep.txt").write_text("keep")
444+
(tmp_path / "ignore.pyc").write_text("ignore")
445+
446+
# Create .gitignore
447+
gitignore_file = tmp_path / ".gitignore"
448+
gitignore_file.write_text("*.pyc\n")
449+
450+
result = await upload_path(tmp_path, "test-project", verbose=True, dry_run=True)
451+
452+
# Should return success
453+
assert result is True
454+
455+
# Check output shows both verbose and dry run info
456+
captured = capsys.readouterr()
457+
assert "Scanning directory:" in captured.out
458+
assert "[INCLUDE] keep.txt" in captured.out
459+
assert "[IGNORED] ignore.pyc" in captured.out
460+
assert "Files that would be uploaded:" in captured.out
461+
assert "keep.txt" in captured.out

0 commit comments

Comments
 (0)