Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 95 additions & 33 deletions src/basic_memory/cli/commands/cloud/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,22 @@
from basic_memory.mcp.tools.utils import call_put


async def upload_path(local_path: Path, project_name: str) -> bool:
async def upload_path(
local_path: Path,
project_name: str,
verbose: bool = False,
use_gitignore: bool = True,
dry_run: bool = False,
) -> bool:
"""
Upload a file or directory to cloud project via WebDAV.

Args:
local_path: Path to local file or directory
project_name: Name of cloud project (destination)
verbose: Show detailed information about filtering and upload
use_gitignore: If False, skip .gitignore patterns (still use .bmignore)
dry_run: If True, show what would be uploaded without uploading

Returns:
True if upload succeeded, False otherwise
Expand All @@ -34,43 +43,66 @@ async def upload_path(local_path: Path, project_name: str) -> bool:
# Get files to upload
if local_path.is_file():
files_to_upload = [(local_path, local_path.name)]
if verbose:
print(f"Uploading single file: {local_path.name}")
else:
files_to_upload = _get_files_to_upload(local_path)
files_to_upload = _get_files_to_upload(local_path, verbose, use_gitignore)

if not files_to_upload:
print("No files found to upload")
if verbose:
print(
"\nTip: Use --verbose to see which files are being filtered, "
"or --no-gitignore to skip .gitignore patterns"
)
return True

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

# Upload files using httpx
total_bytes = 0

async with get_client() as client:
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
# Build remote path: /webdav/{project_name}/{relative_path}
remote_path = f"/webdav/{project_name}/{relative_path}"
print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")

# Read file content asynchronously
async with aiofiles.open(file_path, "rb") as f:
content = await f.read()

# Upload via HTTP PUT to WebDAV endpoint
response = await call_put(client, remote_path, content=content)
response.raise_for_status()

total_bytes += file_path.stat().st_size

# Format size based on magnitude
# Calculate total size
total_bytes = sum(file_path.stat().st_size for file_path, _ in files_to_upload)

# If dry run, just show what would be uploaded
if dry_run:
print("\nFiles that would be uploaded:")
for file_path, relative_path in files_to_upload:
size = file_path.stat().st_size
if size < 1024:
size_str = f"{size} bytes"
elif size < 1024 * 1024:
size_str = f"{size / 1024:.1f} KB"
else:
size_str = f"{size / (1024 * 1024):.1f} MB"
print(f" {relative_path} ({size_str})")
else:
# Upload files using httpx
async with get_client() as client:
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
# Build remote path: /webdav/{project_name}/{relative_path}
remote_path = f"/webdav/{project_name}/{relative_path}"
print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")

# Read file content asynchronously
async with aiofiles.open(file_path, "rb") as f:
content = await f.read()

# Upload via HTTP PUT to WebDAV endpoint
response = await call_put(client, remote_path, content=content)
response.raise_for_status()

# Format total size based on magnitude
if total_bytes < 1024:
size_str = f"{total_bytes} bytes"
elif total_bytes < 1024 * 1024:
size_str = f"{total_bytes / 1024:.1f} KB"
else:
size_str = f"{total_bytes / (1024 * 1024):.1f} MB"

print(f"✓ Upload complete: {len(files_to_upload)} file(s) ({size_str})")
if dry_run:
print(f"\nTotal: {len(files_to_upload)} file(s) ({size_str})")
else:
print(f"✓ Upload complete: {len(files_to_upload)} file(s) ({size_str})")

return True

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


def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
def _get_files_to_upload(
directory: Path, verbose: bool = False, use_gitignore: bool = True
) -> list[tuple[Path, str]]:
"""
Get list of files to upload from directory.

Uses .bmignore and .gitignore patterns for filtering.
Uses .bmignore and optionally .gitignore patterns for filtering.

Args:
directory: Directory to scan
verbose: Show detailed filtering information
use_gitignore: If False, skip .gitignore patterns (still use .bmignore)

Returns:
List of (absolute_path, relative_path) tuples
"""
files = []

# Load ignore patterns from .bmignore and .gitignore
ignore_patterns = load_gitignore_patterns(directory)
ignored_files = []

# Load ignore patterns from .bmignore and optionally .gitignore
ignore_patterns = load_gitignore_patterns(directory, use_gitignore=use_gitignore)

if verbose:
gitignore_path = directory / ".gitignore"
gitignore_exists = gitignore_path.exists() and use_gitignore
print(f"\nScanning directory: {directory}")
print("Using .bmignore: Yes")
print(f"Using .gitignore: {'Yes' if gitignore_exists else 'No'}")
print(f"Ignore patterns loaded: {len(ignore_patterns)}")
if ignore_patterns and len(ignore_patterns) <= 20:
print(f"Patterns: {', '.join(sorted(ignore_patterns))}")
print()

# Walk through directory
for root, dirs, filenames in os.walk(directory):
Expand All @@ -106,23 +154,37 @@ def _get_files_to_upload(directory: Path) -> list[tuple[Path, str]]:
filtered_dirs = []
for d in dirs:
dir_path = root_path / d
if not should_ignore_path(dir_path, directory, ignore_patterns):
if should_ignore_path(dir_path, directory, ignore_patterns):
if verbose:
rel_path = dir_path.relative_to(directory)
print(f" [IGNORED DIR] {rel_path}/")
else:
filtered_dirs.append(d)
dirs[:] = filtered_dirs

# Process files
for filename in filenames:
file_path = root_path / filename

# Calculate relative path for display/remote
rel_path = file_path.relative_to(directory)
remote_path = str(rel_path).replace("\\", "/")

# Check if file should be ignored
if should_ignore_path(file_path, directory, ignore_patterns):
ignored_files.append(remote_path)
if verbose:
print(f" [IGNORED] {remote_path}")
continue

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

files.append((file_path, remote_path))

if verbose:
print("\nSummary:")
print(f" Files to upload: {len(files)}")
print(f" Files ignored: {len(ignored_files)}")

return files
42 changes: 36 additions & 6 deletions src/basic_memory/cli/commands/cloud/upload_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,32 @@ def upload(
"--sync/--no-sync",
help="Sync project after upload (default: true)",
),
verbose: bool = typer.Option(
False,
"--verbose",
"-v",
help="Show detailed information about file filtering and upload",
),
no_gitignore: bool = typer.Option(
False,
"--no-gitignore",
help="Skip .gitignore patterns (still respects .bmignore)",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Show what would be uploaded without actually uploading",
),
) -> None:
"""Upload local files or directories to cloud project via WebDAV.

Examples:
bm cloud upload ~/my-notes --project research
bm cloud upload notes.md --project research --create-project
bm cloud upload ~/docs --project work --no-sync
bm cloud upload ./history --project proto --verbose
bm cloud upload ./notes --project work --no-gitignore
bm cloud upload ./files --project test --dry-run
"""

async def _upload():
Expand All @@ -72,17 +91,28 @@ async def _upload():
)
raise typer.Exit(1)

# Perform upload
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")
success = await upload_path(path, project)
# Perform upload (or dry run)
if dry_run:
console.print(
f"[yellow]DRY RUN: Showing what would be uploaded to '{project}'[/yellow]"
)
else:
console.print(f"[blue]Uploading {path} to project '{project}'...[/blue]")

success = await upload_path(
path, project, verbose=verbose, use_gitignore=not no_gitignore, dry_run=dry_run
)
if not success:
console.print("[red]Upload failed[/red]")
raise typer.Exit(1)

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

# Sync project if requested
if sync:
# Sync project if requested (skip on dry run)
if sync and not dry_run:
console.print(f"[blue]Syncing project '{project}'...[/blue]")
try:
await sync_project(project)
Expand Down
30 changes: 16 additions & 14 deletions src/basic_memory/ignore_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,34 +172,36 @@ def load_bmignore_patterns() -> Set[str]:
return patterns


def load_gitignore_patterns(base_path: Path) -> Set[str]:
def load_gitignore_patterns(base_path: Path, use_gitignore: bool = True) -> Set[str]:
"""Load gitignore patterns from .gitignore file and .bmignore.

Combines patterns from:
1. ~/.basic-memory/.bmignore (user's global ignore patterns)
2. {base_path}/.gitignore (project-specific patterns)
2. {base_path}/.gitignore (project-specific patterns, if use_gitignore=True)

Args:
base_path: The base directory to search for .gitignore file
use_gitignore: If False, only load patterns from .bmignore (default: True)

Returns:
Set of patterns to ignore
"""
# Start with patterns from .bmignore
patterns = load_bmignore_patterns()

gitignore_file = base_path / ".gitignore"
if gitignore_file.exists():
try:
with gitignore_file.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if line and not line.startswith("#"):
patterns.add(line)
except Exception:
# If we can't read .gitignore, just use default patterns
pass
if use_gitignore:
gitignore_file = base_path / ".gitignore"
if gitignore_file.exists():
try:
with gitignore_file.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if line and not line.startswith("#"):
patterns.add(line)
except Exception:
# If we can't read .gitignore, just use default patterns
pass

return patterns

Expand Down
Loading
Loading