portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__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 (92) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +143 -17
  3. portacode/connection/client.py +149 -10
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
  5. portacode/connection/handlers/__init__.py +28 -1
  6. portacode/connection/handlers/base.py +78 -16
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -2185
  20. portacode/connection/handlers/proxmox_infra.py +361 -0
  21. portacode/connection/handlers/registry.py +15 -4
  22. portacode/connection/handlers/session.py +483 -32
  23. portacode/connection/handlers/system_handlers.py +147 -8
  24. portacode/connection/handlers/tab_factory.py +53 -46
  25. portacode/connection/handlers/terminal_handlers.py +21 -8
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +214 -24
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/static/js/test-ntp-clock.html +63 -0
  53. portacode/static/js/utils/ntp-clock.js +232 -0
  54. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  55. portacode/utils/__init__.py +1 -0
  56. portacode/utils/diff_apply.py +456 -0
  57. portacode/utils/diff_renderer.py +371 -0
  58. portacode/utils/ntp_clock.py +65 -0
  59. portacode-1.4.11.dev1.dist-info/METADATA +298 -0
  60. portacode-1.4.11.dev1.dist-info/RECORD +97 -0
  61. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
  62. portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
  63. test_modules/README.md +296 -0
  64. test_modules/__init__.py +1 -0
  65. test_modules/test_device_online.py +44 -0
  66. test_modules/test_file_operations.py +743 -0
  67. test_modules/test_git_status_ui.py +370 -0
  68. test_modules/test_login_flow.py +50 -0
  69. test_modules/test_navigate_testing_folder.py +361 -0
  70. test_modules/test_play_store_screenshots.py +294 -0
  71. test_modules/test_terminal_buffer_performance.py +261 -0
  72. test_modules/test_terminal_interaction.py +80 -0
  73. test_modules/test_terminal_loading_race_condition.py +95 -0
  74. test_modules/test_terminal_start.py +56 -0
  75. testing_framework/.env.example +21 -0
  76. testing_framework/README.md +334 -0
  77. testing_framework/__init__.py +17 -0
  78. testing_framework/cli.py +326 -0
  79. testing_framework/core/__init__.py +1 -0
  80. testing_framework/core/base_test.py +336 -0
  81. testing_framework/core/cli_manager.py +177 -0
  82. testing_framework/core/hierarchical_runner.py +577 -0
  83. testing_framework/core/playwright_manager.py +520 -0
  84. testing_framework/core/runner.py +447 -0
  85. testing_framework/core/shared_cli_manager.py +234 -0
  86. testing_framework/core/test_discovery.py +112 -0
  87. testing_framework/requirements.txt +12 -0
  88. portacode-0.3.19.dev4.dist-info/METADATA +0 -241
  89. portacode-0.3.19.dev4.dist-info/RECORD +0 -30
  90. portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
  91. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
  92. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,1502 @@
1
+ """Git management functionality for project state.
2
+
3
+ This module provides the GitManager class which handles all Git-related operations
4
+ including status checking, diff generation, file content retrieval, and Git commands
5
+ like staging, unstaging, and reverting files.
6
+ """
7
+
8
+ import asyncio
9
+ import hashlib
10
+ import logging
11
+ import os
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Union
16
+
17
+ from .models import GitDetailedStatus, GitFileChange
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Import GitPython with fallback
22
+ try:
23
+ import git
24
+ from git import Repo, InvalidGitRepositoryError
25
+ GIT_AVAILABLE = True
26
+ except ImportError:
27
+ GIT_AVAILABLE = False
28
+ git = None
29
+ Repo = None
30
+ InvalidGitRepositoryError = Exception
31
+
32
+ # Import diff-match-patch with fallback
33
+ try:
34
+ from diff_match_patch import diff_match_patch
35
+ DIFF_MATCH_PATCH_AVAILABLE = True
36
+ except ImportError:
37
+ DIFF_MATCH_PATCH_AVAILABLE = False
38
+ diff_match_patch = None
39
+
40
+ from portacode.utils import diff_renderer
41
+
42
+
43
+ _GIT_PROCESS_REF_COUNTS: Dict[int, int] = {}
44
+ _GIT_PROCESS_LOCK = threading.Lock()
45
+
46
+
47
+ class GitManager:
48
+ """Manages Git operations for project state."""
49
+
50
+ def __init__(self, project_path: str, change_callback: Optional[Callable] = None, owner_session_id: Optional[str] = None):
51
+ self.project_path = project_path
52
+ self.repo: Optional[Repo] = None
53
+ self.is_git_repo = False
54
+ self._change_callback = change_callback
55
+ self.owner_session_id = owner_session_id
56
+
57
+ # Track git processes spawned by this specific GitManager instance
58
+ self._tracked_git_processes = set()
59
+
60
+ # Periodic monitoring attributes
61
+ self._monitoring_task: Optional[asyncio.Task] = None
62
+ self._cached_status_summary: Optional[Dict[str, int]] = None
63
+ self._cached_detailed_status: Optional[GitDetailedStatus] = None
64
+ self._cached_branch: Optional[str] = None
65
+ self._monitoring_enabled = False
66
+
67
+ self._initialize_repo()
68
+
69
+ # Start monitoring if this is a git repo
70
+ if self.is_git_repo and change_callback:
71
+ self.start_periodic_monitoring()
72
+
73
+ def _initialize_repo(self):
74
+ """Initialize Git repository if available."""
75
+ if not GIT_AVAILABLE:
76
+ logger.warning("GitPython not available, Git features disabled")
77
+ return
78
+
79
+ try:
80
+ self.repo = Repo(self.project_path)
81
+ self.is_git_repo = True
82
+ logger.info("Initialized Git repo for project: %s", self.project_path)
83
+
84
+ # Track initial git processes after repo creation
85
+ self._track_current_git_processes()
86
+
87
+ except (InvalidGitRepositoryError, Exception) as e:
88
+ logger.debug("Not a Git repository or Git error: %s", e)
89
+
90
+ def _track_current_git_processes(self):
91
+ """Track currently running git cat-file processes for this repo."""
92
+ try:
93
+ import psutil
94
+
95
+ for proc in psutil.process_iter(['pid', 'cmdline', 'cwd']):
96
+ try:
97
+ cmdline = proc.info['cmdline']
98
+ if (cmdline and len(cmdline) >= 2 and
99
+ 'git' in cmdline[0] and 'cat-file' in cmdline[1] and
100
+ proc.info['cwd'] == self.project_path):
101
+ pid = proc.pid
102
+ self._tracked_git_processes.add(pid)
103
+ with _GIT_PROCESS_LOCK:
104
+ _GIT_PROCESS_REF_COUNTS[pid] = _GIT_PROCESS_REF_COUNTS.get(pid, 0) + 1
105
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
106
+ continue
107
+ except Exception as e:
108
+ logger.debug("Error tracking git processes: %s", e)
109
+
110
+ def reinitialize(self):
111
+ """Reinitialize git repo detection (useful when .git directory is created after initialization)."""
112
+ logger.info("Reinitializing git repo detection for: %s", self.project_path)
113
+
114
+ # Stop existing monitoring
115
+ self.stop_periodic_monitoring()
116
+
117
+ self.repo = None
118
+ self.is_git_repo = False
119
+ self._initialize_repo()
120
+
121
+ # Restart monitoring if this is now a git repo and we have a callback
122
+ if self.is_git_repo and self._change_callback:
123
+ self.start_periodic_monitoring()
124
+
125
+ def get_branch_name(self) -> Optional[str]:
126
+ """Get current Git branch name."""
127
+ if not self.is_git_repo or not self.repo:
128
+ return None
129
+
130
+ try:
131
+ return self.repo.active_branch.name
132
+ except Exception as e:
133
+ logger.debug("Could not get Git branch: %s", e)
134
+ return None
135
+
136
+ def _get_staging_status(self, file_path: str, rel_path: str) -> Union[bool, str]:
137
+ """Get staging status for a file or directory. Returns True, False, or 'mixed'."""
138
+ try:
139
+ if os.path.isdir(file_path):
140
+ # For directories, check all files within the directory
141
+ try:
142
+ # Get all staged files
143
+ staged_files = set(self.repo.git.diff('--cached', '--name-only').splitlines())
144
+ # Get all files with unstaged changes
145
+ unstaged_files = set(self.repo.git.diff('--name-only').splitlines())
146
+
147
+ # Find files within this directory
148
+ dir_staged_files = [f for f in staged_files if f.startswith(rel_path + '/') or f == rel_path]
149
+ dir_unstaged_files = [f for f in unstaged_files if f.startswith(rel_path + '/') or f == rel_path]
150
+
151
+ has_staged = len(dir_staged_files) > 0
152
+ has_unstaged = len(dir_unstaged_files) > 0
153
+
154
+ # Check for mixed staging within individual files in this directory
155
+ has_mixed_files = False
156
+ for staged_file in dir_staged_files:
157
+ if staged_file in dir_unstaged_files:
158
+ has_mixed_files = True
159
+ break
160
+
161
+ if has_mixed_files or (has_staged and has_unstaged):
162
+ return "mixed"
163
+ elif has_staged:
164
+ return True
165
+ else:
166
+ return False
167
+
168
+ except Exception:
169
+ return False
170
+ else:
171
+ # For individual files
172
+ try:
173
+ # Check if file has staged changes
174
+ staged_diff = self.repo.git.diff('--cached', '--name-only', rel_path)
175
+ has_staged = bool(staged_diff.strip())
176
+
177
+ if has_staged:
178
+ # Check if also has unstaged changes (mixed scenario)
179
+ unstaged_diff = self.repo.git.diff('--name-only', rel_path)
180
+ has_unstaged = bool(unstaged_diff.strip())
181
+ return "mixed" if has_unstaged else True
182
+ return False
183
+ except Exception:
184
+ return False
185
+ except Exception:
186
+ return False
187
+
188
+ def get_file_status(self, file_path: str) -> Dict[str, Any]:
189
+ """Get Git status for a specific file or directory."""
190
+ if not self.is_git_repo or not self.repo:
191
+ return {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
192
+
193
+ try:
194
+ rel_path = os.path.relpath(file_path, self.repo.working_dir)
195
+
196
+ # Get staging status for files and directories
197
+ is_staged = self._get_staging_status(file_path, rel_path)
198
+
199
+ # Check if ignored - GitPython handles path normalization internally
200
+ is_ignored = self.repo.ignored(rel_path)
201
+ if is_ignored:
202
+ return {"is_tracked": False, "status": "ignored", "is_ignored": True, "is_staged": False}
203
+
204
+ # For directories, aggregate status from contained files
205
+ if os.path.isdir(file_path):
206
+ # Normalize the relative path for cross-platform compatibility
207
+ rel_path_normalized = rel_path.replace('\\', '/')
208
+
209
+ # Check for untracked files in this directory
210
+ has_untracked = False
211
+ for untracked_file in self.repo.untracked_files:
212
+ untracked_normalized = untracked_file.replace('\\', '/')
213
+ if untracked_normalized.startswith(rel_path_normalized + '/') or untracked_normalized == rel_path_normalized:
214
+ has_untracked = True
215
+ break
216
+
217
+ # Check for modified files in this directory using git status
218
+ has_modified = False
219
+ has_deleted = False
220
+ try:
221
+ # Get status for files in this directory
222
+ status_output = self.repo.git.status(rel_path, porcelain=True)
223
+ if status_output.strip():
224
+ for line in status_output.strip().split('\n'):
225
+ if len(line) >= 2:
226
+ # When filtering git status by path, GitPython strips the leading space
227
+ # So format is either "XY filename" or " XY filename"
228
+ if line.startswith(' '):
229
+ # Full status format: " XY filename"
230
+ index_status = line[0]
231
+ worktree_status = line[1]
232
+ file_path_from_status = line[3:] if len(line) > 3 else ""
233
+ else:
234
+ # Path-filtered format: "XY filename" (leading space stripped)
235
+ # Two possible formats:
236
+ # 1. Regular files: "M filename" (index + worktree + space + filename)
237
+ # 2. Submodules: "M filename" (index + space + filename)
238
+ index_status = line[0] if len(line) > 0 else ' '
239
+ worktree_status = line[1] if len(line) > 1 else ' '
240
+
241
+ # Detect format by checking if position 2 is a space
242
+ if len(line) > 2 and line[2] == ' ':
243
+ # Regular file format: "M filename"
244
+ file_path_from_status = line[3:] if len(line) > 3 else ""
245
+ else:
246
+ # Submodule format: "M filename"
247
+ file_path_from_status = line[2:] if len(line) > 2 else ""
248
+
249
+ # Check if this file is within our directory
250
+ file_normalized = file_path_from_status.replace('\\', '/')
251
+ if (file_normalized.startswith(rel_path_normalized + '/') or
252
+ file_normalized == rel_path_normalized):
253
+ if index_status in ['M', 'A', 'R', 'C'] or worktree_status in ['M', 'A', 'R', 'C']:
254
+ has_modified = True
255
+ elif index_status == 'D' or worktree_status == 'D':
256
+ has_deleted = True
257
+ except Exception as e:
258
+ logger.debug("Error checking directory git status for %s: %s", rel_path, e)
259
+
260
+ # Priority order: untracked > modified/deleted > clean
261
+ if has_untracked:
262
+ return {"is_tracked": False, "status": "untracked", "is_ignored": False, "is_staged": is_staged}
263
+ elif has_deleted:
264
+ return {"is_tracked": True, "status": "deleted", "is_ignored": False, "is_staged": is_staged}
265
+ elif has_modified:
266
+ return {"is_tracked": True, "status": "modified", "is_ignored": False, "is_staged": is_staged}
267
+
268
+ # Check if directory has tracked files to determine if it should show as clean
269
+ try:
270
+ tracked_files = self.repo.git.ls_files(rel_path)
271
+ is_tracked = bool(tracked_files.strip())
272
+ status = "clean" if is_tracked else None
273
+ return {"is_tracked": is_tracked, "status": status, "is_ignored": False, "is_staged": is_staged}
274
+ except Exception:
275
+ return {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
276
+
277
+ # For files
278
+ else:
279
+ # Check if untracked - direct comparison works cross-platform
280
+ if rel_path in self.repo.untracked_files:
281
+ return {"is_tracked": False, "status": "untracked", "is_ignored": False, "is_staged": is_staged}
282
+
283
+ # If file is staged, we need to determine its original status
284
+ if is_staged:
285
+ # Check if this was originally an untracked file that got staged
286
+ # We need to check if the file existed in HEAD, not just in the index
287
+ try:
288
+ # Try to see if file existed in HEAD (was tracked before staging)
289
+ self.repo.git.show(f"HEAD:{rel_path}")
290
+ # If we get here, file existed in HEAD, so it was modified and staged
291
+ is_tracked = True
292
+ original_status = "modified"
293
+ except Exception:
294
+ # File didn't exist in HEAD, so it was untracked when staged (new file)
295
+ is_tracked = False
296
+ original_status = "added"
297
+
298
+ return {"is_tracked": is_tracked, "status": original_status, "is_ignored": False, "is_staged": is_staged}
299
+
300
+ # Check if tracked and dirty - GitPython handles path normalization
301
+ if self.repo.is_dirty(path=rel_path):
302
+ return {"is_tracked": True, "status": "modified", "is_ignored": False, "is_staged": is_staged}
303
+
304
+ # Check if tracked and clean - GitPython handles paths
305
+ try:
306
+ self.repo.git.ls_files(rel_path, error_unmatch=True)
307
+ return {"is_tracked": True, "status": "clean", "is_ignored": False, "is_staged": is_staged}
308
+ except Exception:
309
+ return {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
310
+
311
+ except Exception as e:
312
+ logger.debug("Error getting Git status for %s: %s", file_path, e)
313
+ return {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
314
+
315
+ def get_file_status_batch(self, file_paths: List[str]) -> Dict[str, Dict[str, Any]]:
316
+ """Get Git status for multiple files/directories at once (optimized batch operation).
317
+
318
+ Args:
319
+ file_paths: List of absolute file paths
320
+
321
+ Returns:
322
+ Dict mapping file_path to status dict: {"is_tracked": bool, "status": str, "is_ignored": bool, "is_staged": bool|"mixed"}
323
+ """
324
+ if not self.is_git_repo or not self.repo:
325
+ # Return empty status for all paths
326
+ return {path: {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
327
+ for path in file_paths}
328
+
329
+ result = {}
330
+
331
+ try:
332
+ # Convert all paths to relative paths
333
+ rel_paths_map = {} # abs_path -> rel_path
334
+ for file_path in file_paths:
335
+ try:
336
+ rel_path = os.path.relpath(file_path, self.repo.working_dir)
337
+ rel_paths_map[file_path] = rel_path
338
+ except Exception as e:
339
+ logger.debug("Error converting path %s to relative: %s", file_path, e)
340
+ result[file_path] = {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
341
+
342
+ rel_paths = list(rel_paths_map.values())
343
+
344
+ # BATCH OPERATION 1: Get all ignored paths at once
345
+ ignored_paths = set()
346
+ try:
347
+ ignored_list = self.repo.ignored(*rel_paths)
348
+ ignored_paths = set(ignored_list) if ignored_list else set()
349
+ except Exception as e:
350
+ logger.debug("Error checking ignored status for batch: %s", e)
351
+
352
+ # BATCH OPERATION 2: Get global git data once
353
+ untracked_files = set(self.repo.untracked_files)
354
+
355
+ try:
356
+ staged_files_output = self.repo.git.diff('--cached', '--name-only')
357
+ staged_files = set(staged_files_output.splitlines()) if staged_files_output.strip() else set()
358
+ except Exception:
359
+ staged_files = set()
360
+
361
+ try:
362
+ unstaged_files_output = self.repo.git.diff('--name-only')
363
+ unstaged_files = set(unstaged_files_output.splitlines()) if unstaged_files_output.strip() else set()
364
+ except Exception:
365
+ unstaged_files = set()
366
+
367
+ # BATCH OPERATION 3: Get status for all paths at once
368
+ status_map = {} # rel_path -> status_line
369
+ try:
370
+ status_output = self.repo.git.status(*rel_paths, porcelain=True)
371
+ if status_output.strip():
372
+ for line in status_output.strip().split('\n'):
373
+ # Git porcelain format: XY path (X=index, Y=worktree, then space, then path)
374
+ # Some files may have renamed format: XY path -> new_path
375
+ if len(line) >= 3:
376
+ # Skip first 3 characters (2 status + 1 space) to get the file path
377
+ # But git uses exactly 2 chars for status then space, so position 3 onwards is path
378
+ parts = line.split(None, 1) # Split on first whitespace to separate status from path
379
+ if len(parts) >= 2:
380
+ file_path_from_status = parts[1]
381
+ # Handle renames (format: "old_path -> new_path")
382
+ if ' -> ' in file_path_from_status:
383
+ file_path_from_status = file_path_from_status.split(' -> ')[1]
384
+ status_map[file_path_from_status] = line
385
+ except Exception as e:
386
+ logger.debug("Error getting batch status: %s", e)
387
+
388
+ # BATCH OPERATION 4: Get all tracked files
389
+ try:
390
+ tracked_files_output = self.repo.git.ls_files()
391
+ tracked_files = set(tracked_files_output.splitlines()) if tracked_files_output.strip() else set()
392
+ except Exception:
393
+ tracked_files = set()
394
+
395
+ # Process each file with the batch data
396
+ for file_path, rel_path in rel_paths_map.items():
397
+ try:
398
+ # Check if ignored
399
+ if rel_path in ignored_paths:
400
+ result[file_path] = {"is_tracked": False, "status": "ignored", "is_ignored": True, "is_staged": False}
401
+ continue
402
+
403
+ # Determine staging status
404
+ is_staged = self._get_staging_status_from_batch(
405
+ file_path, rel_path, staged_files, unstaged_files
406
+ )
407
+
408
+ # Handle directories
409
+ if os.path.isdir(file_path):
410
+ result[file_path] = self._get_directory_status_from_batch(
411
+ file_path, rel_path, untracked_files, status_map, tracked_files, is_staged
412
+ )
413
+ # Handle files
414
+ else:
415
+ result[file_path] = self._get_file_status_from_batch(
416
+ file_path, rel_path, untracked_files, staged_files, tracked_files, is_staged
417
+ )
418
+
419
+ except Exception as e:
420
+ logger.debug("Error processing status for %s: %s", file_path, e)
421
+ result[file_path] = {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
422
+
423
+ # Fill in any missing paths with default status
424
+ for file_path in file_paths:
425
+ if file_path not in result:
426
+ result[file_path] = {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
427
+
428
+ except Exception as e:
429
+ logger.error("Error in get_file_status_batch: %s", e)
430
+ # Return default status for all paths on error
431
+ for file_path in file_paths:
432
+ if file_path not in result:
433
+ result[file_path] = {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
434
+
435
+ return result
436
+
437
+ def _get_staging_status_from_batch(self, file_path: str, rel_path: str,
438
+ staged_files: set, unstaged_files: set) -> Union[bool, str]:
439
+ """Get staging status using pre-fetched batch data."""
440
+ try:
441
+ if os.path.isdir(file_path):
442
+ # For directories, check files within
443
+ dir_staged_files = [f for f in staged_files if f.startswith(rel_path + '/') or f == rel_path]
444
+ dir_unstaged_files = [f for f in unstaged_files if f.startswith(rel_path + '/') or f == rel_path]
445
+
446
+ has_staged = len(dir_staged_files) > 0
447
+ has_unstaged = len(dir_unstaged_files) > 0
448
+
449
+ # Check for mixed staging
450
+ has_mixed_files = any(f in dir_unstaged_files for f in dir_staged_files)
451
+
452
+ if has_mixed_files or (has_staged and has_unstaged):
453
+ return "mixed"
454
+ elif has_staged:
455
+ return True
456
+ else:
457
+ return False
458
+ else:
459
+ # For files
460
+ has_staged = rel_path in staged_files
461
+ has_unstaged = rel_path in unstaged_files
462
+
463
+ if has_staged and has_unstaged:
464
+ return "mixed"
465
+ elif has_staged:
466
+ return True
467
+ else:
468
+ return False
469
+ except Exception:
470
+ return False
471
+
472
+ def _get_directory_status_from_batch(self, file_path: str, rel_path: str,
473
+ untracked_files: set, status_map: dict,
474
+ tracked_files: set, is_staged: Union[bool, str]) -> Dict[str, Any]:
475
+ """Get directory status using pre-fetched batch data."""
476
+ try:
477
+ rel_path_normalized = rel_path.replace('\\', '/')
478
+
479
+ # Check for untracked files in this directory
480
+ has_untracked = any(
481
+ f.replace('\\', '/').startswith(rel_path_normalized + '/') or f.replace('\\', '/') == rel_path_normalized
482
+ for f in untracked_files
483
+ )
484
+
485
+ # Check for modified/deleted files using status map
486
+ has_modified = False
487
+ has_deleted = False
488
+
489
+ for status_file_path, status_line in status_map.items():
490
+ if len(status_line) >= 2:
491
+ file_normalized = status_file_path.replace('\\', '/')
492
+ if file_normalized.startswith(rel_path_normalized + '/') or file_normalized == rel_path_normalized:
493
+ index_status = status_line[0] if len(status_line) > 0 else ' '
494
+ worktree_status = status_line[1] if len(status_line) > 1 else ' '
495
+
496
+ if index_status in ['M', 'A', 'R', 'C'] or worktree_status in ['M', 'A', 'R', 'C']:
497
+ has_modified = True
498
+ elif index_status == 'D' or worktree_status == 'D':
499
+ has_deleted = True
500
+
501
+ if has_untracked:
502
+ return {"is_tracked": False, "status": "untracked", "is_ignored": False, "is_staged": is_staged}
503
+ elif has_deleted:
504
+ return {"is_tracked": True, "status": "deleted", "is_ignored": False, "is_staged": is_staged}
505
+ elif has_modified:
506
+ return {"is_tracked": True, "status": "modified", "is_ignored": False, "is_staged": is_staged}
507
+
508
+ # Check if directory has tracked files
509
+ has_tracked = any(
510
+ f.replace('\\', '/').startswith(rel_path_normalized + '/') or f.replace('\\', '/') == rel_path_normalized
511
+ for f in tracked_files
512
+ )
513
+
514
+ status = "clean" if has_tracked else None
515
+ return {"is_tracked": has_tracked, "status": status, "is_ignored": False, "is_staged": is_staged}
516
+
517
+ except Exception as e:
518
+ logger.debug("Error getting directory status for %s: %s", file_path, e)
519
+ return {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
520
+
521
+ def _get_file_status_from_batch(self, file_path: str, rel_path: str,
522
+ untracked_files: set, staged_files: set,
523
+ tracked_files: set, is_staged: Union[bool, str]) -> Dict[str, Any]:
524
+ """Get file status using pre-fetched batch data."""
525
+ try:
526
+ # Check if untracked
527
+ if rel_path in untracked_files:
528
+ return {"is_tracked": False, "status": "untracked", "is_ignored": False, "is_staged": is_staged}
529
+
530
+ # If file is staged, determine original status
531
+ if is_staged:
532
+ # Check if file existed in HEAD
533
+ try:
534
+ self.repo.git.show(f"HEAD:{rel_path}")
535
+ # File existed in HEAD
536
+ return {"is_tracked": True, "status": "modified", "is_ignored": False, "is_staged": is_staged}
537
+ except Exception:
538
+ # File didn't exist in HEAD (new file)
539
+ return {"is_tracked": False, "status": "added", "is_ignored": False, "is_staged": is_staged}
540
+
541
+ # Check if tracked and dirty
542
+ try:
543
+ if self.repo.is_dirty(path=rel_path):
544
+ return {"is_tracked": True, "status": "modified", "is_ignored": False, "is_staged": is_staged}
545
+ except Exception:
546
+ pass
547
+
548
+ # Check if tracked and clean
549
+ if rel_path in tracked_files:
550
+ return {"is_tracked": True, "status": "clean", "is_ignored": False, "is_staged": is_staged}
551
+
552
+ return {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
553
+
554
+ except Exception as e:
555
+ logger.debug("Error getting file status for %s: %s", file_path, e)
556
+ return {"is_tracked": False, "status": None, "is_ignored": False, "is_staged": False}
557
+
558
+ def get_status_summary(self) -> Dict[str, int]:
559
+ """Get summary of Git status."""
560
+ if not self.is_git_repo or not self.repo:
561
+ return {}
562
+
563
+ try:
564
+ status = self.repo.git.status(porcelain=True).strip()
565
+ if not status:
566
+ return {"clean": 0}
567
+
568
+ summary = {"modified": 0, "deleted": 0, "untracked": 0}
569
+
570
+ for line in status.split('\n'):
571
+ if len(line) >= 2:
572
+ index_status = line[0]
573
+ worktree_status = line[1]
574
+
575
+ # Count A (added) as untracked since they represent originally untracked files
576
+ if index_status == 'A' or worktree_status == 'A':
577
+ summary["untracked"] += 1
578
+ elif index_status == 'M' or worktree_status == 'M':
579
+ summary["modified"] += 1
580
+ elif index_status == 'D' or worktree_status == 'D':
581
+ summary["deleted"] += 1
582
+ elif index_status == '?' and worktree_status == '?':
583
+ summary["untracked"] += 1
584
+
585
+ return summary
586
+
587
+ except Exception as e:
588
+ logger.debug("Error getting Git status summary: %s", e)
589
+ return {}
590
+
591
+ def _compute_file_hash(self, file_path: str) -> Optional[str]:
592
+ """Compute SHA256 hash of file content."""
593
+ try:
594
+ with open(file_path, 'rb') as f:
595
+ file_hash = hashlib.sha256()
596
+ chunk = f.read(8192)
597
+ while chunk:
598
+ file_hash.update(chunk)
599
+ chunk = f.read(8192)
600
+ return file_hash.hexdigest()
601
+ except (OSError, IOError) as e:
602
+ logger.debug("Error computing hash for %s: %s", file_path, e)
603
+ return None
604
+
605
+ def _compute_diff_details(self, original_content: str, modified_content: str) -> Optional[Dict[str, Any]]:
606
+ """Compute per-character diff details using diff-match-patch."""
607
+ if not DIFF_MATCH_PATCH_AVAILABLE:
608
+ logger.debug("diff-match-patch not available, skipping diff details computation")
609
+ return None
610
+
611
+ # Add performance safeguards to prevent blocking
612
+ max_content_size = 50000 # 50KB max per file for diff details
613
+ if len(original_content) > max_content_size or len(modified_content) > max_content_size:
614
+ logger.debug("File too large for diff details computation")
615
+ return None
616
+
617
+ try:
618
+ dmp = diff_match_patch()
619
+
620
+ # Set timeout for diff computation
621
+ dmp.Diff_Timeout = 1.0 # 1 second timeout
622
+
623
+ # Compute the diff
624
+ diffs = dmp.diff_main(original_content, modified_content)
625
+
626
+ # Clean up the diff for efficiency
627
+ dmp.diff_cleanupSemantic(diffs)
628
+
629
+ # Convert the diff to a serializable format
630
+ diff_data = []
631
+ for operation, text in diffs:
632
+ diff_data.append({
633
+ "operation": operation, # -1 = delete, 0 = equal, 1 = insert
634
+ "text": text
635
+ })
636
+
637
+ # Also compute some useful statistics
638
+ char_additions = sum(len(text) for op, text in diffs if op == 1)
639
+ char_deletions = sum(len(text) for op, text in diffs if op == -1)
640
+ char_unchanged = sum(len(text) for op, text in diffs if op == 0)
641
+
642
+ return {
643
+ "diffs": diff_data,
644
+ "stats": {
645
+ "char_additions": char_additions,
646
+ "char_deletions": char_deletions,
647
+ "char_unchanged": char_unchanged,
648
+ "total_changes": char_additions + char_deletions
649
+ },
650
+ "algorithm": "diff-match-patch"
651
+ }
652
+
653
+ except Exception as e:
654
+ logger.error("Error computing diff details: %s", e)
655
+ return None
656
+
657
+ def _generate_html_diff(self, original_content: str, modified_content: str, file_path: str) -> Optional[Dict[str, str]]:
658
+ """Proxy to the stateless diff renderer so tab views keep their HTML output."""
659
+ return diff_renderer.generate_html_diff(original_content, modified_content, file_path)
660
+
661
+ def get_head_commit_hash(self) -> Optional[str]:
662
+ """Get the hash of the HEAD commit."""
663
+ if not self.is_git_repo or not self.repo:
664
+ return None
665
+
666
+ try:
667
+ return self.repo.head.commit.hexsha
668
+ except Exception as e:
669
+ logger.debug("Error getting HEAD commit hash: %s", e)
670
+ return None
671
+
672
+
673
+ def get_detailed_status(self) -> GitDetailedStatus:
674
+ """Get detailed Git status with file hashes using GitPython APIs."""
675
+ if not self.is_git_repo or not self.repo:
676
+ return GitDetailedStatus()
677
+
678
+ try:
679
+ detailed_status = GitDetailedStatus()
680
+ detailed_status.head_commit_hash = self.get_head_commit_hash()
681
+
682
+ # Get all changed files using GitPython's index diff
683
+ # Get staged changes (index vs HEAD)
684
+ # Handle case where repository has no commits (no HEAD)
685
+ try:
686
+ staged_files = self.repo.index.diff("HEAD")
687
+ except Exception as e:
688
+ logger.debug("🔍 [TRACE] No HEAD found (likely no commits), using staged-only detection: %s", e)
689
+ # When no HEAD exists, we need to get staged files differently
690
+ staged_file_names = []
691
+ try:
692
+ staged_output = self.repo.git.diff('--cached', '--name-only')
693
+ staged_file_names = staged_output.splitlines() if staged_output.strip() else []
694
+ except Exception:
695
+ staged_file_names = []
696
+ logger.debug("🔍 [TRACE] Found %d staged files in no-HEAD repo: %s", len(staged_file_names), staged_file_names)
697
+
698
+ # Create staged file changes manually for no-HEAD repos
699
+ for file_repo_path in staged_file_names:
700
+ file_abs_path = os.path.join(self.project_path, file_repo_path)
701
+ file_name = os.path.basename(file_repo_path)
702
+ content_hash = self._compute_file_hash(file_abs_path) if os.path.exists(file_abs_path) else None
703
+
704
+ # For staged files in no-HEAD repo, they are all "added" (new files)
705
+ diff_details = None
706
+
707
+ change = GitFileChange(
708
+ file_repo_path=file_repo_path,
709
+ file_name=file_name,
710
+ file_abs_path=file_abs_path,
711
+ change_type='added',
712
+ content_hash=content_hash,
713
+ is_staged=True,
714
+ diff_details=diff_details
715
+ )
716
+ logger.debug("🔍 [TRACE] Created staged change for no-HEAD repo: %s (added)", file_name)
717
+ detailed_status.staged_changes.append(change)
718
+
719
+ # Skip the normal staged files loop since we handled it above
720
+ staged_files = []
721
+ # Get git status --porcelain for accurate change types (GitPython diff can be buggy)
722
+ try:
723
+ porcelain_status = self.repo.git.status(porcelain=True).strip()
724
+ porcelain_map = {}
725
+ if porcelain_status:
726
+ for line in porcelain_status.split('\n'):
727
+ if len(line) >= 3:
728
+ index_status = line[0]
729
+ file_path = line[3:]
730
+ porcelain_map[file_path] = index_status
731
+ except Exception:
732
+ porcelain_map = {}
733
+
734
+ for diff_item in staged_files:
735
+ file_repo_path = diff_item.a_path or diff_item.b_path
736
+ file_abs_path = os.path.join(self.project_path, file_repo_path)
737
+ file_name = os.path.basename(file_repo_path)
738
+
739
+ # Determine change type - use porcelain status for accuracy, fall back to GitPython
740
+ porcelain_status = porcelain_map.get(file_repo_path, '')
741
+ if porcelain_status == 'A':
742
+ change_type = 'untracked'
743
+ elif porcelain_status == 'M':
744
+ change_type = 'modified'
745
+ elif porcelain_status == 'D':
746
+ change_type = 'deleted'
747
+ else:
748
+ # Fall back to GitPython detection
749
+ if diff_item.deleted_file:
750
+ change_type = 'deleted'
751
+ elif diff_item.new_file:
752
+ change_type = 'untracked'
753
+ else:
754
+ change_type = 'modified'
755
+
756
+ # Set content hash and diff details based on change type
757
+ if change_type == 'deleted':
758
+ content_hash = None
759
+ diff_details = None # No diff for deleted files
760
+ elif change_type == 'untracked':
761
+ content_hash = self._compute_file_hash(file_abs_path) if os.path.exists(file_abs_path) else None
762
+ # For new files, compare empty content vs current staged content
763
+ diff_details = None
764
+ else: # modified
765
+ content_hash = self._compute_file_hash(file_abs_path) if os.path.exists(file_abs_path) else None
766
+ # Compare HEAD content vs staged content
767
+ diff_details = None
768
+
769
+ change = GitFileChange(
770
+ file_repo_path=file_repo_path,
771
+ file_name=file_name,
772
+ file_abs_path=file_abs_path,
773
+ change_type=change_type,
774
+ content_hash=content_hash,
775
+ is_staged=True,
776
+ diff_details=diff_details
777
+ )
778
+ logger.debug("Created staged change for: %s (%s)", file_name, change_type)
779
+ detailed_status.staged_changes.append(change)
780
+
781
+ # Get unstaged changes (working tree vs index)
782
+ try:
783
+ unstaged_files = self.repo.index.diff(None)
784
+ except Exception as e:
785
+ logger.debug("🔍 [TRACE] Error getting unstaged files: %s", e)
786
+ unstaged_files = []
787
+ for diff_item in unstaged_files:
788
+ file_repo_path = diff_item.a_path or diff_item.b_path
789
+ file_abs_path = os.path.join(self.project_path, file_repo_path)
790
+ file_name = os.path.basename(file_repo_path)
791
+
792
+ # Determine change type - stick to git's native types
793
+ if diff_item.deleted_file:
794
+ change_type = 'deleted'
795
+ content_hash = None
796
+ diff_details = None # No diff for deleted files
797
+ elif diff_item.new_file:
798
+ change_type = 'added'
799
+ content_hash = self._compute_file_hash(file_abs_path) if os.path.exists(file_abs_path) else None
800
+ # For new files, compare empty content vs current working content
801
+ diff_details = None
802
+ else:
803
+ change_type = 'modified'
804
+ content_hash = self._compute_file_hash(file_abs_path) if os.path.exists(file_abs_path) else None
805
+ # Compare staged/index content vs working content
806
+ diff_details = None
807
+
808
+ change = GitFileChange(
809
+ file_repo_path=file_repo_path,
810
+ file_name=file_name,
811
+ file_abs_path=file_abs_path,
812
+ change_type=change_type,
813
+ content_hash=content_hash,
814
+ is_staged=False,
815
+ diff_details=diff_details
816
+ )
817
+ logger.debug("Created unstaged change for: %s (%s)", file_name, change_type)
818
+ detailed_status.unstaged_changes.append(change)
819
+
820
+ # Get untracked files
821
+ try:
822
+ untracked_files = self.repo.untracked_files
823
+ logger.debug("🔍 [TRACE] Processing %d untracked files: %s", len(untracked_files), untracked_files)
824
+ except Exception as e:
825
+ logger.debug("🔍 [TRACE] Error getting untracked files: %s", e)
826
+ untracked_files = []
827
+ for file_repo_path in untracked_files:
828
+ file_abs_path = os.path.join(self.project_path, file_repo_path)
829
+ file_name = os.path.basename(file_repo_path)
830
+ content_hash = self._compute_file_hash(file_abs_path) if os.path.exists(file_abs_path) else None
831
+
832
+ # For untracked files, compare empty content vs current file content
833
+ diff_details = None
834
+
835
+ change = GitFileChange(
836
+ file_repo_path=file_repo_path,
837
+ file_name=file_name,
838
+ file_abs_path=file_abs_path,
839
+ change_type='untracked',
840
+ content_hash=content_hash,
841
+ is_staged=False,
842
+ diff_details=diff_details
843
+ )
844
+ logger.debug("🔍 [TRACE] Created untracked change for: %s", file_name)
845
+ detailed_status.untracked_files.append(change)
846
+
847
+ logger.debug("🔍 [TRACE] Returning detailed_status with %d staged, %d unstaged, %d untracked",
848
+ len(detailed_status.staged_changes),
849
+ len(detailed_status.unstaged_changes),
850
+ len(detailed_status.untracked_files))
851
+ return detailed_status
852
+
853
+ except Exception as e:
854
+ logger.error("Error getting detailed Git status: %s", e)
855
+ return GitDetailedStatus()
856
+
857
+ def _get_change_type(self, status_char: str) -> str:
858
+ """Convert git status character to change type."""
859
+ status_map = {
860
+ 'A': 'added',
861
+ 'M': 'modified',
862
+ 'D': 'deleted',
863
+ 'R': 'renamed',
864
+ 'C': 'copied',
865
+ 'U': 'unmerged',
866
+ '?': 'untracked'
867
+ }
868
+ return status_map.get(status_char, 'unknown')
869
+
870
+ def get_file_content_at_commit(self, file_path: str, commit_hash: Optional[str] = None) -> Optional[str]:
871
+ """Get file content at a specific commit. If commit_hash is None, gets HEAD content."""
872
+ if not self.is_git_repo or not self.repo:
873
+ return None
874
+
875
+ try:
876
+ if commit_hash is None:
877
+ commit_hash = 'HEAD'
878
+
879
+ # Convert to relative path from repo root
880
+ rel_path = os.path.relpath(file_path, self.repo.working_dir)
881
+
882
+ # Get file content at the specified commit
883
+ try:
884
+ content = self.repo.git.show(f"{commit_hash}:{rel_path}")
885
+ return content
886
+ except Exception as e:
887
+ logger.debug("File %s not found at commit %s: %s", rel_path, commit_hash, e)
888
+ return None
889
+
890
+ except Exception as e:
891
+ logger.error("Error getting file content at commit %s for %s: %s", commit_hash, file_path, e)
892
+ return None
893
+
894
+ def get_file_content_staged(self, file_path: str) -> Optional[str]:
895
+ """Get staged content of a file."""
896
+ if not self.is_git_repo or not self.repo:
897
+ return None
898
+
899
+ try:
900
+ # Convert to relative path from repo root
901
+ rel_path = os.path.relpath(file_path, self.repo.working_dir)
902
+
903
+ # Get staged content
904
+ try:
905
+ content = self.repo.git.show(f":{rel_path}")
906
+ return content
907
+ except Exception as e:
908
+ logger.debug("File %s not found in staging area: %s", rel_path, e)
909
+ return None
910
+
911
+ except Exception as e:
912
+ logger.error("Error getting staged content for %s: %s", file_path, e)
913
+ return None
914
+
915
+ def _is_submodule(self, file_path: str) -> bool:
916
+ """Check if the given path is a submodule."""
917
+ if not self.is_git_repo or not self.repo:
918
+ return False
919
+
920
+ try:
921
+ # Convert to relative path from repo root
922
+ rel_path = os.path.relpath(file_path, self.repo.working_dir)
923
+
924
+ # Check if this path is listed in .gitmodules
925
+ gitmodules_path = os.path.join(self.repo.working_dir, '.gitmodules')
926
+ if os.path.exists(gitmodules_path):
927
+ try:
928
+ with open(gitmodules_path, 'r') as f:
929
+ content = f.read()
930
+ # Simple check - look for path = rel_path in .gitmodules
931
+ for line in content.splitlines():
932
+ if line.strip().startswith('path ='):
933
+ submodule_path = line.split('=', 1)[1].strip()
934
+ if submodule_path == rel_path:
935
+ return True
936
+ except Exception as e:
937
+ logger.warning("Error reading .gitmodules: %s", e)
938
+
939
+ # Alternative check: see if the path has a .git file (submodule indicator)
940
+ git_path = os.path.join(file_path, '.git')
941
+ if os.path.isfile(git_path): # Submodules have .git as a file, not directory
942
+ return True
943
+
944
+ return False
945
+
946
+ except Exception as e:
947
+ logger.warning("Error checking if %s is submodule: %s", file_path, e)
948
+ return False
949
+
950
+ def stage_file(self, file_path: str) -> bool:
951
+ """Stage a file for commit."""
952
+ if not self.is_git_repo or not self.repo:
953
+ raise RuntimeError("Not a git repository")
954
+
955
+ try:
956
+ # Convert to relative path from repo root
957
+ rel_path = os.path.relpath(file_path, self.repo.working_dir)
958
+
959
+ # Check if this is a submodule
960
+ if self._is_submodule(file_path):
961
+ logger.info("Detected submodule, using git add command directly: %s", rel_path)
962
+ # For submodules, use git add directly to stage only the submodule reference
963
+ self.repo.git.add(rel_path)
964
+ else:
965
+ # Use git add -A on the specific file to handle deletions as well
966
+ self.repo.git.add('-A', '--', rel_path)
967
+
968
+ logger.info("Successfully staged file: %s", rel_path)
969
+ return True
970
+
971
+ except Exception as e:
972
+ logger.error("Error staging file %s: %s", file_path, e)
973
+ raise RuntimeError(f"Failed to stage file: {e}")
974
+
975
+ def unstage_file(self, file_path: str) -> bool:
976
+ """Unstage a file (remove from staging area)."""
977
+ if not self.is_git_repo or not self.repo:
978
+ raise RuntimeError("Not a git repository")
979
+
980
+ try:
981
+ # Convert to relative path from repo root
982
+ rel_path = os.path.relpath(file_path, self.repo.working_dir)
983
+
984
+ # Check if this is a submodule
985
+ if self._is_submodule(file_path):
986
+ logger.info("Detected submodule, using git restore for unstaging: %s", rel_path)
987
+ # For submodules, always use git restore --staged (works with submodules)
988
+ self.repo.git.restore('--staged', rel_path)
989
+ else:
990
+ # Check if repository has any commits (HEAD exists)
991
+ try:
992
+ self.repo.head.commit
993
+ has_head = True
994
+ except Exception:
995
+ has_head = False
996
+
997
+ if has_head:
998
+ # Reset the file from HEAD (unstage) - for repos with commits
999
+ self.repo.git.restore('--staged', rel_path)
1000
+ else:
1001
+ # For repositories with no commits, use git rm --cached to unstage
1002
+ self.repo.git.rm('--cached', rel_path)
1003
+
1004
+ logger.info("Successfully unstaged file: %s", rel_path)
1005
+ return True
1006
+
1007
+ except Exception as e:
1008
+ logger.error("Error unstaging file %s: %s", file_path, e)
1009
+ raise RuntimeError(f"Failed to unstage file: {e}")
1010
+
1011
+ def revert_file(self, file_path: str) -> bool:
1012
+ """Revert a file to its HEAD version (discard local changes)."""
1013
+ if not self.is_git_repo or not self.repo:
1014
+ raise RuntimeError("Not a git repository")
1015
+
1016
+ try:
1017
+ # Convert to relative path from repo root
1018
+ rel_path = os.path.relpath(file_path, self.repo.working_dir)
1019
+
1020
+ # Check if repository has any commits (HEAD exists)
1021
+ try:
1022
+ self.repo.head.commit
1023
+ has_head = True
1024
+ except Exception:
1025
+ has_head = False
1026
+
1027
+ if has_head:
1028
+ # Restore both index and working tree from HEAD
1029
+ self.repo.git.restore('--staged', '--worktree', '--', rel_path)
1030
+ logger.info("Successfully reverted file: %s", rel_path)
1031
+ else:
1032
+ # For repositories with no commits, we can't revert to HEAD
1033
+ # Instead, just remove the file to "revert" it to non-existence
1034
+ if os.path.exists(file_path):
1035
+ os.remove(file_path)
1036
+ logger.info("Successfully removed file (no HEAD to revert to): %s", rel_path)
1037
+ else:
1038
+ logger.info("File already does not exist (no HEAD to revert to): %s", rel_path)
1039
+
1040
+ return True
1041
+
1042
+ except Exception as e:
1043
+ logger.error("Error reverting file %s: %s", file_path, e)
1044
+ raise RuntimeError(f"Failed to revert file: {e}")
1045
+
1046
+ def stage_files(self, file_paths: List[str]) -> bool:
1047
+ """Stage multiple files for commit in one atomic operation."""
1048
+ if not self.is_git_repo or not self.repo:
1049
+ raise RuntimeError("Not a git repository")
1050
+
1051
+ if not file_paths:
1052
+ logger.info("No files provided for staging")
1053
+ return True
1054
+
1055
+ try:
1056
+ # Convert all paths to relative paths from repo root
1057
+ rel_paths = []
1058
+ submodule_paths = []
1059
+
1060
+ for file_path in file_paths:
1061
+ rel_path = os.path.relpath(file_path, self.repo.working_dir)
1062
+ if self._is_submodule(file_path):
1063
+ submodule_paths.append(rel_path)
1064
+ else:
1065
+ rel_paths.append(rel_path)
1066
+
1067
+ # Stage submodules using git add directly
1068
+ if submodule_paths:
1069
+ logger.info("Staging submodules using git add directly: %s", submodule_paths)
1070
+ for submodule_path in submodule_paths:
1071
+ self.repo.git.add(submodule_path)
1072
+
1073
+ # Stage regular files using git add -A to capture deletions
1074
+ if rel_paths:
1075
+ logger.info("Staging regular files: %s", rel_paths)
1076
+ self.repo.git.add('-A', '--', *rel_paths)
1077
+
1078
+ logger.info("Successfully staged %d files (%d submodules, %d regular)",
1079
+ len(file_paths), len(submodule_paths), len(rel_paths))
1080
+ return True
1081
+
1082
+ except Exception as e:
1083
+ logger.error("Error staging files %s: %s", file_paths, e)
1084
+ raise RuntimeError(f"Failed to stage files: {e}")
1085
+
1086
+ def unstage_files(self, file_paths: List[str]) -> bool:
1087
+ """Unstage multiple files in one atomic operation."""
1088
+ if not self.is_git_repo or not self.repo:
1089
+ raise RuntimeError("Not a git repository")
1090
+
1091
+ if not file_paths:
1092
+ logger.info("No files provided for unstaging")
1093
+ return True
1094
+
1095
+ try:
1096
+ # Convert all paths to relative paths from repo root
1097
+ rel_paths = []
1098
+ submodule_paths = []
1099
+
1100
+ for file_path in file_paths:
1101
+ rel_path = os.path.relpath(file_path, self.repo.working_dir)
1102
+ if self._is_submodule(file_path):
1103
+ submodule_paths.append(rel_path)
1104
+ else:
1105
+ rel_paths.append(rel_path)
1106
+
1107
+ # Check if repository has any commits (HEAD exists)
1108
+ try:
1109
+ self.repo.head.commit
1110
+ has_head = True
1111
+ except Exception:
1112
+ has_head = False
1113
+
1114
+ # Unstage all files using appropriate method
1115
+ all_rel_paths = rel_paths + submodule_paths
1116
+
1117
+ if has_head:
1118
+ # Use git restore --staged for all files (works for both regular files and submodules)
1119
+ if all_rel_paths:
1120
+ self.repo.git.restore('--staged', *all_rel_paths)
1121
+ else:
1122
+ # For repositories with no commits, use git rm --cached
1123
+ if all_rel_paths:
1124
+ self.repo.git.rm('--cached', *all_rel_paths)
1125
+
1126
+ logger.info("Successfully unstaged %d files (%d submodules, %d regular)",
1127
+ len(file_paths), len(submodule_paths), len(rel_paths))
1128
+ return True
1129
+
1130
+ except Exception as e:
1131
+ logger.error("Error unstaging files %s: %s", file_paths, e)
1132
+ raise RuntimeError(f"Failed to unstage files: {e}")
1133
+
1134
+ def revert_files(self, file_paths: List[str]) -> bool:
1135
+ """Revert multiple files to their HEAD version in one atomic operation."""
1136
+ if not self.is_git_repo or not self.repo:
1137
+ raise RuntimeError("Not a git repository")
1138
+
1139
+ if not file_paths:
1140
+ logger.info("No files provided for reverting")
1141
+ return True
1142
+
1143
+ try:
1144
+ # Check if repository has any commits (HEAD exists)
1145
+ try:
1146
+ self.repo.head.commit
1147
+ has_head = True
1148
+ except Exception:
1149
+ has_head = False
1150
+
1151
+ if has_head:
1152
+ # Convert to relative paths and restore all files at once
1153
+ rel_paths = [os.path.relpath(file_path, self.repo.working_dir) for file_path in file_paths]
1154
+ # Filter out submodules - we don't revert submodules as they don't have working directory changes
1155
+ regular_files = []
1156
+ for i, file_path in enumerate(file_paths):
1157
+ if not self._is_submodule(file_path):
1158
+ regular_files.append(rel_paths[i])
1159
+
1160
+ if regular_files:
1161
+ self.repo.git.restore('--staged', '--worktree', '--', *regular_files)
1162
+ logger.info("Successfully reverted %d files", len(regular_files))
1163
+ else:
1164
+ # For repositories with no commits, remove files to "revert" them
1165
+ removed_count = 0
1166
+ for file_path in file_paths:
1167
+ if not self._is_submodule(file_path) and os.path.exists(file_path):
1168
+ os.remove(file_path)
1169
+ removed_count += 1
1170
+ logger.info("Successfully removed %d files (no HEAD to revert to)", removed_count)
1171
+
1172
+ return True
1173
+
1174
+ except Exception as e:
1175
+ logger.error("Error reverting files %s: %s", file_paths, e)
1176
+ raise RuntimeError(f"Failed to revert files: {e}")
1177
+
1178
+ def stage_all_changes(self) -> bool:
1179
+ """Stage all changes (modified, deleted, untracked) in one atomic operation."""
1180
+ if not self.is_git_repo or not self.repo:
1181
+ raise RuntimeError("Not a git repository")
1182
+
1183
+ try:
1184
+ # Use git add . to stage everything - this is the most efficient way
1185
+ self.repo.git.add('.')
1186
+ logger.info("Successfully staged all changes using 'git add .'")
1187
+ return True
1188
+
1189
+ except Exception as e:
1190
+ logger.error("Error staging all changes: %s", e)
1191
+ raise RuntimeError(f"Failed to stage all changes: {e}")
1192
+
1193
+ def unstage_all_changes(self) -> bool:
1194
+ """Unstage all staged changes in one atomic operation."""
1195
+ if not self.is_git_repo or not self.repo:
1196
+ raise RuntimeError("Not a git repository")
1197
+
1198
+ try:
1199
+ # Check if repository has any commits (HEAD exists)
1200
+ try:
1201
+ self.repo.head.commit
1202
+ has_head = True
1203
+ except Exception:
1204
+ has_head = False
1205
+
1206
+ if has_head:
1207
+ # Use git restore --staged . to unstage everything
1208
+ self.repo.git.restore('--staged', '.')
1209
+ else:
1210
+ # For repositories with no commits, remove everything from index
1211
+ self.repo.git.rm('--cached', '-r', '.')
1212
+
1213
+ logger.info("Successfully unstaged all changes")
1214
+ return True
1215
+
1216
+ except Exception as e:
1217
+ logger.error("Error unstaging all changes: %s", e)
1218
+ raise RuntimeError(f"Failed to unstage all changes: {e}")
1219
+
1220
+ def revert_all_changes(self) -> bool:
1221
+ """Revert all working directory changes in one atomic operation."""
1222
+ if not self.is_git_repo or not self.repo:
1223
+ raise RuntimeError("Not a git repository")
1224
+
1225
+ try:
1226
+ # Check if repository has any commits (HEAD exists)
1227
+ try:
1228
+ self.repo.head.commit
1229
+ has_head = True
1230
+ except Exception:
1231
+ has_head = False
1232
+
1233
+ if has_head:
1234
+ # Use git restore . to revert all working directory changes
1235
+ self.repo.git.restore('.')
1236
+ logger.info("Successfully reverted all working directory changes")
1237
+ else:
1238
+ logger.warning("Cannot revert changes in repository with no commits")
1239
+ return False
1240
+
1241
+ return True
1242
+
1243
+ except Exception as e:
1244
+ logger.error("Error reverting all changes: %s", e)
1245
+ raise RuntimeError(f"Failed to revert all changes: {e}")
1246
+
1247
+ def commit_changes(self, message: str) -> bool:
1248
+ """Commit staged changes with the given message."""
1249
+ if not self.is_git_repo or not self.repo:
1250
+ raise RuntimeError("Not a git repository")
1251
+
1252
+ if not message or not message.strip():
1253
+ raise ValueError("Commit message cannot be empty")
1254
+
1255
+ try:
1256
+ # Handle repositories with no previous commits (first commit)
1257
+ try:
1258
+ self.repo.head.commit
1259
+ has_head = True
1260
+ except Exception:
1261
+ has_head = False
1262
+
1263
+ if not has_head:
1264
+ # For the first commit, check if anything is staged
1265
+ try:
1266
+ staged_output = self.repo.git.diff('--cached', '--name-only')
1267
+ has_staged = bool(staged_output.strip())
1268
+ except Exception:
1269
+ has_staged = False
1270
+ else:
1271
+ # Check if there are staged changes to commit
1272
+ staged_changes = self.repo.index.diff("HEAD")
1273
+ has_staged = len(staged_changes) > 0
1274
+
1275
+ if not has_staged:
1276
+ raise RuntimeError("No staged changes to commit")
1277
+
1278
+ # Perform the commit
1279
+ commit = self.repo.index.commit(message.strip())
1280
+ logger.info("Successfully committed changes with hash: %s", commit.hexsha)
1281
+ logger.info("Commit message: %s", message.strip())
1282
+
1283
+ return True
1284
+
1285
+ except Exception as e:
1286
+ logger.error("Error committing changes: %s", e)
1287
+ raise RuntimeError(f"Failed to commit changes: {e}")
1288
+
1289
+ def start_periodic_monitoring(self):
1290
+ """Start periodic monitoring of git status changes."""
1291
+ if not self.is_git_repo or not self._change_callback:
1292
+ return
1293
+
1294
+ if self._monitoring_task and not self._monitoring_task.done():
1295
+ logger.debug("Git monitoring already running for %s", self.project_path)
1296
+ return
1297
+
1298
+ logger.info("Starting periodic git monitoring for %s (session=%s)", self.project_path, self.owner_session_id)
1299
+ self._monitoring_enabled = True
1300
+
1301
+ # Initialize cached status
1302
+ self._update_cached_status()
1303
+
1304
+ # Start the monitoring task
1305
+ self._monitoring_task = asyncio.create_task(self._monitor_git_changes())
1306
+
1307
+ def stop_periodic_monitoring(self):
1308
+ """Stop periodic monitoring of git status changes."""
1309
+ self._monitoring_enabled = False
1310
+
1311
+ task = self._monitoring_task
1312
+ if task and not task.done():
1313
+ logger.info("Stopping periodic git monitoring for %s", self.project_path)
1314
+ task.cancel()
1315
+ try:
1316
+ loop = asyncio.get_event_loop()
1317
+ if loop.is_running():
1318
+ loop.create_task(self._await_monitor_stop(task))
1319
+ except Exception:
1320
+ pass
1321
+ self._monitoring_task = None
1322
+
1323
+ async def _await_monitor_stop(self, task):
1324
+ try:
1325
+ await task
1326
+ except asyncio.CancelledError:
1327
+ logger.debug("Git monitoring task cancelled for %s", self.project_path)
1328
+
1329
+ def _update_cached_status(self):
1330
+ """Update cached git status for comparison."""
1331
+ if not self.is_git_repo:
1332
+ return
1333
+
1334
+ try:
1335
+ self._cached_status_summary = self.get_status_summary()
1336
+ self._cached_detailed_status = self.get_detailed_status()
1337
+ self._cached_branch = self.get_branch_name()
1338
+ logger.debug("Updated cached git status for %s", self.project_path)
1339
+ except Exception as e:
1340
+ logger.error("Error updating cached git status: %s", e)
1341
+
1342
+ async def _monitor_git_changes(self):
1343
+ """Monitor git changes periodically and trigger callback when changes are detected."""
1344
+ try:
1345
+ while self._monitoring_enabled:
1346
+ await asyncio.sleep(5.0) # Check every 5000ms
1347
+
1348
+ if not self._monitoring_enabled or not self.is_git_repo:
1349
+ break
1350
+
1351
+ try:
1352
+ # Get current status - run in executor to avoid blocking event loop
1353
+ loop = asyncio.get_event_loop()
1354
+ current_status_summary = await loop.run_in_executor(None, self.get_status_summary)
1355
+ current_detailed_status = await loop.run_in_executor(None, self.get_detailed_status)
1356
+ current_branch = await loop.run_in_executor(None, self.get_branch_name)
1357
+
1358
+ # Compare with cached status
1359
+ status_changed = (
1360
+ current_status_summary != self._cached_status_summary or
1361
+ current_branch != self._cached_branch or
1362
+ self._detailed_status_changed(current_detailed_status, self._cached_detailed_status)
1363
+ )
1364
+
1365
+ if not self._monitoring_enabled:
1366
+ continue
1367
+ if status_changed:
1368
+ logger.info("Git status change detected for %s (session=%s)", self.project_path, self.owner_session_id)
1369
+ logger.debug("Status summary: %s -> %s", self._cached_status_summary, current_status_summary)
1370
+ logger.debug("Branch: %s -> %s", self._cached_branch, current_branch)
1371
+
1372
+ # Update cached status
1373
+ self._cached_status_summary = current_status_summary
1374
+ self._cached_detailed_status = current_detailed_status
1375
+ self._cached_branch = current_branch
1376
+
1377
+ # Trigger callback
1378
+ if self._change_callback:
1379
+ try:
1380
+ if asyncio.iscoroutinefunction(self._change_callback):
1381
+ await self._change_callback()
1382
+ else:
1383
+ self._change_callback()
1384
+ except Exception as e:
1385
+ logger.error("Error in git change callback: %s", e)
1386
+
1387
+ except Exception as e:
1388
+ logger.error("Error during git status monitoring: %s", e)
1389
+ # Continue monitoring despite errors
1390
+
1391
+ except asyncio.CancelledError:
1392
+ logger.debug("Git monitoring cancelled for %s", self.project_path)
1393
+ except Exception as e:
1394
+ logger.error("Fatal error in git monitoring: %s", e)
1395
+ finally:
1396
+ logger.debug("Git monitoring stopped for %s", self.project_path)
1397
+
1398
+ def _detailed_status_changed(self, current: Optional[GitDetailedStatus], cached: Optional[GitDetailedStatus]) -> bool:
1399
+ """Compare detailed status objects for changes."""
1400
+ if current is None and cached is None:
1401
+ return False
1402
+ if current is None or cached is None:
1403
+ return True
1404
+
1405
+ # Compare key attributes
1406
+ if (
1407
+ current.head_commit_hash != cached.head_commit_hash or
1408
+ len(current.staged_changes) != len(cached.staged_changes) or
1409
+ len(current.unstaged_changes) != len(cached.unstaged_changes) or
1410
+ len(current.untracked_files) != len(cached.untracked_files)
1411
+ ):
1412
+ return True
1413
+
1414
+ # Compare staged changes content hashes
1415
+ current_staged_hashes = {c.file_repo_path: c.content_hash for c in current.staged_changes}
1416
+ cached_staged_hashes = {c.file_repo_path: c.content_hash for c in cached.staged_changes}
1417
+ if current_staged_hashes != cached_staged_hashes:
1418
+ return True
1419
+
1420
+ # Compare unstaged changes content hashes
1421
+ current_unstaged_hashes = {c.file_repo_path: c.content_hash for c in current.unstaged_changes}
1422
+ cached_unstaged_hashes = {c.file_repo_path: c.content_hash for c in cached.unstaged_changes}
1423
+ if current_unstaged_hashes != cached_unstaged_hashes:
1424
+ return True
1425
+
1426
+ # Compare untracked files content hashes
1427
+ current_untracked_hashes = {c.file_repo_path: c.content_hash for c in current.untracked_files}
1428
+ cached_untracked_hashes = {c.file_repo_path: c.content_hash for c in cached.untracked_files}
1429
+ if current_untracked_hashes != cached_untracked_hashes:
1430
+ return True
1431
+
1432
+ return False
1433
+
1434
+ def cleanup(self):
1435
+ """Cleanup resources when GitManager is being destroyed."""
1436
+ logger.info("Cleaning up GitManager for %s (session=%s)", self.project_path, self.owner_session_id)
1437
+ self.stop_periodic_monitoring()
1438
+
1439
+ # CRITICAL: Close GitPython repo to cleanup git cat-file processes
1440
+ if self.repo:
1441
+ try:
1442
+ # Force cleanup of git command processes
1443
+ if hasattr(self.repo.git, 'clear_cache'):
1444
+ self.repo.git.clear_cache()
1445
+ self.repo.close()
1446
+ logger.info("Successfully closed GitPython repo for %s", self.project_path)
1447
+ except Exception as e:
1448
+ logger.warning("Error during git repo cleanup for %s: %s", self.project_path, e)
1449
+ finally:
1450
+ self.repo = None
1451
+
1452
+ # Clean up only the git processes tracked by this specific GitManager instance
1453
+ try:
1454
+ import psutil
1455
+
1456
+ killed_count = 0
1457
+ for pid in list(self._tracked_git_processes):
1458
+ should_terminate = False
1459
+ with _GIT_PROCESS_LOCK:
1460
+ if pid in _GIT_PROCESS_REF_COUNTS:
1461
+ _GIT_PROCESS_REF_COUNTS[pid] -= 1
1462
+ if _GIT_PROCESS_REF_COUNTS[pid] <= 0:
1463
+ should_terminate = True
1464
+ _GIT_PROCESS_REF_COUNTS.pop(pid, None)
1465
+ else:
1466
+ should_terminate = True
1467
+
1468
+ if should_terminate:
1469
+ try:
1470
+ proc = psutil.Process(pid)
1471
+ if proc.is_running():
1472
+ proc.terminate()
1473
+ killed_count += 1
1474
+ logger.info("Terminated tracked git process %d", pid)
1475
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
1476
+ pass
1477
+
1478
+ self._tracked_git_processes.discard(pid)
1479
+
1480
+ if killed_count > 0:
1481
+ logger.info("Cleaned up %d tracked git processes for session", killed_count)
1482
+
1483
+ except Exception as e:
1484
+ logger.warning("Error cleaning up tracked git processes: %s", e)
1485
+
1486
+ # Clear the tracking set
1487
+ self._tracked_git_processes.clear()
1488
+
1489
+ def get_tracked_git_process_count(self) -> int:
1490
+ """Return how many git helper processes this manager is tracking."""
1491
+ return len(self._tracked_git_processes)
1492
+
1493
+ def get_diagnostics(self) -> Dict[str, Any]:
1494
+ """Expose lightweight stats for health monitoring."""
1495
+ return {
1496
+ "project_path": self.project_path,
1497
+ "is_git_repo": self.is_git_repo,
1498
+ "tracked_git_processes": self.get_tracked_git_process_count(),
1499
+ "monitoring_enabled": self._monitoring_enabled,
1500
+ "monitoring_task_active": bool(self._monitoring_task and not self._monitoring_task.done()),
1501
+ "session_id": self.owner_session_id,
1502
+ }