claude-mpm 3.7.4__py3-none-any.whl → 3.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/BASE_PM.md +0 -106
  3. claude_mpm/agents/INSTRUCTIONS.md +0 -78
  4. claude_mpm/agents/MEMORY.md +88 -0
  5. claude_mpm/agents/WORKFLOW.md +86 -0
  6. claude_mpm/agents/schema/agent_schema.json +1 -1
  7. claude_mpm/agents/templates/code_analyzer.json +26 -11
  8. claude_mpm/agents/templates/data_engineer.json +4 -7
  9. claude_mpm/agents/templates/documentation.json +2 -2
  10. claude_mpm/agents/templates/engineer.json +2 -2
  11. claude_mpm/agents/templates/ops.json +3 -8
  12. claude_mpm/agents/templates/qa.json +2 -3
  13. claude_mpm/agents/templates/research.json +2 -3
  14. claude_mpm/agents/templates/security.json +3 -6
  15. claude_mpm/agents/templates/ticketing.json +4 -9
  16. claude_mpm/agents/templates/version_control.json +3 -3
  17. claude_mpm/agents/templates/web_qa.json +4 -4
  18. claude_mpm/agents/templates/web_ui.json +4 -4
  19. claude_mpm/cli/__init__.py +2 -2
  20. claude_mpm/cli/commands/__init__.py +2 -1
  21. claude_mpm/cli/commands/agents.py +118 -1
  22. claude_mpm/cli/commands/tickets.py +596 -19
  23. claude_mpm/cli/parser.py +228 -5
  24. claude_mpm/config/__init__.py +30 -39
  25. claude_mpm/config/socketio_config.py +8 -5
  26. claude_mpm/constants.py +13 -0
  27. claude_mpm/core/__init__.py +8 -18
  28. claude_mpm/core/cache.py +596 -0
  29. claude_mpm/core/claude_runner.py +166 -622
  30. claude_mpm/core/config.py +5 -1
  31. claude_mpm/core/constants.py +339 -0
  32. claude_mpm/core/container.py +461 -22
  33. claude_mpm/core/exceptions.py +392 -0
  34. claude_mpm/core/framework_loader.py +208 -93
  35. claude_mpm/core/interactive_session.py +432 -0
  36. claude_mpm/core/interfaces.py +424 -0
  37. claude_mpm/core/lazy.py +467 -0
  38. claude_mpm/core/logging_config.py +444 -0
  39. claude_mpm/core/oneshot_session.py +465 -0
  40. claude_mpm/core/optimized_agent_loader.py +485 -0
  41. claude_mpm/core/optimized_startup.py +490 -0
  42. claude_mpm/core/service_registry.py +52 -26
  43. claude_mpm/core/socketio_pool.py +162 -5
  44. claude_mpm/core/types.py +292 -0
  45. claude_mpm/core/typing_utils.py +477 -0
  46. claude_mpm/dashboard/static/js/components/file-tool-tracker.js +46 -2
  47. claude_mpm/dashboard/templates/index.html +5 -5
  48. claude_mpm/hooks/claude_hooks/hook_handler.py +213 -99
  49. claude_mpm/init.py +2 -1
  50. claude_mpm/services/__init__.py +78 -14
  51. claude_mpm/services/agent/__init__.py +24 -0
  52. claude_mpm/services/agent/deployment.py +2548 -0
  53. claude_mpm/services/agent/management.py +598 -0
  54. claude_mpm/services/agent/registry.py +813 -0
  55. claude_mpm/services/agents/deployment/agent_deployment.py +592 -269
  56. claude_mpm/services/agents/deployment/async_agent_deployment.py +5 -1
  57. claude_mpm/services/agents/management/agent_capabilities_generator.py +21 -11
  58. claude_mpm/services/agents/memory/agent_memory_manager.py +156 -1
  59. claude_mpm/services/async_session_logger.py +8 -3
  60. claude_mpm/services/communication/__init__.py +21 -0
  61. claude_mpm/services/communication/socketio.py +1933 -0
  62. claude_mpm/services/communication/websocket.py +479 -0
  63. claude_mpm/services/core/__init__.py +123 -0
  64. claude_mpm/services/core/base.py +247 -0
  65. claude_mpm/services/core/interfaces.py +951 -0
  66. claude_mpm/services/framework_claude_md_generator/section_generators/todo_task_tools.py +23 -23
  67. claude_mpm/services/framework_claude_md_generator.py +3 -2
  68. claude_mpm/services/health_monitor.py +4 -3
  69. claude_mpm/services/hook_service.py +64 -4
  70. claude_mpm/services/infrastructure/__init__.py +21 -0
  71. claude_mpm/services/infrastructure/logging.py +202 -0
  72. claude_mpm/services/infrastructure/monitoring.py +893 -0
  73. claude_mpm/services/memory/indexed_memory.py +648 -0
  74. claude_mpm/services/project/__init__.py +21 -0
  75. claude_mpm/services/project/analyzer.py +864 -0
  76. claude_mpm/services/project/registry.py +608 -0
  77. claude_mpm/services/project_analyzer.py +95 -2
  78. claude_mpm/services/recovery_manager.py +15 -9
  79. claude_mpm/services/socketio/__init__.py +25 -0
  80. claude_mpm/services/socketio/handlers/__init__.py +25 -0
  81. claude_mpm/services/socketio/handlers/base.py +121 -0
  82. claude_mpm/services/socketio/handlers/connection.py +198 -0
  83. claude_mpm/services/socketio/handlers/file.py +213 -0
  84. claude_mpm/services/socketio/handlers/git.py +723 -0
  85. claude_mpm/services/socketio/handlers/memory.py +27 -0
  86. claude_mpm/services/socketio/handlers/project.py +25 -0
  87. claude_mpm/services/socketio/handlers/registry.py +145 -0
  88. claude_mpm/services/socketio_client_manager.py +12 -7
  89. claude_mpm/services/socketio_server.py +156 -30
  90. claude_mpm/services/ticket_manager.py +377 -51
  91. claude_mpm/utils/agent_dependency_loader.py +66 -15
  92. claude_mpm/utils/error_handler.py +1 -1
  93. claude_mpm/utils/robust_installer.py +587 -0
  94. claude_mpm/validation/agent_validator.py +27 -14
  95. claude_mpm/validation/frontmatter_validator.py +231 -0
  96. {claude_mpm-3.7.4.dist-info → claude_mpm-3.8.1.dist-info}/METADATA +74 -41
  97. {claude_mpm-3.7.4.dist-info → claude_mpm-3.8.1.dist-info}/RECORD +101 -76
  98. claude_mpm/.claude-mpm/logs/hooks_20250728.log +0 -10
  99. claude_mpm/agents/agent-template.yaml +0 -83
  100. claude_mpm/cli/README.md +0 -108
  101. claude_mpm/cli_module/refactoring_guide.md +0 -253
  102. claude_mpm/config/async_logging_config.yaml +0 -145
  103. claude_mpm/core/.claude-mpm/logs/hooks_20250730.log +0 -34
  104. claude_mpm/dashboard/.claude-mpm/memories/README.md +0 -36
  105. claude_mpm/dashboard/README.md +0 -121
  106. claude_mpm/dashboard/static/js/dashboard.js.backup +0 -1973
  107. claude_mpm/dashboard/templates/.claude-mpm/memories/README.md +0 -36
  108. claude_mpm/dashboard/templates/.claude-mpm/memories/engineer_agent.md +0 -39
  109. claude_mpm/dashboard/templates/.claude-mpm/memories/version_control_agent.md +0 -38
  110. claude_mpm/hooks/README.md +0 -96
  111. claude_mpm/schemas/agent_schema.json +0 -435
  112. claude_mpm/services/framework_claude_md_generator/README.md +0 -92
  113. claude_mpm/services/version_control/VERSION +0 -1
  114. {claude_mpm-3.7.4.dist-info → claude_mpm-3.8.1.dist-info}/WHEEL +0 -0
  115. {claude_mpm-3.7.4.dist-info → claude_mpm-3.8.1.dist-info}/entry_points.txt +0 -0
  116. {claude_mpm-3.7.4.dist-info → claude_mpm-3.8.1.dist-info}/licenses/LICENSE +0 -0
  117. {claude_mpm-3.7.4.dist-info → claude_mpm-3.8.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,723 @@
1
+ """Git operation event handlers for Socket.IO.
2
+
3
+ WHY: This module handles all git-related events including branch queries,
4
+ file tracking status, and git add operations. Isolating git operations
5
+ improves maintainability and makes it easier to extend git functionality.
6
+ """
7
+
8
+ import os
9
+ import subprocess
10
+ import asyncio
11
+ from typing import Optional, Dict, Any, List
12
+ from datetime import datetime
13
+
14
+ from .base import BaseEventHandler
15
+ from ....core.typing_utils import SocketId, EventData, PathLike
16
+
17
+
18
+ class GitEventHandler(BaseEventHandler):
19
+ """Handles git-related Socket.IO events.
20
+
21
+ WHY: Git operations are a distinct domain that benefits from focused
22
+ handling. This includes checking branches, file tracking status,
23
+ and adding files to git. Separating these improves code organization.
24
+ """
25
+
26
+ def register_events(self) -> None:
27
+ """Register git-related event handlers."""
28
+
29
+ @self.sio.event
30
+ async def get_git_branch(sid, working_dir=None):
31
+ """Get the current git branch for a directory.
32
+
33
+ WHY: The dashboard needs to display the current git branch
34
+ to provide context about which branch changes are being made on.
35
+ """
36
+ try:
37
+ self.logger.info(f"[GIT-BRANCH-DEBUG] get_git_branch called with working_dir: {repr(working_dir)} (type: {type(working_dir)})")
38
+
39
+ # Validate and sanitize working directory
40
+ working_dir = self._sanitize_working_dir(working_dir, "get_git_branch")
41
+
42
+ if not self._validate_directory(sid, working_dir, "git_branch_response"):
43
+ return
44
+
45
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Running git command in directory: {working_dir}")
46
+
47
+ # Run git command to get current branch
48
+ result = subprocess.run(
49
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
50
+ cwd=working_dir,
51
+ capture_output=True,
52
+ text=True
53
+ )
54
+
55
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Git command result: returncode={result.returncode}, stdout={repr(result.stdout)}, stderr={repr(result.stderr)}")
56
+
57
+ if result.returncode == 0:
58
+ branch = result.stdout.strip()
59
+ self.logger.info(f"[GIT-BRANCH-DEBUG] Successfully got git branch: {branch}")
60
+ await self.emit_to_client(sid, 'git_branch_response', {
61
+ 'success': True,
62
+ 'branch': branch,
63
+ 'working_dir': working_dir,
64
+ 'original_working_dir': working_dir
65
+ })
66
+ else:
67
+ self.logger.warning(f"[GIT-BRANCH-DEBUG] Git command failed: {result.stderr}")
68
+ await self.emit_to_client(sid, 'git_branch_response', {
69
+ 'success': False,
70
+ 'error': 'Not a git repository',
71
+ 'working_dir': working_dir,
72
+ 'original_working_dir': working_dir,
73
+ 'git_error': result.stderr
74
+ })
75
+
76
+ except Exception as e:
77
+ self.log_error("get_git_branch", e, {"working_dir": working_dir})
78
+ await self.emit_to_client(sid, 'git_branch_response', {
79
+ 'success': False,
80
+ 'error': str(e),
81
+ 'working_dir': working_dir,
82
+ 'original_working_dir': working_dir
83
+ })
84
+
85
+ @self.sio.event
86
+ async def check_file_tracked(sid, data):
87
+ """Check if a file is tracked by git.
88
+
89
+ WHY: The dashboard needs to know if a file is tracked by git
90
+ to determine whether to show git-related UI elements.
91
+ """
92
+ try:
93
+ file_path = data.get('file_path')
94
+ working_dir = data.get('working_dir', os.getcwd())
95
+
96
+ if not file_path:
97
+ await self.emit_to_client(sid, 'file_tracked_response', {
98
+ 'success': False,
99
+ 'error': 'file_path is required',
100
+ 'file_path': file_path
101
+ })
102
+ return
103
+
104
+ # Use git ls-files to check if file is tracked
105
+ result = subprocess.run(
106
+ ["git", "-C", working_dir, "ls-files", "--", file_path],
107
+ capture_output=True,
108
+ text=True
109
+ )
110
+
111
+ is_tracked = result.returncode == 0 and result.stdout.strip()
112
+
113
+ await self.emit_to_client(sid, 'file_tracked_response', {
114
+ 'success': True,
115
+ 'file_path': file_path,
116
+ 'working_dir': working_dir,
117
+ 'is_tracked': bool(is_tracked)
118
+ })
119
+
120
+ except Exception as e:
121
+ self.log_error("check_file_tracked", e, data)
122
+ await self.emit_to_client(sid, 'file_tracked_response', {
123
+ 'success': False,
124
+ 'error': str(e),
125
+ 'file_path': data.get('file_path', 'unknown')
126
+ })
127
+
128
+ @self.sio.event
129
+ async def check_git_status(sid, data):
130
+ """Check git status for a file to determine if git diff icons should be shown.
131
+
132
+ WHY: The dashboard shows git diff icons for files that have changes.
133
+ This checks if a file has git status to determine icon visibility.
134
+ """
135
+ try:
136
+ file_path = data.get('file_path')
137
+ working_dir = data.get('working_dir', os.getcwd())
138
+
139
+ self.logger.info(f"[GIT-STATUS-DEBUG] check_git_status called with file_path: {repr(file_path)}, working_dir: {repr(working_dir)}")
140
+
141
+ if not file_path:
142
+ await self.emit_to_client(sid, 'git_status_response', {
143
+ 'success': False,
144
+ 'error': 'file_path is required',
145
+ 'file_path': file_path
146
+ })
147
+ return
148
+
149
+ # Validate and sanitize working_dir
150
+ original_working_dir = working_dir
151
+ working_dir = self._sanitize_working_dir(working_dir, "check_git_status")
152
+
153
+ if not self._validate_directory_for_status(sid, working_dir, original_working_dir, file_path):
154
+ return
155
+
156
+ # Check if this is a git repository
157
+ if not self._is_git_repository(working_dir):
158
+ await self.emit_to_client(sid, 'git_status_response', {
159
+ 'success': False,
160
+ 'error': 'Not a git repository',
161
+ 'file_path': file_path,
162
+ 'working_dir': working_dir,
163
+ 'original_working_dir': original_working_dir
164
+ })
165
+ return
166
+
167
+ # Check git status for the file
168
+ file_path_for_git = self._make_path_relative_to_git(file_path, working_dir)
169
+
170
+ # Check if the file exists
171
+ full_path = file_path if os.path.isabs(file_path) else os.path.join(working_dir, file_path)
172
+ if not os.path.exists(full_path):
173
+ self.logger.warning(f"[GIT-STATUS-DEBUG] File does not exist: {full_path}")
174
+ await self.emit_to_client(sid, 'git_status_response', {
175
+ 'success': False,
176
+ 'error': f'File does not exist: {file_path}',
177
+ 'file_path': file_path,
178
+ 'working_dir': working_dir,
179
+ 'original_working_dir': original_working_dir
180
+ })
181
+ return
182
+
183
+ # Check git status and tracking
184
+ is_tracked, has_changes = self._check_file_git_status(file_path_for_git, working_dir)
185
+
186
+ if is_tracked or has_changes:
187
+ self.logger.info(f"[GIT-STATUS-DEBUG] Git status check successful for {file_path}")
188
+ await self.emit_to_client(sid, 'git_status_response', {
189
+ 'success': True,
190
+ 'file_path': file_path,
191
+ 'working_dir': working_dir,
192
+ 'original_working_dir': original_working_dir,
193
+ 'is_tracked': is_tracked,
194
+ 'has_changes': has_changes
195
+ })
196
+ else:
197
+ self.logger.info(f"[GIT-STATUS-DEBUG] File {file_path} is not tracked by git")
198
+ await self.emit_to_client(sid, 'git_status_response', {
199
+ 'success': False,
200
+ 'error': 'File is not tracked by git',
201
+ 'file_path': file_path,
202
+ 'working_dir': working_dir,
203
+ 'original_working_dir': original_working_dir,
204
+ 'is_tracked': False
205
+ })
206
+
207
+ except Exception as e:
208
+ self.log_error("check_git_status", e, data)
209
+ await self.emit_to_client(sid, 'git_status_response', {
210
+ 'success': False,
211
+ 'error': str(e),
212
+ 'file_path': data.get('file_path', 'unknown'),
213
+ 'working_dir': data.get('working_dir', 'unknown')
214
+ })
215
+
216
+ @self.sio.event
217
+ async def git_add_file(sid, data):
218
+ """Add file to git tracking.
219
+
220
+ WHY: Users can add untracked files to git directly from the dashboard,
221
+ making it easier to manage version control without leaving the UI.
222
+ """
223
+ try:
224
+ file_path = data.get('file_path')
225
+ working_dir = data.get('working_dir', os.getcwd())
226
+
227
+ self.logger.info(f"[GIT-ADD-DEBUG] git_add_file called with file_path: {repr(file_path)}, working_dir: {repr(working_dir)} (type: {type(working_dir)})")
228
+
229
+ if not file_path:
230
+ await self.emit_to_client(sid, 'git_add_response', {
231
+ 'success': False,
232
+ 'error': 'file_path is required',
233
+ 'file_path': file_path
234
+ })
235
+ return
236
+
237
+ # Validate and sanitize working_dir
238
+ original_working_dir = working_dir
239
+ working_dir = self._sanitize_working_dir(working_dir, "git_add_file")
240
+
241
+ if not self._validate_directory_for_add(sid, working_dir, original_working_dir, file_path):
242
+ return
243
+
244
+ self.logger.info(f"[GIT-ADD-DEBUG] Running git add command in directory: {working_dir}")
245
+
246
+ # Use git add to track the file
247
+ result = subprocess.run(
248
+ ["git", "-C", working_dir, "add", file_path],
249
+ capture_output=True,
250
+ text=True
251
+ )
252
+
253
+ self.logger.info(f"[GIT-ADD-DEBUG] Git add result: returncode={result.returncode}, stdout={repr(result.stdout)}, stderr={repr(result.stderr)}")
254
+
255
+ if result.returncode == 0:
256
+ self.logger.info(f"[GIT-ADD-DEBUG] Successfully added {file_path} to git in {working_dir}")
257
+ await self.emit_to_client(sid, 'git_add_response', {
258
+ 'success': True,
259
+ 'file_path': file_path,
260
+ 'working_dir': working_dir,
261
+ 'original_working_dir': original_working_dir,
262
+ 'message': 'File successfully added to git tracking'
263
+ })
264
+ else:
265
+ error_message = result.stderr.strip() or 'Unknown git error'
266
+ self.logger.warning(f"[GIT-ADD-DEBUG] Git add failed: {error_message}")
267
+ await self.emit_to_client(sid, 'git_add_response', {
268
+ 'success': False,
269
+ 'error': f'Git add failed: {error_message}',
270
+ 'file_path': file_path,
271
+ 'working_dir': working_dir,
272
+ 'original_working_dir': original_working_dir
273
+ })
274
+
275
+ except Exception as e:
276
+ self.log_error("git_add_file", e, data)
277
+ await self.emit_to_client(sid, 'git_add_response', {
278
+ 'success': False,
279
+ 'error': str(e),
280
+ 'file_path': data.get('file_path', 'unknown'),
281
+ 'working_dir': data.get('working_dir', 'unknown')
282
+ })
283
+
284
+ def _sanitize_working_dir(self, working_dir: Optional[str], operation: str) -> str:
285
+ """Sanitize and validate working directory input.
286
+
287
+ WHY: Working directory input from clients can be invalid or malformed.
288
+ This ensures we have a valid directory path to work with.
289
+ """
290
+ invalid_states = [
291
+ None, '', 'Unknown', 'Loading...', 'Loading', 'undefined', 'null',
292
+ 'Not Connected', 'Invalid Directory', 'No Directory', '.'
293
+ ]
294
+
295
+ original_working_dir = working_dir
296
+ if working_dir in invalid_states or (isinstance(working_dir, str) and working_dir.strip() == ''):
297
+ working_dir = os.getcwd()
298
+ self.logger.info(f"[{operation}] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
299
+ else:
300
+ self.logger.info(f"[{operation}] Using provided working_dir: {working_dir}")
301
+
302
+ # Additional validation for obviously invalid paths
303
+ if isinstance(working_dir, str):
304
+ working_dir = working_dir.strip()
305
+ # Check for null bytes or other invalid characters
306
+ if '\x00' in working_dir:
307
+ self.logger.warning(f"[{operation}] working_dir contains null bytes, using cwd instead")
308
+ working_dir = os.getcwd()
309
+
310
+ return working_dir
311
+
312
+ async def _validate_directory(self, sid: str, working_dir: str, response_event: str) -> bool:
313
+ """Validate that a directory exists and is accessible.
314
+
315
+ WHY: We need to ensure the directory exists and is a directory
316
+ before attempting git operations on it.
317
+ """
318
+ if not os.path.exists(working_dir):
319
+ self.logger.info(f"Directory does not exist: {working_dir} - responding gracefully")
320
+ await self.emit_to_client(sid, response_event, {
321
+ 'success': False,
322
+ 'error': f'Directory not found',
323
+ 'working_dir': working_dir,
324
+ 'detail': f'Path does not exist: {working_dir}'
325
+ })
326
+ return False
327
+
328
+ if not os.path.isdir(working_dir):
329
+ self.logger.info(f"Path is not a directory: {working_dir} - responding gracefully")
330
+ await self.emit_to_client(sid, response_event, {
331
+ 'success': False,
332
+ 'error': f'Not a directory',
333
+ 'working_dir': working_dir,
334
+ 'detail': f'Path is not a directory: {working_dir}'
335
+ })
336
+ return False
337
+
338
+ return True
339
+
340
+ async def _validate_directory_for_status(self, sid: str, working_dir: str, original_working_dir: str, file_path: str) -> bool:
341
+ """Validate directory for git status operations."""
342
+ if not os.path.exists(working_dir):
343
+ self.logger.warning(f"[GIT-STATUS-DEBUG] Directory does not exist: {working_dir}")
344
+ await self.emit_to_client(sid, 'git_status_response', {
345
+ 'success': False,
346
+ 'error': f'Directory does not exist: {working_dir}',
347
+ 'file_path': file_path,
348
+ 'working_dir': working_dir,
349
+ 'original_working_dir': original_working_dir
350
+ })
351
+ return False
352
+
353
+ if not os.path.isdir(working_dir):
354
+ self.logger.warning(f"[GIT-STATUS-DEBUG] Path is not a directory: {working_dir}")
355
+ await self.emit_to_client(sid, 'git_status_response', {
356
+ 'success': False,
357
+ 'error': f'Path is not a directory: {working_dir}',
358
+ 'file_path': file_path,
359
+ 'working_dir': working_dir,
360
+ 'original_working_dir': original_working_dir
361
+ })
362
+ return False
363
+
364
+ return True
365
+
366
+ async def _validate_directory_for_add(self, sid: str, working_dir: str, original_working_dir: str, file_path: str) -> bool:
367
+ """Validate directory for git add operations."""
368
+ if not os.path.exists(working_dir):
369
+ self.logger.warning(f"[GIT-ADD-DEBUG] Directory does not exist: {working_dir}")
370
+ await self.emit_to_client(sid, 'git_add_response', {
371
+ 'success': False,
372
+ 'error': f'Directory does not exist: {working_dir}',
373
+ 'file_path': file_path,
374
+ 'working_dir': working_dir,
375
+ 'original_working_dir': original_working_dir
376
+ })
377
+ return False
378
+
379
+ if not os.path.isdir(working_dir):
380
+ self.logger.warning(f"[GIT-ADD-DEBUG] Path is not a directory: {working_dir}")
381
+ await self.emit_to_client(sid, 'git_add_response', {
382
+ 'success': False,
383
+ 'error': f'Path is not a directory: {working_dir}',
384
+ 'file_path': file_path,
385
+ 'working_dir': working_dir,
386
+ 'original_working_dir': original_working_dir
387
+ })
388
+ return False
389
+
390
+ return True
391
+
392
+ def _is_git_repository(self, working_dir: str) -> bool:
393
+ """Check if a directory is a git repository."""
394
+ git_check = subprocess.run(
395
+ ["git", "-C", working_dir, "rev-parse", "--git-dir"],
396
+ capture_output=True,
397
+ text=True
398
+ )
399
+ return git_check.returncode == 0
400
+
401
+ def _make_path_relative_to_git(self, file_path: str, working_dir: str) -> str:
402
+ """Make an absolute path relative to the git root if needed."""
403
+ if not os.path.isabs(file_path):
404
+ return file_path
405
+
406
+ # Get git root to make path relative if needed
407
+ git_root_result = subprocess.run(
408
+ ["git", "-C", working_dir, "rev-parse", "--show-toplevel"],
409
+ capture_output=True,
410
+ text=True
411
+ )
412
+
413
+ if git_root_result.returncode == 0:
414
+ git_root = git_root_result.stdout.strip()
415
+ try:
416
+ relative_path = os.path.relpath(file_path, git_root)
417
+ self.logger.info(f"Made file path relative to git root: {relative_path}")
418
+ return relative_path
419
+ except ValueError:
420
+ # File is not under git root - keep original path
421
+ self.logger.info(f"File not under git root, keeping original path: {file_path}")
422
+
423
+ return file_path
424
+
425
+ def _check_file_git_status(self, file_path: str, working_dir: str) -> tuple[bool, bool]:
426
+ """Check if a file is tracked and has changes."""
427
+ # Check git status for the file
428
+ git_status_result = subprocess.run(
429
+ ["git", "-C", working_dir, "status", "--porcelain", file_path],
430
+ capture_output=True,
431
+ text=True
432
+ )
433
+
434
+ # Check if file is tracked by git
435
+ ls_files_result = subprocess.run(
436
+ ["git", "-C", working_dir, "ls-files", file_path],
437
+ capture_output=True,
438
+ text=True
439
+ )
440
+
441
+ is_tracked = ls_files_result.returncode == 0 and ls_files_result.stdout.strip()
442
+ has_changes = git_status_result.returncode == 0 and bool(git_status_result.stdout.strip())
443
+
444
+ self.logger.info(f"File tracking status: is_tracked={is_tracked}, has_changes={has_changes}")
445
+
446
+ return is_tracked, has_changes
447
+
448
+ async def generate_git_diff(self, file_path: str, timestamp: Optional[str] = None, working_dir: Optional[str] = None) -> Dict[str, Any]:
449
+ """Generate git diff for a specific file operation.
450
+
451
+ WHY: This method generates a git diff showing the changes made to a file
452
+ during a specific write operation. It uses git log and show commands to
453
+ find the most relevant commit around the specified timestamp.
454
+
455
+ Args:
456
+ file_path: Path to the file relative to the git repository
457
+ timestamp: ISO timestamp of the file operation (optional)
458
+ working_dir: Working directory containing the git repository
459
+
460
+ Returns:
461
+ dict: Contains diff content, metadata, and status information
462
+ """
463
+ try:
464
+ # If file_path is absolute, determine its git repository
465
+ if os.path.isabs(file_path):
466
+ # Find the directory containing the file
467
+ file_dir = os.path.dirname(file_path)
468
+ if os.path.exists(file_dir):
469
+ # Try to find the git root from the file's directory
470
+ current_dir = file_dir
471
+ while current_dir != "/" and current_dir:
472
+ if os.path.exists(os.path.join(current_dir, ".git")):
473
+ working_dir = current_dir
474
+ self.logger.info(f"Found git repository at: {working_dir}")
475
+ break
476
+ current_dir = os.path.dirname(current_dir)
477
+ else:
478
+ # If no git repo found, use the file's directory
479
+ working_dir = file_dir
480
+ self.logger.info(f"No git repo found, using file's directory: {working_dir}")
481
+
482
+ # Handle case where working_dir is None, empty string, or 'Unknown'
483
+ original_working_dir = working_dir
484
+ if not working_dir or working_dir == 'Unknown' or working_dir.strip() == '':
485
+ working_dir = os.getcwd()
486
+ self.logger.info(f"[GIT-DIFF-DEBUG] working_dir was invalid ({repr(original_working_dir)}), using cwd: {working_dir}")
487
+ else:
488
+ self.logger.info(f"[GIT-DIFF-DEBUG] Using provided working_dir: {working_dir}")
489
+
490
+ # For read-only git operations, we can work from any directory
491
+ # by passing the -C flag to git commands instead of changing directories
492
+ original_cwd = os.getcwd()
493
+ try:
494
+ # We'll use git -C <working_dir> for all commands instead of chdir
495
+
496
+ # Check if this is a git repository
497
+ git_check = await asyncio.create_subprocess_exec(
498
+ 'git', '-C', working_dir, 'rev-parse', '--git-dir',
499
+ stdout=asyncio.subprocess.PIPE,
500
+ stderr=asyncio.subprocess.PIPE
501
+ )
502
+ await git_check.communicate()
503
+
504
+ if git_check.returncode != 0:
505
+ return {
506
+ "success": False,
507
+ "error": "Not a git repository",
508
+ "file_path": file_path,
509
+ "working_dir": working_dir
510
+ }
511
+
512
+ # Get the absolute path of the file relative to git root
513
+ git_root_proc = await asyncio.create_subprocess_exec(
514
+ 'git', '-C', working_dir, 'rev-parse', '--show-toplevel',
515
+ stdout=asyncio.subprocess.PIPE,
516
+ stderr=asyncio.subprocess.PIPE
517
+ )
518
+ git_root_output, _ = await git_root_proc.communicate()
519
+
520
+ if git_root_proc.returncode != 0:
521
+ return {"success": False, "error": "Failed to determine git root directory"}
522
+
523
+ git_root = git_root_output.decode().strip()
524
+
525
+ # Make file_path relative to git root if it's absolute
526
+ if os.path.isabs(file_path):
527
+ try:
528
+ file_path = os.path.relpath(file_path, git_root)
529
+ except ValueError:
530
+ # File is not under git root
531
+ pass
532
+
533
+ # If timestamp is provided, try to find commits around that time
534
+ if timestamp:
535
+ # Convert timestamp to git format
536
+ try:
537
+ dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
538
+ git_since = dt.strftime('%Y-%m-%d %H:%M:%S')
539
+
540
+ # Find commits that modified this file around the timestamp
541
+ log_proc = await asyncio.create_subprocess_exec(
542
+ 'git', '-C', working_dir, 'log', '--oneline', '--since', git_since,
543
+ '--until', f'{git_since} +1 hour', '--', file_path,
544
+ stdout=asyncio.subprocess.PIPE,
545
+ stderr=asyncio.subprocess.PIPE
546
+ )
547
+ log_output, _ = await log_proc.communicate()
548
+
549
+ if log_proc.returncode == 0 and log_output:
550
+ # Get the most recent commit hash
551
+ commits = log_output.decode().strip().split('\n')
552
+ if commits and commits[0]:
553
+ commit_hash = commits[0].split()[0]
554
+
555
+ # Get the diff for this specific commit
556
+ diff_proc = await asyncio.create_subprocess_exec(
557
+ 'git', '-C', working_dir, 'show', '--format=fuller', commit_hash, '--', file_path,
558
+ stdout=asyncio.subprocess.PIPE,
559
+ stderr=asyncio.subprocess.PIPE
560
+ )
561
+ diff_output, diff_error = await diff_proc.communicate()
562
+
563
+ if diff_proc.returncode == 0:
564
+ return {
565
+ "success": True,
566
+ "diff": diff_output.decode(),
567
+ "commit_hash": commit_hash,
568
+ "file_path": file_path,
569
+ "method": "timestamp_based",
570
+ "timestamp": timestamp
571
+ }
572
+ except Exception as e:
573
+ self.logger.warning(f"Failed to parse timestamp or find commits: {e}")
574
+
575
+ # Fallback: Get the most recent change to the file
576
+ log_proc = await asyncio.create_subprocess_exec(
577
+ 'git', '-C', working_dir, 'log', '-1', '--oneline', '--', file_path,
578
+ stdout=asyncio.subprocess.PIPE,
579
+ stderr=asyncio.subprocess.PIPE
580
+ )
581
+ log_output, _ = await log_proc.communicate()
582
+
583
+ if log_proc.returncode == 0 and log_output:
584
+ commit_hash = log_output.decode().strip().split()[0]
585
+
586
+ # Get the diff for the most recent commit
587
+ diff_proc = await asyncio.create_subprocess_exec(
588
+ 'git', '-C', working_dir, 'show', '--format=fuller', commit_hash, '--', file_path,
589
+ stdout=asyncio.subprocess.PIPE,
590
+ stderr=asyncio.subprocess.PIPE
591
+ )
592
+ diff_output, diff_error = await diff_proc.communicate()
593
+
594
+ if diff_proc.returncode == 0:
595
+ return {
596
+ "success": True,
597
+ "diff": diff_output.decode(),
598
+ "commit_hash": commit_hash,
599
+ "file_path": file_path,
600
+ "method": "latest_commit",
601
+ "timestamp": timestamp
602
+ }
603
+
604
+ # Try to show unstaged changes first
605
+ diff_proc = await asyncio.create_subprocess_exec(
606
+ 'git', '-C', working_dir, 'diff', '--', file_path,
607
+ stdout=asyncio.subprocess.PIPE,
608
+ stderr=asyncio.subprocess.PIPE
609
+ )
610
+ diff_output, _ = await diff_proc.communicate()
611
+
612
+ if diff_proc.returncode == 0 and diff_output.decode().strip():
613
+ return {
614
+ "success": True,
615
+ "diff": diff_output.decode(),
616
+ "commit_hash": "unstaged_changes",
617
+ "file_path": file_path,
618
+ "method": "unstaged_changes",
619
+ "timestamp": timestamp
620
+ }
621
+
622
+ # Then try staged changes
623
+ diff_proc = await asyncio.create_subprocess_exec(
624
+ 'git', '-C', working_dir, 'diff', '--cached', '--', file_path,
625
+ stdout=asyncio.subprocess.PIPE,
626
+ stderr=asyncio.subprocess.PIPE
627
+ )
628
+ diff_output, _ = await diff_proc.communicate()
629
+
630
+ if diff_proc.returncode == 0 and diff_output.decode().strip():
631
+ return {
632
+ "success": True,
633
+ "diff": diff_output.decode(),
634
+ "commit_hash": "staged_changes",
635
+ "file_path": file_path,
636
+ "method": "staged_changes",
637
+ "timestamp": timestamp
638
+ }
639
+
640
+ # Final fallback: Show changes against HEAD
641
+ diff_proc = await asyncio.create_subprocess_exec(
642
+ 'git', '-C', working_dir, 'diff', 'HEAD', '--', file_path,
643
+ stdout=asyncio.subprocess.PIPE,
644
+ stderr=asyncio.subprocess.PIPE
645
+ )
646
+ diff_output, _ = await diff_proc.communicate()
647
+
648
+ if diff_proc.returncode == 0:
649
+ working_diff = diff_output.decode()
650
+ if working_diff.strip():
651
+ return {
652
+ "success": True,
653
+ "diff": working_diff,
654
+ "commit_hash": "working_directory",
655
+ "file_path": file_path,
656
+ "method": "working_directory",
657
+ "timestamp": timestamp
658
+ }
659
+
660
+ # Check if file is tracked by git
661
+ status_proc = await asyncio.create_subprocess_exec(
662
+ 'git', '-C', working_dir, 'ls-files', '--', file_path,
663
+ stdout=asyncio.subprocess.PIPE,
664
+ stderr=asyncio.subprocess.PIPE
665
+ )
666
+ status_output, _ = await status_proc.communicate()
667
+
668
+ is_tracked = status_proc.returncode == 0 and status_output.decode().strip()
669
+
670
+ if not is_tracked:
671
+ # File is not tracked by git
672
+ return {
673
+ "success": False,
674
+ "error": "This file is not tracked by git",
675
+ "file_path": file_path,
676
+ "working_dir": working_dir,
677
+ "suggestions": [
678
+ "This file has not been added to git yet",
679
+ "Use 'git add' to track this file before viewing its diff",
680
+ "Git diff can only show changes for files that are tracked by git"
681
+ ]
682
+ }
683
+
684
+ # File is tracked but has no changes to show
685
+ suggestions = [
686
+ "The file may not have any committed changes yet",
687
+ "The file may have been added but not committed",
688
+ "The timestamp may be outside the git history range"
689
+ ]
690
+
691
+ if os.path.isabs(file_path) and not file_path.startswith(os.getcwd()):
692
+ current_repo = os.path.basename(os.getcwd())
693
+ file_repo = "unknown"
694
+ # Try to extract repository name from path
695
+ path_parts = file_path.split("/")
696
+ if "Projects" in path_parts:
697
+ idx = path_parts.index("Projects")
698
+ if idx + 1 < len(path_parts):
699
+ file_repo = path_parts[idx + 1]
700
+
701
+ suggestions.clear()
702
+ suggestions.append(f"This file is from the '{file_repo}' repository")
703
+ suggestions.append(f"The git diff viewer is running from the '{current_repo}' repository")
704
+ suggestions.append("Git diff can only show changes for files in the current repository")
705
+ suggestions.append("To view changes for this file, run the monitoring dashboard from its repository")
706
+
707
+ return {
708
+ "success": False,
709
+ "error": "No git history found for this file",
710
+ "file_path": file_path,
711
+ "suggestions": suggestions
712
+ }
713
+
714
+ finally:
715
+ os.chdir(original_cwd)
716
+
717
+ except Exception as e:
718
+ self.logger.error(f"Error in generate_git_diff: {e}")
719
+ return {
720
+ "success": False,
721
+ "error": f"Git diff generation failed: {str(e)}",
722
+ "file_path": file_path
723
+ }