basic-memory 0.17.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- basic_memory/__init__.py +7 -0
- basic_memory/alembic/alembic.ini +119 -0
- basic_memory/alembic/env.py +185 -0
- basic_memory/alembic/migrations.py +24 -0
- basic_memory/alembic/script.py.mako +26 -0
- basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
- basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
- basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
- basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
- basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
- basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
- basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
- basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
- basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
- basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
- basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
- basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
- basic_memory/api/__init__.py +5 -0
- basic_memory/api/app.py +131 -0
- basic_memory/api/routers/__init__.py +11 -0
- basic_memory/api/routers/directory_router.py +84 -0
- basic_memory/api/routers/importer_router.py +152 -0
- basic_memory/api/routers/knowledge_router.py +318 -0
- basic_memory/api/routers/management_router.py +80 -0
- basic_memory/api/routers/memory_router.py +90 -0
- basic_memory/api/routers/project_router.py +448 -0
- basic_memory/api/routers/prompt_router.py +260 -0
- basic_memory/api/routers/resource_router.py +249 -0
- basic_memory/api/routers/search_router.py +36 -0
- basic_memory/api/routers/utils.py +169 -0
- basic_memory/api/template_loader.py +292 -0
- basic_memory/api/v2/__init__.py +35 -0
- basic_memory/api/v2/routers/__init__.py +21 -0
- basic_memory/api/v2/routers/directory_router.py +93 -0
- basic_memory/api/v2/routers/importer_router.py +182 -0
- basic_memory/api/v2/routers/knowledge_router.py +413 -0
- basic_memory/api/v2/routers/memory_router.py +130 -0
- basic_memory/api/v2/routers/project_router.py +342 -0
- basic_memory/api/v2/routers/prompt_router.py +270 -0
- basic_memory/api/v2/routers/resource_router.py +286 -0
- basic_memory/api/v2/routers/search_router.py +73 -0
- basic_memory/cli/__init__.py +1 -0
- basic_memory/cli/app.py +84 -0
- basic_memory/cli/auth.py +277 -0
- basic_memory/cli/commands/__init__.py +18 -0
- basic_memory/cli/commands/cloud/__init__.py +6 -0
- basic_memory/cli/commands/cloud/api_client.py +112 -0
- basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
- basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
- basic_memory/cli/commands/cloud/core_commands.py +195 -0
- basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
- basic_memory/cli/commands/cloud/rclone_config.py +110 -0
- basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
- basic_memory/cli/commands/cloud/upload.py +233 -0
- basic_memory/cli/commands/cloud/upload_command.py +124 -0
- basic_memory/cli/commands/command_utils.py +77 -0
- basic_memory/cli/commands/db.py +44 -0
- basic_memory/cli/commands/format.py +198 -0
- basic_memory/cli/commands/import_chatgpt.py +84 -0
- basic_memory/cli/commands/import_claude_conversations.py +87 -0
- basic_memory/cli/commands/import_claude_projects.py +86 -0
- basic_memory/cli/commands/import_memory_json.py +87 -0
- basic_memory/cli/commands/mcp.py +76 -0
- basic_memory/cli/commands/project.py +889 -0
- basic_memory/cli/commands/status.py +174 -0
- basic_memory/cli/commands/telemetry.py +81 -0
- basic_memory/cli/commands/tool.py +341 -0
- basic_memory/cli/main.py +28 -0
- basic_memory/config.py +616 -0
- basic_memory/db.py +394 -0
- basic_memory/deps.py +705 -0
- basic_memory/file_utils.py +478 -0
- basic_memory/ignore_utils.py +297 -0
- basic_memory/importers/__init__.py +27 -0
- basic_memory/importers/base.py +79 -0
- basic_memory/importers/chatgpt_importer.py +232 -0
- basic_memory/importers/claude_conversations_importer.py +180 -0
- basic_memory/importers/claude_projects_importer.py +148 -0
- basic_memory/importers/memory_json_importer.py +108 -0
- basic_memory/importers/utils.py +61 -0
- basic_memory/markdown/__init__.py +21 -0
- basic_memory/markdown/entity_parser.py +279 -0
- basic_memory/markdown/markdown_processor.py +160 -0
- basic_memory/markdown/plugins.py +242 -0
- basic_memory/markdown/schemas.py +70 -0
- basic_memory/markdown/utils.py +117 -0
- basic_memory/mcp/__init__.py +1 -0
- basic_memory/mcp/async_client.py +139 -0
- basic_memory/mcp/project_context.py +141 -0
- basic_memory/mcp/prompts/__init__.py +19 -0
- basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
- basic_memory/mcp/prompts/continue_conversation.py +62 -0
- basic_memory/mcp/prompts/recent_activity.py +188 -0
- basic_memory/mcp/prompts/search.py +57 -0
- basic_memory/mcp/prompts/utils.py +162 -0
- basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
- basic_memory/mcp/resources/project_info.py +71 -0
- basic_memory/mcp/server.py +81 -0
- basic_memory/mcp/tools/__init__.py +48 -0
- basic_memory/mcp/tools/build_context.py +120 -0
- basic_memory/mcp/tools/canvas.py +152 -0
- basic_memory/mcp/tools/chatgpt_tools.py +190 -0
- basic_memory/mcp/tools/delete_note.py +242 -0
- basic_memory/mcp/tools/edit_note.py +324 -0
- basic_memory/mcp/tools/list_directory.py +168 -0
- basic_memory/mcp/tools/move_note.py +551 -0
- basic_memory/mcp/tools/project_management.py +201 -0
- basic_memory/mcp/tools/read_content.py +281 -0
- basic_memory/mcp/tools/read_note.py +267 -0
- basic_memory/mcp/tools/recent_activity.py +534 -0
- basic_memory/mcp/tools/search.py +385 -0
- basic_memory/mcp/tools/utils.py +540 -0
- basic_memory/mcp/tools/view_note.py +78 -0
- basic_memory/mcp/tools/write_note.py +230 -0
- basic_memory/models/__init__.py +15 -0
- basic_memory/models/base.py +10 -0
- basic_memory/models/knowledge.py +226 -0
- basic_memory/models/project.py +87 -0
- basic_memory/models/search.py +85 -0
- basic_memory/repository/__init__.py +11 -0
- basic_memory/repository/entity_repository.py +503 -0
- basic_memory/repository/observation_repository.py +73 -0
- basic_memory/repository/postgres_search_repository.py +379 -0
- basic_memory/repository/project_info_repository.py +10 -0
- basic_memory/repository/project_repository.py +128 -0
- basic_memory/repository/relation_repository.py +146 -0
- basic_memory/repository/repository.py +385 -0
- basic_memory/repository/search_index_row.py +95 -0
- basic_memory/repository/search_repository.py +94 -0
- basic_memory/repository/search_repository_base.py +241 -0
- basic_memory/repository/sqlite_search_repository.py +439 -0
- basic_memory/schemas/__init__.py +86 -0
- basic_memory/schemas/base.py +297 -0
- basic_memory/schemas/cloud.py +50 -0
- basic_memory/schemas/delete.py +37 -0
- basic_memory/schemas/directory.py +30 -0
- basic_memory/schemas/importer.py +35 -0
- basic_memory/schemas/memory.py +285 -0
- basic_memory/schemas/project_info.py +212 -0
- basic_memory/schemas/prompt.py +90 -0
- basic_memory/schemas/request.py +112 -0
- basic_memory/schemas/response.py +229 -0
- basic_memory/schemas/search.py +117 -0
- basic_memory/schemas/sync_report.py +72 -0
- basic_memory/schemas/v2/__init__.py +27 -0
- basic_memory/schemas/v2/entity.py +129 -0
- basic_memory/schemas/v2/resource.py +46 -0
- basic_memory/services/__init__.py +8 -0
- basic_memory/services/context_service.py +601 -0
- basic_memory/services/directory_service.py +308 -0
- basic_memory/services/entity_service.py +864 -0
- basic_memory/services/exceptions.py +37 -0
- basic_memory/services/file_service.py +541 -0
- basic_memory/services/initialization.py +216 -0
- basic_memory/services/link_resolver.py +121 -0
- basic_memory/services/project_service.py +880 -0
- basic_memory/services/search_service.py +404 -0
- basic_memory/services/service.py +15 -0
- basic_memory/sync/__init__.py +6 -0
- basic_memory/sync/background_sync.py +26 -0
- basic_memory/sync/sync_service.py +1259 -0
- basic_memory/sync/watch_service.py +510 -0
- basic_memory/telemetry.py +249 -0
- basic_memory/templates/prompts/continue_conversation.hbs +110 -0
- basic_memory/templates/prompts/search.hbs +101 -0
- basic_memory/utils.py +468 -0
- basic_memory-0.17.1.dist-info/METADATA +617 -0
- basic_memory-0.17.1.dist-info/RECORD +171 -0
- basic_memory-0.17.1.dist-info/WHEEL +4 -0
- basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
- basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Watch service for Basic Memory."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional, Set, Sequence, Callable, Awaitable, TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from basic_memory.sync.sync_service import SyncService
|
|
12
|
+
|
|
13
|
+
from basic_memory.config import BasicMemoryConfig, WATCH_STATUS_JSON
|
|
14
|
+
from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_path
|
|
15
|
+
from basic_memory.models import Project
|
|
16
|
+
from basic_memory.repository import ProjectRepository
|
|
17
|
+
from loguru import logger
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from watchfiles import awatch
|
|
21
|
+
from watchfiles.main import FileChange, Change
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WatchEvent(BaseModel):
|
|
26
|
+
timestamp: datetime
|
|
27
|
+
path: str
|
|
28
|
+
action: str # new, delete, etc
|
|
29
|
+
status: str # success, error
|
|
30
|
+
checksum: Optional[str]
|
|
31
|
+
error: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WatchServiceState(BaseModel):
|
|
35
|
+
# Service status
|
|
36
|
+
running: bool = False
|
|
37
|
+
start_time: datetime = datetime.now() # Use directly with Pydantic model
|
|
38
|
+
pid: int = os.getpid() # Use directly with Pydantic model
|
|
39
|
+
|
|
40
|
+
# Stats
|
|
41
|
+
error_count: int = 0
|
|
42
|
+
last_error: Optional[datetime] = None
|
|
43
|
+
last_scan: Optional[datetime] = None
|
|
44
|
+
|
|
45
|
+
# File counts
|
|
46
|
+
synced_files: int = 0
|
|
47
|
+
|
|
48
|
+
# Recent activity
|
|
49
|
+
recent_events: List[WatchEvent] = [] # Use directly with Pydantic model
|
|
50
|
+
|
|
51
|
+
def add_event(
|
|
52
|
+
self,
|
|
53
|
+
path: str,
|
|
54
|
+
action: str,
|
|
55
|
+
status: str,
|
|
56
|
+
checksum: Optional[str] = None,
|
|
57
|
+
error: Optional[str] = None,
|
|
58
|
+
) -> WatchEvent:
|
|
59
|
+
event = WatchEvent(
|
|
60
|
+
timestamp=datetime.now(),
|
|
61
|
+
path=path,
|
|
62
|
+
action=action,
|
|
63
|
+
status=status,
|
|
64
|
+
checksum=checksum,
|
|
65
|
+
error=error,
|
|
66
|
+
)
|
|
67
|
+
self.recent_events.insert(0, event)
|
|
68
|
+
self.recent_events = self.recent_events[:100] # Keep last 100
|
|
69
|
+
return event
|
|
70
|
+
|
|
71
|
+
def record_error(self, error: str):
|
|
72
|
+
self.error_count += 1
|
|
73
|
+
self.add_event(path="", action="sync", status="error", error=error)
|
|
74
|
+
self.last_error = datetime.now()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Type alias for sync service factory function
|
|
78
|
+
SyncServiceFactory = Callable[[Project], Awaitable["SyncService"]]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WatchService:
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
app_config: BasicMemoryConfig,
|
|
85
|
+
project_repository: ProjectRepository,
|
|
86
|
+
quiet: bool = False,
|
|
87
|
+
sync_service_factory: Optional[SyncServiceFactory] = None,
|
|
88
|
+
):
|
|
89
|
+
self.app_config = app_config
|
|
90
|
+
self.project_repository = project_repository
|
|
91
|
+
self.state = WatchServiceState()
|
|
92
|
+
self.status_path = Path.home() / ".basic-memory" / WATCH_STATUS_JSON
|
|
93
|
+
self.status_path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
self._ignore_patterns_cache: dict[Path, Set[str]] = {}
|
|
95
|
+
self._sync_service_factory = sync_service_factory
|
|
96
|
+
|
|
97
|
+
# quiet mode for mcp so it doesn't mess up stdout
|
|
98
|
+
self.console = Console(quiet=quiet)
|
|
99
|
+
|
|
100
|
+
async def _get_sync_service(self, project: Project) -> "SyncService":
|
|
101
|
+
"""Get sync service for a project, using factory if provided."""
|
|
102
|
+
if self._sync_service_factory:
|
|
103
|
+
return await self._sync_service_factory(project)
|
|
104
|
+
# Fall back to default factory
|
|
105
|
+
from basic_memory.sync.sync_service import get_sync_service
|
|
106
|
+
|
|
107
|
+
return await get_sync_service(project)
|
|
108
|
+
|
|
109
|
+
async def _schedule_restart(self, stop_event: asyncio.Event):
|
|
110
|
+
"""Schedule a restart of the watch service after the configured interval."""
|
|
111
|
+
await asyncio.sleep(self.app_config.watch_project_reload_interval)
|
|
112
|
+
stop_event.set()
|
|
113
|
+
|
|
114
|
+
def _get_ignore_patterns(self, project_path: Path) -> Set[str]:
|
|
115
|
+
"""Get or load ignore patterns for a project path."""
|
|
116
|
+
if project_path not in self._ignore_patterns_cache:
|
|
117
|
+
self._ignore_patterns_cache[project_path] = load_gitignore_patterns(project_path)
|
|
118
|
+
return self._ignore_patterns_cache[project_path]
|
|
119
|
+
|
|
120
|
+
async def _watch_projects_cycle(self, projects: Sequence[Project], stop_event: asyncio.Event):
|
|
121
|
+
"""Run one cycle of watching the given projects until stop_event is set."""
|
|
122
|
+
project_paths = [project.path for project in projects]
|
|
123
|
+
|
|
124
|
+
async for changes in awatch(
|
|
125
|
+
*project_paths,
|
|
126
|
+
debounce=self.app_config.sync_delay,
|
|
127
|
+
watch_filter=self.filter_changes,
|
|
128
|
+
recursive=True,
|
|
129
|
+
stop_event=stop_event,
|
|
130
|
+
):
|
|
131
|
+
# group changes by project and filter using ignore patterns
|
|
132
|
+
project_changes = defaultdict(list)
|
|
133
|
+
for change, path in changes:
|
|
134
|
+
for project in projects:
|
|
135
|
+
if self.is_project_path(project, path):
|
|
136
|
+
# Check if the file should be ignored based on gitignore patterns
|
|
137
|
+
project_path = Path(project.path)
|
|
138
|
+
file_path = Path(path)
|
|
139
|
+
ignore_patterns = self._get_ignore_patterns(project_path)
|
|
140
|
+
|
|
141
|
+
if should_ignore_path(file_path, project_path, ignore_patterns):
|
|
142
|
+
logger.trace(
|
|
143
|
+
f"Ignoring watched file change: {file_path.relative_to(project_path)}"
|
|
144
|
+
)
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
project_changes[project].append((change, path))
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
# create coroutines to handle changes
|
|
151
|
+
change_handlers = [
|
|
152
|
+
self.handle_changes(project, changes) # pyright: ignore
|
|
153
|
+
for project, changes in project_changes.items()
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
# process changes
|
|
157
|
+
await asyncio.gather(*change_handlers)
|
|
158
|
+
|
|
159
|
+
async def run(self): # pragma: no cover
|
|
160
|
+
"""Watch for file changes and sync them"""
|
|
161
|
+
|
|
162
|
+
self.state.running = True
|
|
163
|
+
self.state.start_time = datetime.now()
|
|
164
|
+
await self.write_status()
|
|
165
|
+
|
|
166
|
+
logger.info(
|
|
167
|
+
"Watch service started",
|
|
168
|
+
f"debounce_ms={self.app_config.sync_delay}",
|
|
169
|
+
f"pid={os.getpid()}",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
while self.state.running:
|
|
174
|
+
# Clear ignore patterns cache to pick up any .gitignore changes
|
|
175
|
+
self._ignore_patterns_cache.clear()
|
|
176
|
+
|
|
177
|
+
# Reload projects to catch any new/removed projects
|
|
178
|
+
projects = await self.project_repository.get_active_projects()
|
|
179
|
+
|
|
180
|
+
project_paths = [project.path for project in projects]
|
|
181
|
+
logger.debug(f"Starting watch cycle for directories: {project_paths}")
|
|
182
|
+
|
|
183
|
+
# Create stop event for this watch cycle
|
|
184
|
+
stop_event = asyncio.Event()
|
|
185
|
+
|
|
186
|
+
# Schedule restart after configured interval to reload projects
|
|
187
|
+
timer_task = asyncio.create_task(self._schedule_restart(stop_event))
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
await self._watch_projects_cycle(projects, stop_event)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.exception("Watch service error during cycle", error=str(e))
|
|
193
|
+
self.state.record_error(str(e))
|
|
194
|
+
await self.write_status()
|
|
195
|
+
# Continue to next cycle instead of exiting
|
|
196
|
+
await asyncio.sleep(5) # Brief pause before retry
|
|
197
|
+
finally:
|
|
198
|
+
# Cancel timer task if it's still running
|
|
199
|
+
if not timer_task.done():
|
|
200
|
+
timer_task.cancel()
|
|
201
|
+
try:
|
|
202
|
+
await timer_task
|
|
203
|
+
except asyncio.CancelledError:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.exception("Watch service error", error=str(e))
|
|
208
|
+
self.state.record_error(str(e))
|
|
209
|
+
await self.write_status()
|
|
210
|
+
raise
|
|
211
|
+
|
|
212
|
+
finally:
|
|
213
|
+
logger.info(
|
|
214
|
+
"Watch service stopped",
|
|
215
|
+
f"runtime_seconds={int((datetime.now() - self.state.start_time).total_seconds())}",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
self.state.running = False
|
|
219
|
+
await self.write_status()
|
|
220
|
+
|
|
221
|
+
def filter_changes(self, change: Change, path: str) -> bool: # pragma: no cover
|
|
222
|
+
"""Filter to only watch non-hidden files and directories.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
True if the file should be watched, False if it should be ignored
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
# Skip hidden directories and files
|
|
229
|
+
path_parts = Path(path).parts
|
|
230
|
+
for part in path_parts:
|
|
231
|
+
if part.startswith("."):
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
# Skip temp files used in atomic operations
|
|
235
|
+
if path.endswith(".tmp"):
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
async def write_status(self):
|
|
241
|
+
"""Write current state to status file"""
|
|
242
|
+
self.status_path.write_text(WatchServiceState.model_dump_json(self.state, indent=2))
|
|
243
|
+
|
|
244
|
+
def is_project_path(self, project: Project, path):
|
|
245
|
+
"""
|
|
246
|
+
Checks if path is a subdirectory or file within a project
|
|
247
|
+
"""
|
|
248
|
+
project_path = Path(project.path).resolve()
|
|
249
|
+
sub_path = Path(path).resolve()
|
|
250
|
+
return project_path in sub_path.parents
|
|
251
|
+
|
|
252
|
+
async def handle_changes(self, project: Project, changes: Set[FileChange]) -> None:
|
|
253
|
+
"""Process a batch of file changes"""
|
|
254
|
+
# Check if project still exists in configuration before processing
|
|
255
|
+
# This prevents deleted projects from being recreated by background sync
|
|
256
|
+
from basic_memory.config import ConfigManager
|
|
257
|
+
|
|
258
|
+
config_manager = ConfigManager()
|
|
259
|
+
if (
|
|
260
|
+
project.name not in config_manager.projects
|
|
261
|
+
and project.permalink not in config_manager.projects
|
|
262
|
+
):
|
|
263
|
+
logger.info(
|
|
264
|
+
f"Skipping sync for deleted project: {project.name}, change_count={len(changes)}"
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
sync_service = await self._get_sync_service(project)
|
|
269
|
+
file_service = sync_service.file_service
|
|
270
|
+
|
|
271
|
+
start_time = time.time()
|
|
272
|
+
directory = Path(project.path).resolve()
|
|
273
|
+
logger.info(
|
|
274
|
+
f"Processing project: {project.name} changes, change_count={len(changes)}, directory={directory}"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Group changes by type
|
|
278
|
+
adds: List[str] = []
|
|
279
|
+
deletes: List[str] = []
|
|
280
|
+
modifies: List[str] = []
|
|
281
|
+
|
|
282
|
+
for change, path in changes:
|
|
283
|
+
# convert to relative path
|
|
284
|
+
relative_path = Path(path).relative_to(directory).as_posix()
|
|
285
|
+
|
|
286
|
+
# Skip .tmp files - they're temporary and shouldn't be synced
|
|
287
|
+
if relative_path.endswith(".tmp"):
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
if change == Change.added:
|
|
291
|
+
adds.append(relative_path)
|
|
292
|
+
elif change == Change.deleted:
|
|
293
|
+
deletes.append(relative_path)
|
|
294
|
+
elif change == Change.modified:
|
|
295
|
+
modifies.append(relative_path)
|
|
296
|
+
|
|
297
|
+
logger.debug(
|
|
298
|
+
f"Grouped file changes, added={len(adds)}, deleted={len(deletes)}, modified={len(modifies)}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# because of our atomic writes on updates, an add may be an existing file
|
|
302
|
+
for added_path in adds: # pragma: no cover TODO add test
|
|
303
|
+
entity = await sync_service.entity_repository.get_by_file_path(added_path)
|
|
304
|
+
if entity is not None:
|
|
305
|
+
logger.debug(f"Existing file will be processed as modified, path={added_path}")
|
|
306
|
+
adds.remove(added_path)
|
|
307
|
+
modifies.append(added_path)
|
|
308
|
+
|
|
309
|
+
# Track processed files to avoid duplicates
|
|
310
|
+
processed: Set[str] = set()
|
|
311
|
+
|
|
312
|
+
# First handle potential moves
|
|
313
|
+
for added_path in adds:
|
|
314
|
+
if added_path in processed:
|
|
315
|
+
continue # pragma: no cover
|
|
316
|
+
|
|
317
|
+
# Skip directories for added paths
|
|
318
|
+
# We don't need to process directories, only the files inside them
|
|
319
|
+
# This prevents errors when trying to compute checksums or read directories as files
|
|
320
|
+
added_full_path = directory / added_path
|
|
321
|
+
if not added_full_path.exists() or added_full_path.is_dir():
|
|
322
|
+
logger.debug("Skipping non-existent or directory path", path=added_path)
|
|
323
|
+
processed.add(added_path)
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
for deleted_path in deletes:
|
|
327
|
+
if deleted_path in processed:
|
|
328
|
+
continue # pragma: no cover
|
|
329
|
+
|
|
330
|
+
# Skip directories for deleted paths (based on entity type in db)
|
|
331
|
+
deleted_entity = await sync_service.entity_repository.get_by_file_path(deleted_path)
|
|
332
|
+
if deleted_entity is None:
|
|
333
|
+
# If this was a directory, it wouldn't have an entity
|
|
334
|
+
logger.debug("Skipping unknown path for move detection", path=deleted_path)
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
if added_path != deleted_path:
|
|
338
|
+
# Compare checksums to detect moves
|
|
339
|
+
try:
|
|
340
|
+
added_checksum = await file_service.compute_checksum(added_path)
|
|
341
|
+
|
|
342
|
+
if deleted_entity and deleted_entity.checksum == added_checksum:
|
|
343
|
+
await sync_service.handle_move(deleted_path, added_path)
|
|
344
|
+
self.state.add_event(
|
|
345
|
+
path=f"{deleted_path} -> {added_path}",
|
|
346
|
+
action="moved",
|
|
347
|
+
status="success",
|
|
348
|
+
)
|
|
349
|
+
self.console.print(f"[blue]→[/blue] {deleted_path} → {added_path}")
|
|
350
|
+
logger.info(f"move: {deleted_path} -> {added_path}")
|
|
351
|
+
processed.add(added_path)
|
|
352
|
+
processed.add(deleted_path)
|
|
353
|
+
break
|
|
354
|
+
except Exception as e: # pragma: no cover
|
|
355
|
+
logger.warning(
|
|
356
|
+
"Error checking for move",
|
|
357
|
+
f"old_path={deleted_path}",
|
|
358
|
+
f"new_path={added_path}",
|
|
359
|
+
f"error={str(e)}",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Handle remaining changes - group them by type for concise output
|
|
363
|
+
moved_count = len([p for p in processed if p in deletes or p in adds])
|
|
364
|
+
delete_count = 0
|
|
365
|
+
add_count = 0
|
|
366
|
+
modify_count = 0
|
|
367
|
+
|
|
368
|
+
# Process deletes
|
|
369
|
+
for path in deletes:
|
|
370
|
+
if path not in processed:
|
|
371
|
+
# Check if file still exists on disk (vim atomic write edge case)
|
|
372
|
+
full_path = directory / path
|
|
373
|
+
if full_path.exists() and full_path.is_file():
|
|
374
|
+
# File still exists despite DELETE event - treat as modification
|
|
375
|
+
logger.debug(
|
|
376
|
+
"File exists despite DELETE event, treating as modification", path=path
|
|
377
|
+
)
|
|
378
|
+
entity, checksum = await sync_service.sync_file(path, new=False)
|
|
379
|
+
self.state.add_event(
|
|
380
|
+
path=path, action="modified", status="success", checksum=checksum
|
|
381
|
+
)
|
|
382
|
+
self.console.print(f"[yellow]✎[/yellow] {path} (atomic write)")
|
|
383
|
+
logger.info(f"atomic write detected: {path}")
|
|
384
|
+
processed.add(path)
|
|
385
|
+
modify_count += 1
|
|
386
|
+
else:
|
|
387
|
+
# Check if this was a directory - skip if so
|
|
388
|
+
# (we can't tell if the deleted path was a directory since it no longer exists,
|
|
389
|
+
# so we check if there's an entity in the database for it)
|
|
390
|
+
entity = await sync_service.entity_repository.get_by_file_path(path)
|
|
391
|
+
if entity is None:
|
|
392
|
+
# No entity means this was likely a directory - skip it
|
|
393
|
+
logger.debug(
|
|
394
|
+
f"Skipping deleted path with no entity (likely directory), path={path}"
|
|
395
|
+
)
|
|
396
|
+
processed.add(path)
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
# File truly deleted
|
|
400
|
+
logger.debug("Processing deleted file", path=path)
|
|
401
|
+
await sync_service.handle_delete(path)
|
|
402
|
+
self.state.add_event(path=path, action="deleted", status="success")
|
|
403
|
+
self.console.print(f"[red]✕[/red] {path}")
|
|
404
|
+
logger.info(f"deleted: {path}")
|
|
405
|
+
processed.add(path)
|
|
406
|
+
delete_count += 1
|
|
407
|
+
|
|
408
|
+
# Process adds
|
|
409
|
+
for path in adds:
|
|
410
|
+
if path not in processed:
|
|
411
|
+
# Skip directories - only process files
|
|
412
|
+
full_path = directory / path
|
|
413
|
+
if not full_path.exists() or full_path.is_dir():
|
|
414
|
+
logger.debug(
|
|
415
|
+
f"Skipping non-existent or directory path, path={path}"
|
|
416
|
+
) # pragma: no cover
|
|
417
|
+
processed.add(path) # pragma: no cover
|
|
418
|
+
continue # pragma: no cover
|
|
419
|
+
|
|
420
|
+
logger.debug(f"Processing new file, path={path}")
|
|
421
|
+
entity, checksum = await sync_service.sync_file(path, new=True)
|
|
422
|
+
if checksum:
|
|
423
|
+
self.state.add_event(
|
|
424
|
+
path=path, action="new", status="success", checksum=checksum
|
|
425
|
+
)
|
|
426
|
+
self.console.print(f"[green]✓[/green] {path}")
|
|
427
|
+
logger.info(
|
|
428
|
+
"new file processed",
|
|
429
|
+
f"path={path}",
|
|
430
|
+
f"checksum={checksum}",
|
|
431
|
+
)
|
|
432
|
+
processed.add(path)
|
|
433
|
+
add_count += 1
|
|
434
|
+
else: # pragma: no cover
|
|
435
|
+
logger.warning(f"Error syncing new file, path={path}") # pragma: no cover
|
|
436
|
+
self.console.print(
|
|
437
|
+
f"[orange]?[/orange] Error syncing: {path}"
|
|
438
|
+
) # pragma: no cover
|
|
439
|
+
|
|
440
|
+
# Process modifies - detect repeats
|
|
441
|
+
last_modified_path = None
|
|
442
|
+
repeat_count = 0
|
|
443
|
+
|
|
444
|
+
for path in modifies:
|
|
445
|
+
if path not in processed:
|
|
446
|
+
# Skip directories - only process files
|
|
447
|
+
full_path = directory / path
|
|
448
|
+
if not full_path.exists() or full_path.is_dir():
|
|
449
|
+
logger.debug("Skipping non-existent or directory path", path=path)
|
|
450
|
+
processed.add(path)
|
|
451
|
+
continue
|
|
452
|
+
|
|
453
|
+
logger.debug(f"Processing modified file: path={path}")
|
|
454
|
+
entity, checksum = await sync_service.sync_file(path, new=False)
|
|
455
|
+
self.state.add_event(
|
|
456
|
+
path=path, action="modified", status="success", checksum=checksum
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Check if this is a repeat of the last modified file
|
|
460
|
+
if path == last_modified_path: # pragma: no cover
|
|
461
|
+
repeat_count += 1 # pragma: no cover
|
|
462
|
+
# Only show a message for the first repeat
|
|
463
|
+
if repeat_count == 1: # pragma: no cover
|
|
464
|
+
self.console.print(
|
|
465
|
+
f"[yellow]...[/yellow] Repeated changes to {path}"
|
|
466
|
+
) # pragma: no cover
|
|
467
|
+
else:
|
|
468
|
+
# haven't processed this file
|
|
469
|
+
self.console.print(f"[yellow]✎[/yellow] {path}")
|
|
470
|
+
logger.info(f"modified: {path}")
|
|
471
|
+
last_modified_path = path
|
|
472
|
+
repeat_count = 0
|
|
473
|
+
modify_count += 1
|
|
474
|
+
|
|
475
|
+
logger.debug( # pragma: no cover
|
|
476
|
+
"Modified file processed, "
|
|
477
|
+
f"path={path} "
|
|
478
|
+
f"entity_id={entity.id if entity else None} "
|
|
479
|
+
f"checksum={checksum}",
|
|
480
|
+
)
|
|
481
|
+
processed.add(path)
|
|
482
|
+
|
|
483
|
+
# Add a concise summary instead of a divider
|
|
484
|
+
if processed:
|
|
485
|
+
changes = [] # pyright: ignore
|
|
486
|
+
if add_count > 0:
|
|
487
|
+
changes.append(f"[green]{add_count} added[/green]") # pyright: ignore
|
|
488
|
+
if modify_count > 0:
|
|
489
|
+
changes.append(f"[yellow]{modify_count} modified[/yellow]") # pyright: ignore
|
|
490
|
+
if moved_count > 0:
|
|
491
|
+
changes.append(f"[blue]{moved_count} moved[/blue]") # pyright: ignore
|
|
492
|
+
if delete_count > 0:
|
|
493
|
+
changes.append(f"[red]{delete_count} deleted[/red]") # pyright: ignore
|
|
494
|
+
|
|
495
|
+
if changes:
|
|
496
|
+
self.console.print(f"{', '.join(changes)}", style="dim") # pyright: ignore
|
|
497
|
+
logger.info(f"changes: {len(changes)}")
|
|
498
|
+
|
|
499
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
500
|
+
self.state.last_scan = datetime.now()
|
|
501
|
+
self.state.synced_files += len(processed)
|
|
502
|
+
|
|
503
|
+
logger.info(
|
|
504
|
+
"File change processing completed, "
|
|
505
|
+
f"processed_files={len(processed)}, "
|
|
506
|
+
f"total_synced_files={self.state.synced_files}, "
|
|
507
|
+
f"duration_ms={duration_ms}"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
await self.write_status()
|