Skip to content

Commit 7f9c1a9

Browse files
phernandezclaude
andauthored
feat: Add --verbose and --no-gitignore options to cloud upload (#362)
Signed-off-by: phernandez <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 53fb13b commit 7f9c1a9

File tree

4 files changed

+283
-54
lines changed

4 files changed

+283
-54
lines changed

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

Lines changed: 95 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,22 @@
1111
from basic_memory.mcp.tools.utils import call_put
1212

1313

14-
async def upload_path(local_path: Path, project_name: str) -> bool:
14+
async def upload_path(
15+
local_path: Path,
16+
project_name: str,
17+
verbose: bool = False,
18+
use_gitignore: bool = True,
19+
dry_run: bool = False,
20+
) -> bool:
1521
"""
1622
Upload a file or directory to cloud project via WebDAV.
1723
1824
Args:
1925
local_path: Path to local file or directory
2026
project_name: Name of cloud project (destination)
27+
verbose: Show detailed information about filtering and upload
28+
use_gitignore: If False, skip .gitignore patterns (still use .bmignore)
29+
dry_run: If True, show what would be uploaded without uploading
2130
2231
Returns:
2332
True if upload succeeded, False otherwise
@@ -34,43 +43,66 @@ async def upload_path(local_path: Path, project_name: str) -> bool:
3443
# Get files to upload
3544
if local_path.is_file():
3645
files_to_upload = [(local_path, local_path.name)]
46+
if verbose:
47+
print(f"Uploading single file: {local_path.name}")
3748
else:
38-
files_to_upload = _get_files_to_upload(local_path)
49+
files_to_upload = _get_files_to_upload(local_path, verbose, use_gitignore)
3950

4051
if not files_to_upload:
4152
print("No files found to upload")
53+
if verbose:
54+
print(
55+
"\nTip: Use --verbose to see which files are being filtered, "
56+
"or --no-gitignore to skip .gitignore patterns"
57+
)
4258
return True
4359

4460
print(f"Found {len(files_to_upload)} file(s) to upload")
4561

46-
# Upload files using httpx
47-
total_bytes = 0
48-
49-
async with get_client() as client:
50-
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
51-
# Build remote path: /webdav/{project_name}/{relative_path}
52-
remote_path = f"/webdav/{project_name}/{relative_path}"
53-
print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")
54-
55-
# Read file content asynchronously
56-
async with aiofiles.open(file_path, "rb") as f:
57-
content = await f.read()
58-
59-
# Upload via HTTP PUT to WebDAV endpoint
60-
response = await call_put(client, remote_path, content=content)
61-
response.raise_for_status()
62-
63-
total_bytes += file_path.stat().st_size
64-
65-
# 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
6694
if total_bytes < 1024:
6795
size_str = f"{total_bytes} bytes"
6896
elif total_bytes < 1024 * 1024:
6997
size_str = f"{total_bytes / 1024:.1f} KB"
7098
else:
7199
size_str = f"{total_bytes / (1024 * 1024):.1f} MB"
72100

73-
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+
74106
return True
75107

76108
except httpx.HTTPStatusError as e:
@@ -81,22 +113,38 @@ async def upload_path(local_path: Path, project_name: str) -> bool:
81113
return False
82114

83115

84-
def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
116+
def _get_files_to_upload(
117+
directory: Path, verbose: bool = False, use_gitignore: bool = True
118+
) -> list[tuple[Path, str]]:
85119
"""
86120
Get list of files to upload from directory.
87121
88-
Uses .bmignore and .gitignore patterns for filtering.
122+
Uses .bmignore and optionally .gitignore patterns for filtering.
89123
90124
Args:
91125
directory: Directory to scan
126+
verbose: Show detailed filtering information
127+
use_gitignore: If False, skip .gitignore patterns (still use .bmignore)
92128
93129
Returns:
94130
List of (absolute_path, relative_path) tuples
95131
"""
96132
files = []
97-
98-
# Load ignore patterns from .bmignore and .gitignore
99-
ignore_patterns = load_gitignore_patterns(directory)
133+
ignored_files = []
134+
135+
# Load ignore patterns from .bmignore and optionally .gitignore
136+
ignore_patterns = load_gitignore_patterns(directory, use_gitignore=use_gitignore)
137+
138+
if verbose:
139+
gitignore_path = directory / ".gitignore"
140+
gitignore_exists = gitignore_path.exists() and use_gitignore
141+
print(f"\nScanning directory: {directory}")
142+
print("Using .bmignore: Yes")
143+
print(f"Using .gitignore: {'Yes' if gitignore_exists else 'No'}")
144+
print(f"Ignore patterns loaded: {len(ignore_patterns)}")
145+
if ignore_patterns and len(ignore_patterns) <= 20:
146+
print(f"Patterns: {', '.join(sorted(ignore_patterns))}")
147+
print()
100148

101149
# Walk through directory
102150
for root, dirs, filenames in os.walk(directory):
@@ -106,23 +154,37 @@ def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
106154
filtered_dirs = []
107155
for d in dirs:
108156
dir_path = root_path / d
109-
if not should_ignore_path(dir_path, directory, ignore_patterns):
157+
if should_ignore_path(dir_path, directory, ignore_patterns):
158+
if verbose:
159+
rel_path = dir_path.relative_to(directory)
160+
print(f" [IGNORED DIR] {rel_path}/")
161+
else:
110162
filtered_dirs.append(d)
111163
dirs[:] = filtered_dirs
112164

113165
# Process files
114166
for filename in filenames:
115167
file_path = root_path / filename
116168

169+
# Calculate relative path for display/remote
170+
rel_path = file_path.relative_to(directory)
171+
remote_path = str(rel_path).replace("\\", "/")
172+
117173
# Check if file should be ignored
118174
if should_ignore_path(file_path, directory, ignore_patterns):
175+
ignored_files.append(remote_path)
176+
if verbose:
177+
print(f" [IGNORED] {remote_path}")
119178
continue
120179

121-
# Calculate relative path for remote
122-
rel_path = file_path.relative_to(directory)
123-
# Use forward slashes for WebDAV paths
124-
remote_path = str(rel_path).replace("\\", "/")
180+
if verbose:
181+
print(f" [INCLUDE] {remote_path}")
125182

126183
files.append((file_path, remote_path))
127184

185+
if verbose:
186+
print("\nSummary:")
187+
print(f" Files to upload: {len(files)}")
188+
print(f" Files ignored: {len(ignored_files)}")
189+
128190
return files

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

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,32 @@ def upload(
4343
"--sync/--no-sync",
4444
help="Sync project after upload (default: true)",
4545
),
46+
verbose: bool = typer.Option(
47+
False,
48+
"--verbose",
49+
"-v",
50+
help="Show detailed information about file filtering and upload",
51+
),
52+
no_gitignore: bool = typer.Option(
53+
False,
54+
"--no-gitignore",
55+
help="Skip .gitignore patterns (still respects .bmignore)",
56+
),
57+
dry_run: bool = typer.Option(
58+
False,
59+
"--dry-run",
60+
help="Show what would be uploaded without actually uploading",
61+
),
4662
) -> None:
4763
"""Upload local files or directories to cloud project via WebDAV.
4864
4965
Examples:
5066
bm cloud upload ~/my-notes --project research
5167
bm cloud upload notes.md --project research --create-project
5268
bm cloud upload ~/docs --project work --no-sync
69+
bm cloud upload ./history --project proto --verbose
70+
bm cloud upload ./notes --project work --no-gitignore
71+
bm cloud upload ./files --project test --dry-run
5372
"""
5473

5574
async def _upload():
@@ -72,17 +91,28 @@ async def _upload():
7291
)
7392
raise typer.Exit(1)
7493

75-
# Perform upload
76-
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
77-
success = await upload_path(path, project)
94+
# Perform upload (or dry run)
95+
if dry_run:
96+
console.print(
97+
f"[yellow]DRY RUN: Showing what would be uploaded to '{project}'[/yellow]"
98+
)
99+
else:
100+
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
101+
102+
success = await upload_path(
103+
path, project, verbose=verbose, use_gitignore=not no_gitignore, dry_run=dry_run
104+
)
78105
if not success:
79106
console.print("[red]Upload failed[/red]")
80107
raise typer.Exit(1)
81108

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

84-
# Sync project if requested
85-
if sync:
114+
# Sync project if requested (skip on dry run)
115+
if sync and not dry_run:
86116
console.print(f"[blue]Syncing project '{project}'...[/blue]")
87117
try:
88118
await sync_project(project)

src/basic_memory/ignore_utils.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -172,34 +172,36 @@ def load_bmignore_patterns() -> Set[str]:
172172
return patterns
173173

174174

175-
def load_gitignore_patterns(base_path: Path) -> Set[str]:
175+
def load_gitignore_patterns(base_path: Path, use_gitignore: bool = True) -> Set[str]:
176176
"""Load gitignore patterns from .gitignore file and .bmignore.
177177
178178
Combines patterns from:
179179
1. ~/.basic-memory/.bmignore (user's global ignore patterns)
180-
2. {base_path}/.gitignore (project-specific patterns)
180+
2. {base_path}/.gitignore (project-specific patterns, if use_gitignore=True)
181181
182182
Args:
183183
base_path: The base directory to search for .gitignore file
184+
use_gitignore: If False, only load patterns from .bmignore (default: True)
184185
185186
Returns:
186187
Set of patterns to ignore
187188
"""
188189
# Start with patterns from .bmignore
189190
patterns = load_bmignore_patterns()
190191

191-
gitignore_file = base_path / ".gitignore"
192-
if gitignore_file.exists():
193-
try:
194-
with gitignore_file.open("r", encoding="utf-8") as f:
195-
for line in f:
196-
line = line.strip()
197-
# Skip empty lines and comments
198-
if line and not line.startswith("#"):
199-
patterns.add(line)
200-
except Exception:
201-
# If we can't read .gitignore, just use default patterns
202-
pass
192+
if use_gitignore:
193+
gitignore_file = base_path / ".gitignore"
194+
if gitignore_file.exists():
195+
try:
196+
with gitignore_file.open("r", encoding="utf-8") as f:
197+
for line in f:
198+
line = line.strip()
199+
# Skip empty lines and comments
200+
if line and not line.startswith("#"):
201+
patterns.add(line)
202+
except Exception:
203+
# If we can't read .gitignore, just use default patterns
204+
pass
203205

204206
return patterns
205207

0 commit comments

Comments
 (0)