portacode 1.3.32__py3-none-any.whl → 1.4.11.dev5__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.

Potentially problematic release.


This version of portacode might be problematic. Click here for more details.

Files changed (56) hide show
  1. portacode/_version.py +2 -2
  2. portacode/cli.py +158 -14
  3. portacode/connection/client.py +127 -8
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +370 -4
  5. portacode/connection/handlers/__init__.py +16 -1
  6. portacode/connection/handlers/diff_handlers.py +603 -0
  7. portacode/connection/handlers/file_handlers.py +674 -17
  8. portacode/connection/handlers/project_aware_file_handlers.py +11 -0
  9. portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
  10. portacode/connection/handlers/project_state/git_manager.py +139 -572
  11. portacode/connection/handlers/project_state/handlers.py +28 -14
  12. portacode/connection/handlers/project_state/manager.py +226 -101
  13. portacode/connection/handlers/proxmox_infra.py +790 -0
  14. portacode/connection/handlers/session.py +465 -84
  15. portacode/connection/handlers/system_handlers.py +181 -8
  16. portacode/connection/handlers/tab_factory.py +1 -47
  17. portacode/connection/handlers/update_handler.py +61 -0
  18. portacode/connection/terminal.py +55 -10
  19. portacode/keypair.py +63 -1
  20. portacode/link_capture/__init__.py +38 -0
  21. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  22. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  23. portacode/link_capture/bin/elinks +3 -0
  24. portacode/link_capture/bin/gio-open +3 -0
  25. portacode/link_capture/bin/gnome-open +3 -0
  26. portacode/link_capture/bin/gvfs-open +3 -0
  27. portacode/link_capture/bin/kde-open +3 -0
  28. portacode/link_capture/bin/kfmclient +3 -0
  29. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  30. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  31. portacode/link_capture/bin/links +3 -0
  32. portacode/link_capture/bin/links2 +3 -0
  33. portacode/link_capture/bin/lynx +3 -0
  34. portacode/link_capture/bin/mate-open +3 -0
  35. portacode/link_capture/bin/netsurf +3 -0
  36. portacode/link_capture/bin/sensible-browser +3 -0
  37. portacode/link_capture/bin/w3m +3 -0
  38. portacode/link_capture/bin/x-www-browser +3 -0
  39. portacode/link_capture/bin/xdg-open +3 -0
  40. portacode/pairing.py +103 -0
  41. portacode/static/js/utils/ntp-clock.js +170 -79
  42. portacode/utils/diff_apply.py +456 -0
  43. portacode/utils/diff_renderer.py +371 -0
  44. portacode/utils/ntp_clock.py +45 -131
  45. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/METADATA +71 -3
  46. portacode-1.4.11.dev5.dist-info/RECORD +97 -0
  47. test_modules/test_device_online.py +1 -1
  48. test_modules/test_login_flow.py +8 -4
  49. test_modules/test_play_store_screenshots.py +294 -0
  50. testing_framework/.env.example +4 -1
  51. testing_framework/core/playwright_manager.py +63 -9
  52. portacode-1.3.32.dist-info/RECORD +0 -70
  53. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/WHEEL +0 -0
  54. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/entry_points.txt +0 -0
  55. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/licenses/LICENSE +0 -0
  56. {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/top_level.txt +0 -0
@@ -411,7 +411,11 @@ class ProjectStateGitStageHandler(AsyncHandler):
411
411
 
412
412
  if success:
413
413
  # Refresh git status only (no filesystem changes from staging)
414
- await manager._refresh_project_state(source_client_session, git_only=True)
414
+ await manager._refresh_project_state(
415
+ source_client_session,
416
+ git_only=True,
417
+ reason="git_stage",
418
+ )
415
419
 
416
420
  # Build response
417
421
  response = {
@@ -485,7 +489,11 @@ class ProjectStateGitUnstageHandler(AsyncHandler):
485
489
 
486
490
  if success:
487
491
  # Refresh git status only (no filesystem changes from unstaging)
488
- await manager._refresh_project_state(source_client_session, git_only=True)
492
+ await manager._refresh_project_state(
493
+ source_client_session,
494
+ git_only=True,
495
+ reason="git_unstage",
496
+ )
489
497
 
490
498
  # Build response
491
499
  response = {
@@ -559,7 +567,10 @@ class ProjectStateGitRevertHandler(AsyncHandler):
559
567
 
560
568
  if success:
561
569
  # Refresh entire project state to ensure consistency
562
- await manager._refresh_project_state(source_client_session)
570
+ await manager._refresh_project_state(
571
+ source_client_session,
572
+ reason="git_revert",
573
+ )
563
574
 
564
575
  # Build response
565
576
  response = {
@@ -622,7 +633,11 @@ class ProjectStateGitCommitHandler(AsyncHandler):
622
633
  commit_hash = git_manager.get_head_commit_hash()
623
634
 
624
635
  # Refresh git status only (no filesystem changes from commit)
625
- await manager._refresh_project_state(source_client_session, git_only=True)
636
+ await manager._refresh_project_state(
637
+ source_client_session,
638
+ git_only=True,
639
+ reason="git_commit",
640
+ )
626
641
  except Exception as e:
627
642
  error_message = str(e)
628
643
  logger.error("Error during commit: %s", error_message)
@@ -656,7 +671,7 @@ async def handle_client_session_cleanup(handler, payload: Dict[str, Any], source
656
671
  manager = get_or_create_project_state_manager(handler.context, handler.control_channel)
657
672
 
658
673
  # Clean up the client session's project state
659
- manager.cleanup_projects_by_client_session(client_session_id)
674
+ await manager.cleanup_projects_by_client_session(client_session_id)
660
675
 
661
676
  logger.info("Client session cleanup completed: %s", client_session_id)
662
677
 
@@ -764,14 +779,13 @@ class ProjectStateDiffContentHandler(AsyncHandler):
764
779
  if content is None or (content_type == "all" and not all([matching_tab.original_content, matching_tab.modified_content])):
765
780
  if content_type in ["original", "modified", "all"]:
766
781
  # Re-generate the diff content if needed
767
- await manager.create_diff_tab(
768
- source_client_session,
769
- file_path,
770
- from_ref,
771
- to_ref,
772
- from_hash,
773
- to_hash,
774
- activate=False # Don't activate, just ensure content is loaded
782
+ await manager.open_diff_tab(
783
+ source_client_session,
784
+ file_path,
785
+ from_ref,
786
+ to_ref,
787
+ from_hash,
788
+ to_hash
775
789
  )
776
790
 
777
791
  # Try to get content again after regeneration (use same matching logic)
@@ -858,4 +872,4 @@ class ProjectStateDiffContentHandler(AsyncHandler):
858
872
  # Add request_id if present in original message
859
873
  if "request_id" in message:
860
874
  error_response["request_id"] = message["request_id"]
861
- await self.send_response(error_response, project_id=server_project_id)
875
+ await self.send_response(error_response, project_id=server_project_id)
@@ -11,6 +11,8 @@ import logging
11
11
  import os
12
12
  import threading
13
13
  import time
14
+ from pathlib import Path
15
+ from asyncio import Lock
14
16
  from dataclasses import asdict
15
17
  from typing import Any, Dict, List, Optional, Set
16
18
 
@@ -34,9 +36,12 @@ class ProjectStateManager:
34
36
  self.context = context
35
37
  self.projects: Dict[str, ProjectState] = {}
36
38
  self.git_managers: Dict[str, GitManager] = {}
39
+ self._shared_git_managers: Dict[str, Dict[str, Any]] = {}
40
+ self._shared_git_lock: Lock = Lock()
37
41
  self.file_watcher = FileSystemWatcher(self)
38
42
  self.debug_mode = False
39
43
  self.debug_file_path: Optional[str] = None
44
+ self._session_locks: Dict[str, Lock] = {}
40
45
 
41
46
  # Content caching optimization
42
47
  self.use_content_caching = context.get("use_content_caching", False)
@@ -44,6 +49,7 @@ class ProjectStateManager:
44
49
  # Debouncing for file changes
45
50
  self._change_debounce_timer: Optional[asyncio.Task] = None
46
51
  self._pending_changes: Set[str] = set()
52
+ self._pending_change_sources: Dict[str, Dict[str, Any]] = {}
47
53
 
48
54
  def set_debug_mode(self, enabled: bool, debug_file_path: Optional[str] = None):
49
55
  """Enable or disable debug mode with JSON output."""
@@ -52,6 +58,14 @@ class ProjectStateManager:
52
58
  if enabled:
53
59
  logger.info("Project state debug mode enabled, output to: %s", debug_file_path)
54
60
 
61
+ def _get_session_lock(self, client_session_id: str) -> Lock:
62
+ """Get or create an asyncio lock for a client session."""
63
+ lock = self._session_locks.get(client_session_id)
64
+ if not lock:
65
+ lock = Lock()
66
+ self._session_locks[client_session_id] = lock
67
+ return lock
68
+
55
69
  def _write_debug_state(self):
56
70
  """Write current state to debug JSON file (thread-safe)."""
57
71
  if not self.debug_mode or not self.debug_file_path:
@@ -127,65 +141,115 @@ class ProjectStateManager:
127
141
  metadata.pop('diff_details', None)
128
142
 
129
143
  return tab_dict
144
+
145
+ async def _handle_shared_git_change(self, project_folder_path: str):
146
+ """Notify all sessions for a project when the shared git manager detects changes."""
147
+ async with self._shared_git_lock:
148
+ entry = self._shared_git_managers.get(project_folder_path)
149
+ target_sessions = list(entry["sessions"]) if entry else []
150
+
151
+ for session_id in target_sessions:
152
+ await self._refresh_project_state(session_id, git_only=True, reason="git_monitor")
153
+
154
+ async def _acquire_shared_git_manager(self, project_folder_path: str, client_session_id: str) -> GitManager:
155
+ """Get or create a shared git manager for a project path."""
156
+ async with self._shared_git_lock:
157
+ entry = self._shared_git_managers.get(project_folder_path)
158
+
159
+ if not entry:
160
+ async def git_change_callback():
161
+ await self._handle_shared_git_change(project_folder_path)
162
+
163
+ git_manager = GitManager(
164
+ project_folder_path,
165
+ change_callback=git_change_callback,
166
+ owner_session_id=client_session_id,
167
+ )
168
+ entry = {"manager": git_manager, "sessions": set()}
169
+ self._shared_git_managers[project_folder_path] = entry
170
+
171
+ entry["sessions"].add(client_session_id)
172
+ self.git_managers[client_session_id] = entry["manager"]
173
+ return entry["manager"]
174
+
175
+ async def _release_shared_git_manager(self, project_folder_path: Optional[str], client_session_id: str):
176
+ """Release a session's reference to a shared git manager and clean it up if unused."""
177
+ manager_to_cleanup: Optional[GitManager] = None
178
+
179
+ async with self._shared_git_lock:
180
+ manager = self.git_managers.pop(client_session_id, None)
181
+
182
+ if not project_folder_path:
183
+ manager_to_cleanup = manager
184
+ else:
185
+ entry = self._shared_git_managers.get(project_folder_path)
186
+ if entry:
187
+ entry["sessions"].discard(client_session_id)
188
+ if not entry["sessions"]:
189
+ self._shared_git_managers.pop(project_folder_path, None)
190
+ manager_to_cleanup = manager or entry["manager"]
191
+ else:
192
+ manager_to_cleanup = manager
193
+
194
+ if manager_to_cleanup:
195
+ manager_to_cleanup.cleanup()
130
196
 
131
197
  async def initialize_project_state(self, client_session_id: str, project_folder_path: str) -> ProjectState:
132
198
  """Initialize project state for a client session."""
133
- # Check if this client session already has a project state
134
- if client_session_id in self.projects:
135
- existing_project = self.projects[client_session_id]
136
- # If it's the same folder, return existing state
137
- if existing_project.project_folder_path == project_folder_path:
138
- logger.info("Returning existing project state for client session: %s", client_session_id)
139
- return existing_project
140
- else:
141
- # Different folder - cleanup old state and create new one
142
- logger.info("Client session %s switching projects from %s to %s",
143
- client_session_id, existing_project.project_folder_path, project_folder_path)
144
- self.cleanup_project(client_session_id)
145
-
146
- # Note: Multiple client sessions can have independent project states for the same folder
147
- # Each client session gets its own project state instance
148
-
149
- logger.info("Initializing project state for client session: %s, folder: %s", client_session_id, project_folder_path)
150
-
151
- # Initialize Git manager with change callback
152
- async def git_change_callback():
153
- """Callback when git status changes are detected."""
154
- logger.debug("Git change detected, refreshing project state for %s", client_session_id)
155
- # Git directory changes only affect git status, not filesystem
156
- await self._refresh_project_state(client_session_id, git_only=True)
157
-
158
- git_manager = GitManager(project_folder_path, change_callback=git_change_callback)
159
- self.git_managers[client_session_id] = git_manager
199
+ lock = self._get_session_lock(client_session_id)
200
+ async with lock:
201
+ existing_project = self.projects.get(client_session_id)
202
+ if existing_project:
203
+ if existing_project.project_folder_path == project_folder_path:
204
+ logger.info("Returning existing project state for client session: %s", client_session_id)
205
+ return existing_project
206
+ logger.info("Client session %s switching projects from %s to %s",
207
+ client_session_id, existing_project.project_folder_path, project_folder_path)
208
+ previous_path = self._cleanup_project_locked(client_session_id)
209
+ await self._release_shared_git_manager(previous_path, client_session_id)
210
+
211
+ logger.info("Initializing project state for client session: %s, folder: %s", client_session_id, project_folder_path)
212
+ git_manager = await self._acquire_shared_git_manager(project_folder_path, client_session_id)
213
+
214
+ loop = asyncio.get_event_loop()
215
+ is_git_repo = git_manager.is_git_repo
216
+ git_branch = await loop.run_in_executor(None, git_manager.get_branch_name)
217
+ git_status_summary = await loop.run_in_executor(None, git_manager.get_status_summary)
218
+ git_detailed_status = await loop.run_in_executor(None, git_manager.get_detailed_status)
219
+
220
+ project_state = ProjectState(
221
+ client_session_id=client_session_id,
222
+ project_folder_path=project_folder_path,
223
+ items=[],
224
+ is_git_repo=is_git_repo,
225
+ git_branch=git_branch,
226
+ git_status_summary=git_status_summary,
227
+ git_detailed_status=git_detailed_status,
228
+ )
229
+
230
+ await self._initialize_monitored_folders(project_state)
231
+ await self._sync_all_state_with_monitored_folders(project_state)
232
+
233
+ self.projects[client_session_id] = project_state
234
+ self._write_debug_state()
235
+
236
+ return project_state
160
237
 
161
- # Run git operations in executor to avoid blocking event loop
162
- loop = asyncio.get_event_loop()
163
- is_git_repo = git_manager.is_git_repo
164
- git_branch = await loop.run_in_executor(None, git_manager.get_branch_name)
165
- git_status_summary = await loop.run_in_executor(None, git_manager.get_status_summary)
166
- git_detailed_status = await loop.run_in_executor(None, git_manager.get_detailed_status)
238
+ def get_diagnostics(self) -> Dict[str, Any]:
239
+ """Return aggregate stats for health monitoring."""
240
+ git_diag = {}
241
+ for session_id, git_manager in self.git_managers.items():
242
+ git_diag[session_id] = git_manager.get_diagnostics()
167
243
 
168
- # Create project state
169
- project_state = ProjectState(
170
- client_session_id=client_session_id,
171
- project_folder_path=project_folder_path,
172
- items=[],
173
- is_git_repo=is_git_repo,
174
- git_branch=git_branch,
175
- git_status_summary=git_status_summary,
176
- git_detailed_status=git_detailed_status
177
- )
178
-
179
- # Initialize monitored folders with project root and its immediate subdirectories
180
- await self._initialize_monitored_folders(project_state)
181
-
182
- # Sync all dependent state (items, watchdog)
183
- await self._sync_all_state_with_monitored_folders(project_state)
184
-
185
- self.projects[client_session_id] = project_state
186
- self._write_debug_state()
187
-
188
- return project_state
244
+ watcher_diag = self.file_watcher.get_diagnostics() if self.file_watcher else {}
245
+
246
+ return {
247
+ "projects": len(self.projects),
248
+ "git_managers": len(self.git_managers),
249
+ "pending_file_changes": len(self._pending_changes),
250
+ "watcher": watcher_diag,
251
+ "git_sessions": git_diag,
252
+ }
189
253
 
190
254
  async def _initialize_monitored_folders(self, project_state: ProjectState):
191
255
  """Initialize monitored folders with project root (expanded) and its immediate subdirectories (collapsed)."""
@@ -216,18 +280,12 @@ class ProjectStateManager:
216
280
  for monitored_folder in project_state.monitored_folders:
217
281
  self.file_watcher.start_watching(monitored_folder.folder_path)
218
282
 
219
- # For git repositories, also watch the .git directory for git status changes
283
+ # Intentionally avoid watching .git; Git status changes are polled separately
220
284
  if project_state.is_git_repo:
221
285
  git_dir_path = os.path.join(project_state.project_folder_path, '.git')
222
- logger.debug("🔍 [TRACE] Project is git repo, checking .git directory: %s", LogCategory.GIT, git_dir_path)
223
- if os.path.exists(git_dir_path):
224
- logger.debug("🔍 [TRACE] ✅ Starting to watch .git directory: %s", LogCategory.GIT, git_dir_path)
225
- self.file_watcher.start_watching_git_directory(git_dir_path)
226
- logger.debug("🔍 [TRACE] ✅ Started monitoring .git directory for git status changes: %s", LogCategory.GIT, git_dir_path)
227
- else:
228
- logger.error("🔍 [TRACE] ❌ .git directory does not exist: %s", LogCategory.GIT, git_dir_path)
286
+ logger.debug("🔍 [TRACE] Project is git repo, but skipping .git watcher registration: %s", LogCategory.GIT, git_dir_path)
229
287
  else:
230
- logger.debug("🔍 [TRACE] Project is NOT a git repo, skipping .git directory monitoring", LogCategory.GIT)
288
+ logger.debug("🔍 [TRACE] Project is NOT a git repo, no .git watcher needed", LogCategory.GIT)
231
289
 
232
290
  # Watchdog synchronized
233
291
 
@@ -308,8 +366,8 @@ class ProjectStateManager:
308
366
  with os.scandir(directory_path) as entries:
309
367
  for entry in entries:
310
368
  try:
311
- # Skip .git folders and their contents
312
- if entry.name == '.git' and entry.is_dir():
369
+ # Skip .git metadata regardless of whether it's a dir or file (worktrees create files)
370
+ if entry.name == '.git':
313
371
  continue
314
372
 
315
373
  stat_info = entry.stat()
@@ -380,9 +438,10 @@ class ProjectStateManager:
380
438
  child_paths = []
381
439
  with os.scandir(monitored_folder.folder_path) as entries:
382
440
  for entry in entries:
383
- if entry.name != '.git' or not entry.is_dir():
384
- child_paths.append(entry.path)
385
- all_file_paths.append(entry.path)
441
+ if entry.name == '.git':
442
+ continue
443
+ child_paths.append(entry.path)
444
+ all_file_paths.append(entry.path)
386
445
  folder_to_paths[monitored_folder.folder_path] = child_paths
387
446
  except (OSError, PermissionError) as e:
388
447
  logger.error("Error scanning folder %s: %s", monitored_folder.folder_path, e)
@@ -465,8 +524,8 @@ class ProjectStateManager:
465
524
  with os.scandir(directory_path) as entries:
466
525
  for entry in entries:
467
526
  try:
468
- # Skip .git folders and their contents
469
- if entry.name == '.git' and entry.is_dir():
527
+ # Skip .git metadata regardless of whether it's a dir or file (worktrees create files)
528
+ if entry.name == '.git':
470
529
  continue
471
530
 
472
531
  stat_info = entry.stat()
@@ -816,8 +875,18 @@ class ProjectStateManager:
816
875
  async def _handle_file_change(self, event):
817
876
  """Handle file system change events with debouncing."""
818
877
  logger.debug("🔍 [TRACE] _handle_file_change called: %s - %s", LogCategory.FILE_SYSTEM, event.event_type, event.src_path)
878
+ is_git_event = ".git" in Path(event.src_path).parts
879
+ if is_git_event:
880
+ logger.info("File watcher event from .git: %s %s", LogCategory.FILE_SYSTEM, event.event_type, event.src_path)
881
+ else:
882
+ logger.debug("File watcher event: %s %s", LogCategory.FILE_SYSTEM, event.event_type, event.src_path)
819
883
 
820
884
  self._pending_changes.add(event.src_path)
885
+ self._pending_change_sources[event.src_path] = {
886
+ "event_type": event.event_type,
887
+ "is_git_event": is_git_event,
888
+ "timestamp": time.time(),
889
+ }
821
890
  logger.debug("🔍 [TRACE] Added to pending changes: %s (total pending: %d)", LogCategory.FILE_SYSTEM, event.src_path, len(self._pending_changes))
822
891
 
823
892
  # Cancel existing timer
@@ -849,6 +918,16 @@ class ProjectStateManager:
849
918
  return
850
919
 
851
920
  logger.debug("🔍 [TRACE] Processing %d pending file changes: %s", len(self._pending_changes), list(self._pending_changes))
921
+ git_events = [path for path in self._pending_changes if self._pending_change_sources.get(path, {}).get("is_git_event")]
922
+ workspace_events = [path for path in self._pending_changes if path not in git_events]
923
+ logger.info(
924
+ "Pending change summary: total=%d git_events=%d workspace_events=%d sample_git=%s",
925
+ LogCategory.FILE_SYSTEM,
926
+ len(self._pending_changes),
927
+ len(git_events),
928
+ len(workspace_events),
929
+ git_events[:3],
930
+ )
852
931
 
853
932
  # Process changes for each affected project
854
933
  affected_projects = set()
@@ -872,13 +951,38 @@ class ProjectStateManager:
872
951
 
873
952
  # Refresh affected projects
874
953
  for client_session_id in affected_projects:
875
- logger.debug("🔍 [TRACE] About to refresh project state for session: %s", client_session_id)
876
- await self._refresh_project_state(client_session_id)
954
+ project_state = self.projects.get(client_session_id)
955
+ if not project_state:
956
+ continue
957
+ project_paths = [
958
+ path for path in self._pending_changes
959
+ if path.startswith(project_state.project_folder_path)
960
+ ]
961
+ git_paths = [
962
+ path for path in project_paths
963
+ if self._pending_change_sources.get(path, {}).get("is_git_event")
964
+ ]
965
+ is_git_only_batch = bool(project_paths) and len(project_paths) == len(git_paths)
966
+ logger.info(
967
+ "Refreshing project %s due to pending changes: total_paths=%d git_paths=%d git_only_batch=%s sample_paths=%s",
968
+ LogCategory.FILE_SYSTEM,
969
+ client_session_id,
970
+ len(project_paths),
971
+ len(git_paths),
972
+ is_git_only_batch,
973
+ project_paths[:3],
974
+ )
975
+ await self._refresh_project_state(
976
+ client_session_id,
977
+ git_only=is_git_only_batch,
978
+ reason="filesystem_watch_git_only" if is_git_only_batch else "filesystem_watch",
979
+ )
877
980
 
878
981
  self._pending_changes.clear()
982
+ self._pending_change_sources.clear()
879
983
  logger.debug("🔍 [TRACE] ✅ Finished processing file changes")
880
984
 
881
- async def _refresh_project_state(self, client_session_id: str, git_only: bool = False):
985
+ async def _refresh_project_state(self, client_session_id: str, git_only: bool = False, reason: str = "unknown"):
882
986
  """Refresh project state after file changes.
883
987
 
884
988
  Args:
@@ -887,8 +991,8 @@ class ProjectStateManager:
887
991
  detecting new directories and syncing file state). Use this for
888
992
  git operations (stage, unstage, revert) to avoid unnecessary work.
889
993
  """
890
- logger.debug("🔍 [TRACE] _refresh_project_state called for session: %s (git_only=%s)",
891
- client_session_id, git_only)
994
+ logger.debug("🔍 [TRACE] _refresh_project_state called for session: %s (git_only=%s, reason=%s)",
995
+ client_session_id, git_only, reason)
892
996
 
893
997
  if client_session_id not in self.projects:
894
998
  logger.debug("🔍 [TRACE] ❌ Session not found in projects: %s", client_session_id)
@@ -906,19 +1010,11 @@ class ProjectStateManager:
906
1010
  # Git repo was created
907
1011
  logger.debug("🔍 [TRACE] Git repo detected, reinitializing git manager for session: %s", client_session_id)
908
1012
  git_manager.reinitialize()
909
-
910
- # Start watching .git directory for git status changes
911
- if git_manager.is_git_repo:
912
- logger.debug("🔍 [TRACE] Starting to watch .git directory: %s", git_dir_path)
913
- self.file_watcher.start_watching_git_directory(git_dir_path)
914
1013
  elif git_manager.is_git_repo and not git_dir_exists:
915
1014
  # Git repo was deleted
916
1015
  logger.debug("🔍 [TRACE] Git repo removed, updating git manager for session: %s", client_session_id)
917
1016
  git_manager.repo = None
918
1017
  git_manager.is_git_repo = False
919
-
920
- # Stop watching .git directory
921
- self.file_watcher.stop_watching(git_dir_path)
922
1018
 
923
1019
  # Update Git status
924
1020
  if git_manager:
@@ -1063,10 +1159,33 @@ class ProjectStateManager:
1063
1159
  except Exception as e:
1064
1160
  logger.error("🔍 [TRACE] ❌ Failed to send project_state_update: %s", e)
1065
1161
 
1066
- def cleanup_project(self, client_session_id: str):
1162
+ async def cleanup_project(self, client_session_id: str):
1067
1163
  """Clean up project state and resources."""
1068
- if client_session_id in self.projects:
1069
- project_state = self.projects[client_session_id]
1164
+ lock = self._get_session_lock(client_session_id)
1165
+ async with lock:
1166
+ project_folder_path = self._cleanup_project_locked(client_session_id)
1167
+ await self._release_shared_git_manager(project_folder_path, client_session_id)
1168
+
1169
+ def _cleanup_project_locked(self, client_session_id: str) -> Optional[str]:
1170
+ """Internal helper to release resources associated with a project state. Lock must be held."""
1171
+ project_state = self.projects.get(client_session_id)
1172
+ project_folder_path = project_state.project_folder_path if project_state else None
1173
+
1174
+ if project_state:
1175
+ # Cancel debounce timer to prevent pending refreshes running after cleanup
1176
+ if self._change_debounce_timer and not self._change_debounce_timer.done():
1177
+ try:
1178
+ self._change_debounce_timer.cancel()
1179
+ except Exception:
1180
+ pass
1181
+ # Remove pending file change events related to this project
1182
+ if self._pending_changes:
1183
+ removed = [path for path in list(self._pending_changes) if path.startswith(project_state.project_folder_path)]
1184
+ for path in removed:
1185
+ self._pending_changes.discard(path)
1186
+ self._pending_change_sources.pop(path, None)
1187
+ if removed:
1188
+ logger.debug("Removed %d pending change paths for session %s during cleanup", len(removed), client_session_id)
1070
1189
 
1071
1190
  # Stop watching all monitored folders for this project
1072
1191
  for monitored_folder in project_state.monitored_folders:
@@ -1077,34 +1196,34 @@ class ProjectStateManager:
1077
1196
  git_dir_path = os.path.join(project_state.project_folder_path, '.git')
1078
1197
  self.file_watcher.stop_watching(git_dir_path)
1079
1198
 
1080
- # Clean up managers
1081
- git_manager = self.git_managers.get(client_session_id)
1082
- if git_manager:
1083
- git_manager.cleanup()
1084
- self.git_managers.pop(client_session_id, None)
1085
1199
  self.projects.pop(client_session_id, None)
1086
-
1087
1200
  logger.info("Cleaned up project state: %s", client_session_id)
1088
- self._write_debug_state()
1201
+ else:
1202
+ logger.info("No project state found for client session: %s during cleanup", client_session_id)
1203
+
1204
+ # Clean up associated git manager even if project state was not registered
1205
+ # (actual cleanup occurs when shared git manager refcount drops to zero)
1206
+ self._write_debug_state()
1207
+ return project_folder_path
1089
1208
 
1090
- def cleanup_projects_by_client_session(self, client_session_id: str):
1209
+ async def cleanup_projects_by_client_session(self, client_session_id: str):
1091
1210
  """Clean up project state for a specific client session when explicitly notified of disconnection."""
1092
1211
  logger.info("Explicitly cleaning up project state for disconnected client session: %s", client_session_id)
1093
1212
 
1094
1213
  # With the new design, each client session has only one project
1095
1214
  if client_session_id in self.projects:
1096
- self.cleanup_project(client_session_id)
1215
+ await self.cleanup_project(client_session_id)
1097
1216
  logger.info("Cleaned up project state for client session: %s", client_session_id)
1098
1217
  else:
1099
1218
  logger.info("No project state found for client session: %s", client_session_id)
1100
1219
 
1101
- def cleanup_all_projects(self):
1220
+ async def cleanup_all_projects(self):
1102
1221
  """Clean up all project states. Used for shutdown or reset."""
1103
1222
  logger.info("Cleaning up all project states")
1104
1223
 
1105
1224
  client_session_ids = list(self.projects.keys())
1106
1225
  for client_session_id in client_session_ids:
1107
- self.cleanup_project(client_session_id)
1226
+ await self.cleanup_project(client_session_id)
1108
1227
 
1109
1228
  logger.info("Cleaned up %d project states", len(client_session_ids))
1110
1229
 
@@ -1121,13 +1240,13 @@ class ProjectStateManager:
1121
1240
  Path(file_path).relative_to(project_folder)
1122
1241
  # File is within this project, trigger refresh
1123
1242
  logger.info(f"Refreshing project state for session {client_session_id} after file change: {file_path}")
1124
- await self._refresh_project_state(client_session_id)
1243
+ await self._refresh_project_state(client_session_id, reason="manual_file_change")
1125
1244
  break
1126
1245
  except ValueError:
1127
1246
  # File is not within this project
1128
1247
  continue
1129
1248
 
1130
- def cleanup_orphaned_project_states(self, current_client_sessions: List[str]):
1249
+ async def cleanup_orphaned_project_states(self, current_client_sessions: List[str]):
1131
1250
  """Clean up project states that don't match any current client session."""
1132
1251
  current_sessions_set = set(current_client_sessions)
1133
1252
  orphaned_keys = []
@@ -1139,7 +1258,7 @@ class ProjectStateManager:
1139
1258
  if orphaned_keys:
1140
1259
  logger.info("Found %d orphaned project states, cleaning up: %s", len(orphaned_keys), orphaned_keys)
1141
1260
  for session_id in orphaned_keys:
1142
- self.cleanup_project(session_id)
1261
+ await self.cleanup_project(session_id)
1143
1262
  logger.info("Cleaned up %d orphaned project states", len(orphaned_keys))
1144
1263
  else:
1145
1264
  logger.debug("No orphaned project states found")
@@ -1183,6 +1302,12 @@ def get_or_create_project_state_manager(context: Dict[str, Any], control_channel
1183
1302
  return _global_project_state_manager
1184
1303
 
1185
1304
 
1305
+ def get_global_project_state_manager() -> Optional['ProjectStateManager']:
1306
+ """Return the current global project state manager if it exists."""
1307
+ with _manager_lock:
1308
+ return _global_project_state_manager
1309
+
1310
+
1186
1311
  def reset_global_project_state_manager():
1187
1312
  """Reset the global project state manager (for testing/cleanup)."""
1188
1313
  global _global_project_state_manager
@@ -1203,4 +1328,4 @@ def debug_global_manager_state():
1203
1328
  logger.info("Active project states: %s", list(_global_project_state_manager.projects.keys()))
1204
1329
  logger.info("Total project states: %d", len(_global_project_state_manager.projects))
1205
1330
  else:
1206
- logger.info("No global ProjectStateManager exists (PID: %s)", os.getpid())
1331
+ logger.info("No global ProjectStateManager exists (PID: %s)", os.getpid())