diff --git a/src/basic_memory/cli/commands/status.py b/src/basic_memory/cli/commands/status.py index 24c0d552..3f1a076b 100644 --- a/src/basic_memory/cli/commands/status.py +++ b/src/basic_memory/cli/commands/status.py @@ -67,8 +67,19 @@ def group_changes_by_directory(changes: SyncReport) -> Dict[str, Dict[str, int]] ]: for path in paths: dir_name = path.split("/", 1)[0] - by_dir.setdefault(dir_name, {"new": 0, "modified": 0, "deleted": 0}) + by_dir.setdefault(dir_name, {"new": 0, "modified": 0, "deleted": 0, "moved": 0}) by_dir[dir_name][change_type] += 1 + + # Handle moves - count in both source and destination directories + for old_path, new_path in changes.moves.items(): + old_dir = old_path.split("/", 1)[0] + new_dir = new_path.split("/", 1)[0] + by_dir.setdefault(old_dir, {"new": 0, "modified": 0, "deleted": 0, "moved": 0}) + by_dir.setdefault(new_dir, {"new": 0, "modified": 0, "deleted": 0, "moved": 0}) + by_dir[old_dir]["moved"] += 1 + if old_dir != new_dir: + by_dir[new_dir]["moved"] += 1 + return by_dir @@ -79,6 +90,8 @@ def build_directory_summary(counts: Dict[str, int]) -> str: parts.append(f"[green]+{counts['new']} new[/green]") if counts["modified"]: parts.append(f"[yellow]~{counts['modified']} modified[/yellow]") + if counts["moved"]: + parts.append(f"[blue]↔{counts['moved']} moved[/blue]") if counts["deleted"]: parts.append(f"[red]-{counts['deleted']} deleted[/red]") return " ".join(parts) @@ -101,6 +114,10 @@ def display_changes(title: str, changes: SyncReport, verbose: bool = False): if changes.modified: mod_branch = tree.add("[yellow]Modified[/yellow]") add_files_to_tree(mod_branch, changes.modified, "yellow", changes.checksums) + if changes.moves: + move_branch = tree.add("[blue]Moved[/blue]") + for old_path, new_path in sorted(changes.moves.items()): + move_branch.add(f"[blue]{old_path}[/blue] → [blue]{new_path}[/blue]") if changes.deleted: del_branch = tree.add("[red]Deleted[/red]") add_files_to_tree(del_branch, changes.deleted, "red") diff --git a/src/basic_memory/cli/commands/sync.py b/src/basic_memory/cli/commands/sync.py index 30418321..655cb105 100644 --- a/src/basic_memory/cli/commands/sync.py +++ b/src/basic_memory/cli/commands/sync.py @@ -25,6 +25,7 @@ from basic_memory.repository import ( RelationRepository, ) from basic_memory.repository.search_repository import SearchRepository +from basic_memory.services.link_resolver import LinkResolver from basic_memory.services.search_service import SearchService from basic_memory.sync import SyncService, FileChangeScanner, EntitySyncService from basic_memory.sync.utils import SyncReport @@ -51,21 +52,28 @@ async def get_sync_service(db_type=DatabaseType.FILESYSTEM): relation_repository = RelationRepository(session_maker) search_repository = SearchRepository(session_maker) + # Initialize services + search_service = SearchService(search_repository, entity_repository) + link_resolver = LinkResolver(entity_repository, search_service) + # Initialize scanner file_change_scanner = FileChangeScanner(entity_repository) # Initialize services knowledge_sync_service = EntitySyncService( - entity_repository, observation_repository, relation_repository + entity_repository, + observation_repository, + relation_repository, + link_resolver ) entity_parser = EntityParser(config.home) - search_service = SearchService(search_repository, entity_repository) # Create sync service sync_service = SyncService( scanner=file_change_scanner, entity_sync_service=knowledge_sync_service, entity_parser=entity_parser, + entity_repository=entity_repository, search_service=search_service, ) @@ -135,16 +143,19 @@ def display_sync_summary(knowledge: SyncReport): console.print("[green]Everything up to date[/green]") return - # Format as: "Synced X files (A new, B modified, C deleted)" + # Format as: "Synced X files (A new, B modified, C moved, D deleted)" changes = [] new_count = len(knowledge.new) mod_count = len(knowledge.modified) + move_count = len(knowledge.moves) del_count = len(knowledge.deleted) if new_count: changes.append(f"[green]{new_count} new[/green]") if mod_count: changes.append(f"[yellow]{mod_count} modified[/yellow]") + if move_count: + changes.append(f"[blue]{move_count} moved[/blue]") if del_count: changes.append(f"[red]{del_count} deleted[/red]") @@ -171,13 +182,17 @@ def display_detailed_sync_results(knowledge: SyncReport): for path in sorted(knowledge.modified): checksum = knowledge.checksums.get(path, "") modified.add(f"[yellow]{path}[/yellow] ({checksum[:8]})") + if knowledge.moves: + moved = knowledge_tree.add("[blue]Moved[/blue]") + for old_path, new_path in sorted(knowledge.moves.items()): + checksum = knowledge.checksums.get(new_path, "") + moved.add(f"[blue]{old_path}[/blue] → [blue]{new_path}[/blue] ({checksum[:8]})") if knowledge.deleted: deleted = knowledge_tree.add("[red]Deleted[/red]") for path in sorted(knowledge.deleted): deleted.add(f"[red]{path}[/red]") console.print(knowledge_tree) - async def validate_knowledge_files( sync_service: SyncService, directory: Path ) -> List[ValidationIssue]: diff --git a/src/basic_memory/services/link_resolver.py b/src/basic_memory/services/link_resolver.py index 37683f92..2047fd5d 100644 --- a/src/basic_memory/services/link_resolver.py +++ b/src/basic_memory/services/link_resolver.py @@ -62,7 +62,6 @@ class LinkResolver(): return await self.entity_repository.get_by_permalink(best_match.permalink) - # TODO replace with search ranking def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]: """Normalize link text and extract alias if present. diff --git a/src/basic_memory/sync/file_change_scanner.py b/src/basic_memory/sync/file_change_scanner.py index 9bd90904..37ee61e5 100644 --- a/src/basic_memory/sync/file_change_scanner.py +++ b/src/basic_memory/sync/file_change_scanner.py @@ -87,18 +87,9 @@ class FileChangeScanner: return result async def find_changes( - self, directory: Path, db_file_state: Dict[str, FileState] + self, directory: Path, db_file_state: Dict[str, FileState] ) -> SyncReport: - """ - Find changes between filesystem and database. - - Args: - directory: Directory to check - db_file_state: dict mapping file_path to DbState(permalink, checksum) - - Returns: - SyncReport detailing changes - """ + """Find changes between filesystem and database.""" # Get current files and checksums scan_result = await self.scan_directory(directory) current_files = scan_result.files @@ -106,28 +97,41 @@ class FileChangeScanner: # Build report report = SyncReport() + # Track potentially moved files by checksum + files_by_checksum = {} # checksum -> (file_path, permalink) + # Find new and modified files for file_path, checksum in current_files.items(): logger.debug(f"{file_path} ({checksum[:8]})") if file_path not in db_file_state: report.new.add(file_path) + # Track new file's checksum for move detection + files_by_checksum[checksum] = file_path elif checksum != db_file_state[file_path].checksum: report.modified.add(file_path) report.checksums[file_path] = checksum - # Find deleted files - need to be deleted from db - # either not in current_files, or - # db row has no checksum + # Find deleted and moved files for db_file_path, db_state in db_file_state.items(): if db_file_path not in current_files: - report.deleted.add(db_file_path) + # Check if this file was moved by looking for same checksum + if db_state.checksum in files_by_checksum: + new_path = files_by_checksum[db_state.checksum] + # Found a move - file exists at new path with same checksum + report.moves[db_file_path] = new_path + # Remove from new files since it's a move + report.new.remove(new_path) + else: + # Actually deleted + report.deleted.add(db_file_path) # Log summary logger.debug(f"Changes found: {report.total_changes}") logger.debug(f" New: {len(report.new)}") logger.debug(f" Modified: {len(report.modified)}") + logger.debug(f" Moved: {len(report.moves)}") logger.debug(f" Deleted: {len(report.deleted)}") if scan_result.errors: diff --git a/src/basic_memory/sync/utils.py b/src/basic_memory/sync/utils.py index f25bc205..8276e226 100644 --- a/src/basic_memory/sync/utils.py +++ b/src/basic_memory/sync/utils.py @@ -12,14 +12,16 @@ class SyncReport: new: Files that exist on disk but not in database modified: Files that exist in both but have different checksums deleted: Files that exist in database but not on disk + moves: Files that have been moved from one location to another checksums: Current checksums for files on disk """ new: Set[str] = field(default_factory=set) modified: Set[str] = field(default_factory=set) deleted: Set[str] = field(default_factory=set) + moves: Dict[str, str] = field(default_factory=dict) # old_path -> new_path checksums: Dict[str, str] = field(default_factory=dict) @property def total_changes(self) -> int: """Total number of files that need attention.""" - return len(self.new) + len(self.modified) + len(self.deleted) + return len(self.new) + len(self.modified) + len(self.deleted) + len(self.moves) diff --git a/tests/sync/test_file_change_scanner.py b/tests/sync/test_file_change_scanner.py index 31e7a675..ddaf7574 100644 --- a/tests/sync/test_file_change_scanner.py +++ b/tests/sync/test_file_change_scanner.py @@ -143,3 +143,128 @@ async def test_empty_directory(file_change_scanner: FileChangeScanner, temp_dir: assert not changes.new assert not changes.modified assert not changes.deleted + + +@pytest.mark.asyncio +async def test_detect_moved_file(file_change_scanner: FileChangeScanner, temp_dir: Path): + """Test detection of file moves.""" + # Create original file + old_path = "original/test.md" + new_path = "new/location/test.md" + content = "test content" + + await create_test_file(temp_dir / old_path, content) + original_checksum = await compute_checksum(content) + + # Set up DB state with original location + db_records = { + old_path: FileState( + file_path=old_path, + permalink="test", + checksum=original_checksum + ) + } + + # Move file to new location + old_file = temp_dir / old_path + new_file = temp_dir / new_path + new_file.parent.mkdir(parents=True, exist_ok=True) + old_file.rename(new_file) + + # Check changes + changes = await file_change_scanner.find_changes( + directory=temp_dir, + db_file_state=db_records + ) + + # Should detect as move + assert len(changes.moves) == 1 + assert changes.moves[old_path] == new_path + # Should not be in new or deleted + assert old_path not in changes.new + assert old_path not in changes.deleted + assert new_path not in changes.new + + +@pytest.mark.asyncio +async def test_move_with_content_change(file_change_scanner: FileChangeScanner, temp_dir: Path): + """Test handling a file that is both moved and modified.""" + # Create original file + old_path = "original/test.md" + new_path = "new/location/test.md" + content = "original content" + + await create_test_file(temp_dir / old_path, content) + original_checksum = await compute_checksum(content) + + # Set up DB state with original location + db_records = { + old_path: FileState( + file_path=old_path, + permalink="test", + checksum=original_checksum + ) + } + + # Move file and change content + old_file = temp_dir / old_path + new_file = temp_dir / new_path + new_file.parent.mkdir(parents=True, exist_ok=True) + await create_test_file(new_file, "modified content") + old_file.unlink() + + # Check changes + changes = await file_change_scanner.find_changes( + directory=temp_dir, + db_file_state=db_records + ) + + # Should be treated as delete + new, not move + assert old_path in changes.deleted + assert new_path in changes.new + assert len(changes.moves) == 0 + + +@pytest.mark.asyncio +async def test_multiple_moves(file_change_scanner: FileChangeScanner, temp_dir: Path): + """Test detecting multiple file moves at once.""" + # Create original files + files = { + "a/test1.md": "content1", + "b/test2.md": "content2" + } + new_locations = { + "a/test1.md": "new/test1.md", + "b/test2.md": "new/nested/test2.md" + } + + db_records = {} + # Create files and DB state + for old_path, content in files.items(): + await create_test_file(temp_dir / old_path, content) + checksum = await compute_checksum(content) + db_records[old_path] = FileState( + file_path=old_path, + permalink=old_path.replace(".md", ""), + checksum=checksum + ) + + # Move all files + for old_path, new_path in new_locations.items(): + old_file = temp_dir / old_path + new_file = temp_dir / new_path + new_file.parent.mkdir(parents=True, exist_ok=True) + old_file.rename(new_file) + + # Check changes + changes = await file_change_scanner.find_changes( + directory=temp_dir, + db_file_state=db_records + ) + + # Should detect both moves + assert len(changes.moves) == 2 + assert changes.moves["a/test1.md"] == "new/test1.md" + assert changes.moves["b/test2.md"] == "new/nested/test2.md" + assert not changes.new + assert not changes.deleted \ No newline at end of file