portacode 0.3.4.dev0__py3-none-any.whl → 1.4.11.dev0__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.
- portacode/_version.py +16 -3
- portacode/cli.py +155 -19
- portacode/connection/client.py +152 -12
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
- portacode/connection/handlers/__init__.py +43 -1
- portacode/connection/handlers/base.py +122 -18
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -0
- portacode/connection/handlers/proxmox_infra.py +307 -0
- portacode/connection/handlers/registry.py +53 -10
- portacode/connection/handlers/session.py +705 -53
- portacode/connection/handlers/system_handlers.py +142 -8
- portacode/connection/handlers/tab_factory.py +389 -0
- portacode/connection/handlers/terminal_handlers.py +150 -11
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +695 -28
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/service.py +6 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev0.dist-info/METADATA +298 -0
- portacode-1.4.11.dev0.dist-info/RECORD +97 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.4.dev0.dist-info/METADATA +0 -236
- portacode-0.3.4.dev0.dist-info/RECORD +0 -27
- portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.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
|
+
}
|