Skip to content

Commit e44f46f

Browse files
phernandezclaude
andcommitted
feat: Add --verbose and --no-gitignore options to cloud upload
Improves cloud upload diagnostics and user experience when files are filtered by ignore patterns. Changes: - Add --verbose flag to show detailed filtering information - Lists all files being scanned - Shows which files are ignored and which are included - Displays ignore patterns being used - Shows summary of files to upload vs ignored - Add --no-gitignore flag to skip .gitignore patterns - Still respects .bmignore patterns - Useful when uploading files that are normally gitignored - Enhanced ignore_utils.py: - Add use_gitignore parameter to load_gitignore_patterns() - Allows selective loading of ignore patterns - Comprehensive test coverage: - Test verbose output - Test --no-gitignore skips .gitignore patterns - Test --no-gitignore still respects .bmignore - Test wildcard patterns (reproduces user's issue) Fixes #361 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 994c8b8 commit e44f46f

4 files changed

Lines changed: 174 additions & 28 deletions

File tree

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

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@
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, project_name: str, verbose: bool = False, use_gitignore: bool = True
16+
) -> bool:
1517
"""
1618
Upload a file or directory to cloud project via WebDAV.
1719
1820
Args:
1921
local_path: Path to local file or directory
2022
project_name: Name of cloud project (destination)
23+
verbose: Show detailed information about filtering and upload
24+
use_gitignore: If False, skip .gitignore patterns (still use .bmignore)
2125
2226
Returns:
2327
True if upload succeeded, False otherwise
@@ -34,11 +38,18 @@ async def upload_path(local_path: Path, project_name: str) -> bool:
3438
# Get files to upload
3539
if local_path.is_file():
3640
files_to_upload = [(local_path, local_path.name)]
41+
if verbose:
42+
print(f"Uploading single file: {local_path.name}")
3743
else:
38-
files_to_upload = _get_files_to_upload(local_path)
44+
files_to_upload = _get_files_to_upload(local_path, verbose, use_gitignore)
3945

4046
if not files_to_upload:
4147
print("No files found to upload")
48+
if verbose:
49+
print(
50+
"\nTip: Use --verbose to see which files are being filtered, "
51+
"or --no-gitignore to skip .gitignore patterns"
52+
)
4253
return True
4354

4455
print(f"Found {len(files_to_upload)} file(s) to upload")
@@ -81,22 +92,38 @@ async def upload_path(local_path: Path, project_name: str) -> bool:
8192
return False
8293

8394

84-
def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
95+
def _get_files_to_upload(
96+
directory: Path, verbose: bool = False, use_gitignore: bool = True
97+
) -> list[tuple[Path, str]]:
8598
"""
8699
Get list of files to upload from directory.
87100
88-
Uses .bmignore and .gitignore patterns for filtering.
101+
Uses .bmignore and optionally .gitignore patterns for filtering.
89102
90103
Args:
91104
directory: Directory to scan
105+
verbose: Show detailed filtering information
106+
use_gitignore: If False, skip .gitignore patterns (still use .bmignore)
92107
93108
Returns:
94109
List of (absolute_path, relative_path) tuples
95110
"""
96111
files = []
97-
98-
# Load ignore patterns from .bmignore and .gitignore
99-
ignore_patterns = load_gitignore_patterns(directory)
112+
ignored_files = []
113+
114+
# Load ignore patterns from .bmignore and optionally .gitignore
115+
ignore_patterns = load_gitignore_patterns(directory, use_gitignore=use_gitignore)
116+
117+
if verbose:
118+
bmignore_path = directory / ".gitignore"
119+
gitignore_exists = bmignore_path.exists() and use_gitignore
120+
print(f"\nScanning directory: {directory}")
121+
print(f"Using .bmignore: Yes")
122+
print(f"Using .gitignore: {'Yes' if gitignore_exists else 'No'}")
123+
print(f"Ignore patterns loaded: {len(ignore_patterns)}")
124+
if ignore_patterns and len(ignore_patterns) <= 20:
125+
print(f"Patterns: {', '.join(sorted(ignore_patterns))}")
126+
print()
100127

101128
# Walk through directory
102129
for root, dirs, filenames in os.walk(directory):
@@ -106,23 +133,37 @@ def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
106133
filtered_dirs = []
107134
for d in dirs:
108135
dir_path = root_path / d
109-
if not should_ignore_path(dir_path, directory, ignore_patterns):
136+
if should_ignore_path(dir_path, directory, ignore_patterns):
137+
if verbose:
138+
rel_path = dir_path.relative_to(directory)
139+
print(f" [IGNORED DIR] {rel_path}/")
140+
else:
110141
filtered_dirs.append(d)
111142
dirs[:] = filtered_dirs
112143

113144
# Process files
114145
for filename in filenames:
115146
file_path = root_path / filename
116147

148+
# Calculate relative path for display/remote
149+
rel_path = file_path.relative_to(directory)
150+
remote_path = str(rel_path).replace("\\", "/")
151+
117152
# Check if file should be ignored
118153
if should_ignore_path(file_path, directory, ignore_patterns):
154+
ignored_files.append(remote_path)
155+
if verbose:
156+
print(f" [IGNORED] {remote_path}")
119157
continue
120158

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("\\", "/")
159+
if verbose:
160+
print(f" [INCLUDE] {remote_path}")
125161

126162
files.append((file_path, remote_path))
127163

164+
if verbose:
165+
print(f"\nSummary:")
166+
print(f" Files to upload: {len(files)}")
167+
print(f" Files ignored: {len(ignored_files)}")
168+
128169
return files

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,26 @@ 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+
),
4657
) -> None:
4758
"""Upload local files or directories to cloud project via WebDAV.
4859
4960
Examples:
5061
bm cloud upload ~/my-notes --project research
5162
bm cloud upload notes.md --project research --create-project
5263
bm cloud upload ~/docs --project work --no-sync
64+
bm cloud upload ./history --project proto --verbose
65+
bm cloud upload ./notes --project work --no-gitignore
5366
"""
5467

5568
async def _upload():
@@ -74,7 +87,9 @@ async def _upload():
7487

7588
# Perform upload
7689
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
77-
success = await upload_path(path, project)
90+
success = await upload_path(
91+
path, project, verbose=verbose, use_gitignore=not no_gitignore
92+
)
7893
if not success:
7994
console.print("[red]Upload failed[/red]")
8095
raise typer.Exit(1)

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

tests/cli/test_upload.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def test_collects_files_from_directory(self, tmp_path):
2020
(tmp_path / "subdir" / "file3.py").write_text("content3")
2121

2222
# Call with real ignore utils (no mocking)
23-
result = _get_files_to_upload(tmp_path)
23+
result = _get_files_to_upload(tmp_path, verbose=False, use_gitignore=True)
2424

2525
# Should find all 3 files
2626
assert len(result) == 3
@@ -326,3 +326,91 @@ async def test_builds_correct_webdav_path(self, tmp_path):
326326
mock_put.assert_called_once()
327327
call_args = mock_put.call_args
328328
assert call_args[0][1] == "/webdav/my-project/subdir/file.txt"
329+
330+
def test_no_gitignore_skips_gitignore_patterns(self, tmp_path):
331+
"""Test that --no-gitignore flag skips .gitignore patterns."""
332+
# Create test files
333+
(tmp_path / "keep.txt").write_text("keep")
334+
(tmp_path / "secret.bak").write_text("secret") # Use .bak instead of .pyc
335+
336+
# Create .gitignore file that ignores .bak files
337+
gitignore_file = tmp_path / ".gitignore"
338+
gitignore_file.write_text("*.bak\n")
339+
340+
# With use_gitignore=False, should include .bak files
341+
result = _get_files_to_upload(tmp_path, verbose=False, use_gitignore=False)
342+
343+
# Extract relative paths
344+
relative_paths = [rel_path for _, rel_path in result]
345+
346+
# Both files should be included when gitignore is disabled
347+
assert "keep.txt" in relative_paths
348+
assert "secret.bak" in relative_paths
349+
350+
def test_no_gitignore_still_respects_bmignore(self, tmp_path):
351+
"""Test that --no-gitignore still respects .bmignore patterns."""
352+
# Create test files
353+
(tmp_path / "keep.txt").write_text("keep")
354+
(tmp_path / ".hidden").write_text("hidden") # Should be ignored by .bmignore default pattern
355+
356+
# Create .gitignore that would allow .hidden
357+
gitignore_file = tmp_path / ".gitignore"
358+
gitignore_file.write_text("# Allow all\n")
359+
360+
# With use_gitignore=False, should still filter hidden files via .bmignore
361+
result = _get_files_to_upload(tmp_path, verbose=False, use_gitignore=False)
362+
363+
# Extract relative paths
364+
relative_paths = [rel_path for _, rel_path in result]
365+
366+
# keep.txt should be included, .hidden should be filtered by .bmignore
367+
assert "keep.txt" in relative_paths
368+
assert ".hidden" not in relative_paths
369+
370+
def test_verbose_shows_filtering_info(self, tmp_path, capsys):
371+
"""Test that verbose mode shows filtering information."""
372+
# Create test files
373+
(tmp_path / "keep.txt").write_text("keep")
374+
(tmp_path / "ignore.pyc").write_text("ignore")
375+
376+
# Create .gitignore
377+
gitignore_file = tmp_path / ".gitignore"
378+
gitignore_file.write_text("*.pyc\n")
379+
380+
# Run with verbose=True
381+
result = _get_files_to_upload(tmp_path, verbose=True, use_gitignore=True)
382+
383+
# Capture output
384+
captured = capsys.readouterr()
385+
386+
# Should show scanning information
387+
assert "Scanning directory:" in captured.out
388+
assert "Using .bmignore: Yes" in captured.out
389+
assert "Using .gitignore:" in captured.out
390+
assert "Ignore patterns loaded:" in captured.out
391+
392+
# Should show file status
393+
assert "[INCLUDE]" in captured.out or "[IGNORED]" in captured.out
394+
395+
# Should show summary
396+
assert "Summary:" in captured.out
397+
assert "Files to upload:" in captured.out
398+
assert "Files ignored:" in captured.out
399+
400+
def test_wildcard_gitignore_filters_all_files(self, tmp_path):
401+
"""Test that a wildcard * in .gitignore filters all files."""
402+
# Create test files
403+
(tmp_path / "file1.txt").write_text("content1")
404+
(tmp_path / "file2.md").write_text("content2")
405+
406+
# Create .gitignore with wildcard
407+
gitignore_file = tmp_path / ".gitignore"
408+
gitignore_file.write_text("*\n")
409+
410+
# Should filter all files
411+
result = _get_files_to_upload(tmp_path, verbose=False, use_gitignore=True)
412+
assert len(result) == 0
413+
414+
# With use_gitignore=False, should include files
415+
result = _get_files_to_upload(tmp_path, verbose=False, use_gitignore=False)
416+
assert len(result) == 2

0 commit comments

Comments
 (0)