bamboo-coding 0.1.0__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 (62) hide show
  1. agent/__init__.py +0 -0
  2. agent/bamboo_git_agent/__init__.py +0 -0
  3. agent/bamboo_git_agent/capabilities/__init__.py +0 -0
  4. agent/bamboo_git_agent/capabilities/repo.py +189 -0
  5. agent/bamboo_git_agent/config.py +76 -0
  6. agent/bamboo_git_agent/controller_client.py +73 -0
  7. agent/bamboo_git_agent/journal.py +209 -0
  8. agent/bamboo_git_agent/main.py +164 -0
  9. agent/bamboo_git_agent/protocol.py +21 -0
  10. agent/bamboo_git_agent/status.py +59 -0
  11. api/__init__.py +2 -0
  12. api/commit.py +82 -0
  13. api/repository.py +592 -0
  14. bamboo_coding/__init__.py +3 -0
  15. bamboo_coding/client/__init__.py +3 -0
  16. bamboo_coding/client/capabilities/__init__.py +1 -0
  17. bamboo_coding/client/capabilities/repo.py +279 -0
  18. bamboo_coding/client/config.py +179 -0
  19. bamboo_coding/client/controller_client.py +112 -0
  20. bamboo_coding/client/journal.py +1 -0
  21. bamboo_coding/client/main.py +425 -0
  22. bamboo_coding/client/status.py +59 -0
  23. bamboo_coding/client/terminal_runtime.py +251 -0
  24. bamboo_coding/server/__init__.py +3 -0
  25. bamboo_coding/server/core/__init__.py +1 -0
  26. bamboo_coding/server/core/config.py +1 -0
  27. bamboo_coding/server/main.py +39 -0
  28. bamboo_coding/shared/__init__.py +39 -0
  29. bamboo_coding/shared/protocol.py +1 -0
  30. bamboo_coding-0.1.0.dist-info/METADATA +284 -0
  31. bamboo_coding-0.1.0.dist-info/RECORD +62 -0
  32. bamboo_coding-0.1.0.dist-info/WHEEL +5 -0
  33. bamboo_coding-0.1.0.dist-info/entry_points.txt +3 -0
  34. bamboo_coding-0.1.0.dist-info/top_level.txt +6 -0
  35. controller/__init__.py +0 -0
  36. controller/app/__init__.py +0 -0
  37. controller/app/api/__init__.py +0 -0
  38. controller/app/api/agents/__init__.py +3 -0
  39. controller/app/api/agents/ws.py +133 -0
  40. controller/app/api/public/__init__.py +11 -0
  41. controller/app/api/public/repos.py +207 -0
  42. controller/app/api/public/terminals.py +64 -0
  43. controller/app/core/__init__.py +0 -0
  44. controller/app/core/config.py +20 -0
  45. controller/app/core/errors.py +14 -0
  46. controller/app/db/__init__.py +0 -0
  47. controller/app/db/models.py +42 -0
  48. controller/app/db/session.py +74 -0
  49. controller/app/main.py +71 -0
  50. controller/app/schemas/__init__.py +23 -0
  51. controller/app/schemas/agent_messages.py +21 -0
  52. controller/app/schemas/public.py +3 -0
  53. controller/app/services/__init__.py +0 -0
  54. controller/app/services/agents.py +108 -0
  55. controller/app/services/registrations.py +17 -0
  56. controller/app/services/repositories.py +85 -0
  57. controller/app/services/router.py +140 -0
  58. controller/app/services/tasks.py +104 -0
  59. controller/app/services/terminals.py +148 -0
  60. git_utils.py +2238 -0
  61. shared/__init__.py +39 -0
  62. shared/protocol.py +253 -0
git_utils.py ADDED
@@ -0,0 +1,2238 @@
1
+ """
2
+ Git Operations Utility Module
3
+
4
+ This module provides a centralized interface for all Git operations used by the application.
5
+ It wraps GitPython library calls with validation, error handling, and caching.
6
+
7
+ Security:
8
+ - All paths are validated to prevent directory traversal attacks
9
+ - Repository paths must be within configured allowed directories
10
+ - Write operations (branch management) include validation and confirmation
11
+
12
+ Performance:
13
+ - File tree results are cached with 60-second TTL
14
+ - Diff context is limited to prevent memory issues with large files
15
+ """
16
+
17
+ import os
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Dict, List, Optional, Tuple, Any
21
+ from functools import wraps
22
+
23
+ try:
24
+ from git import Repo, InvalidGitRepositoryError, GitCommandError, NoSuchPathError
25
+ from git.exc import BadName
26
+ except ImportError:
27
+ raise ImportError("GitPython is required. Install with: pip install gitpython")
28
+
29
+
30
+ # Custom Exceptions
31
+ class GitPermissionError(Exception):
32
+ """Raised when Git operation fails due to permission issues."""
33
+ pass
34
+
35
+
36
+ class GitCorruptionError(Exception):
37
+ """Raised when repository appears to be corrupted."""
38
+ pass
39
+
40
+
41
+ class GitConflictError(Exception):
42
+ """Raised when a merge operation results in conflicts."""
43
+ pass
44
+
45
+
46
+ # Caching decorator with TTL
47
+ _cache = {}
48
+
49
+
50
+ def clear_cache():
51
+ """Clear all cached data. Useful for testing or when you need fresh data."""
52
+ global _cache
53
+ _cache.clear()
54
+
55
+
56
+ def ttl_cache(ttl_seconds: int = 60):
57
+ """Simple TTL cache decorator for expensive operations."""
58
+ def decorator(func):
59
+ @wraps(func)
60
+ def wrapper(*args, **kwargs):
61
+ # Create cache key from function name and arguments
62
+ cache_key = (func.__name__, str(args), str(sorted(kwargs.items())))
63
+ current_time = time.time()
64
+
65
+ # Check if cached and not expired
66
+ if cache_key in _cache:
67
+ result, timestamp = _cache[cache_key]
68
+ if current_time - timestamp < ttl_seconds:
69
+ return result
70
+
71
+ # Call function and cache result
72
+ result = func(*args, **kwargs)
73
+ _cache[cache_key] = (result, current_time)
74
+ return result
75
+ return wrapper
76
+ return decorator
77
+
78
+
79
+ def _normalize_path(path: str) -> Path:
80
+ """
81
+ Normalize and resolve a file path.
82
+
83
+ Args:
84
+ path: Path string to normalize
85
+
86
+ Returns:
87
+ Resolved Path object
88
+
89
+ Raises:
90
+ ValueError: If path contains traversal sequences
91
+ """
92
+ if ".." in path:
93
+ raise ValueError("Path traversal detected")
94
+
95
+ return Path(path).resolve()
96
+
97
+
98
+ def is_valid_repository(repo_path: str) -> bool:
99
+ """
100
+ Check if a directory is a valid Git repository.
101
+
102
+ Args:
103
+ repo_path: Path to potential Git repository
104
+
105
+ Returns:
106
+ True if valid Git repository, False otherwise
107
+ """
108
+ try:
109
+ normalized_path = _normalize_path(repo_path)
110
+
111
+ # Check if directory exists
112
+ if not normalized_path.exists() or not normalized_path.is_dir():
113
+ return False
114
+
115
+ # Try to create Repo object
116
+ Repo(str(normalized_path))
117
+ return True
118
+
119
+ except (InvalidGitRepositoryError, ValueError, NoSuchPathError):
120
+ return False
121
+ except PermissionError:
122
+ return False
123
+
124
+
125
+ @ttl_cache(ttl_seconds=5)
126
+ def get_file_tree(repo_path: str, changed_only: bool = True) -> Dict[str, Any]:
127
+ """
128
+ Get hierarchical file structure of the repository.
129
+
130
+ Args:
131
+ repo_path: Path to Git repository
132
+ changed_only: If True, return only changed files (modified, staged, added, deleted, untracked).
133
+ If False, return all files in repository. Default is True for performance.
134
+
135
+ Returns:
136
+ Dictionary with file tree structure and file statuses
137
+
138
+ Raises:
139
+ InvalidGitRepositoryError: If path is not a valid Git repository
140
+ GitPermissionError: If permission denied accessing repository
141
+ """
142
+ try:
143
+ normalized_path = _normalize_path(repo_path)
144
+ repo = Repo(str(normalized_path))
145
+
146
+ # Get tracked files from git ls-tree
147
+ tracked_files = []
148
+ if not changed_only:
149
+ # Only load all files if explicitly requested
150
+ try:
151
+ for item in repo.head.commit.tree.traverse():
152
+ if item.type == 'blob': # Only files, not trees
153
+ tracked_files.append({
154
+ 'path': item.path,
155
+ 'type': 'file',
156
+ 'size': item.size,
157
+ 'status': 'unmodified'
158
+ })
159
+ except (ValueError, BadName):
160
+ # Empty repository or no commits
161
+ pass
162
+
163
+ # Get file statuses
164
+ status_map = {}
165
+
166
+ # First, get staged changes (index vs HEAD)
167
+ # repo.index.diff('HEAD') compares: index (a) to HEAD (b)
168
+ try:
169
+ for item in repo.index.diff('HEAD'):
170
+ # a_blob = index, b_blob = HEAD
171
+ if item.a_blob is not None and item.b_blob is None:
172
+ # File exists in index but not in HEAD = newly added file
173
+ status_map[item.a_path] = 'added'
174
+ elif item.a_blob is None and item.b_blob is not None:
175
+ # File doesn't exist in index but exists in HEAD = staged deleted file
176
+ # Use 'deleted-staged' to distinguish from unstaged deleted files
177
+ status_map[item.a_path or item.b_path] = 'deleted-staged'
178
+ elif item.a_blob is not None and item.b_blob is not None:
179
+ # File exists in both = modified file
180
+ status_map[item.a_path] = 'staged'
181
+ except (ValueError, BadName):
182
+ # Empty repository or no commits yet
183
+ pass
184
+
185
+ # Then, get unstaged changes (working tree vs index)
186
+ # These take precedence over staged status
187
+ for item in repo.index.diff(None):
188
+ if item.change_type == 'M':
189
+ status_map[item.a_path] = 'modified'
190
+ elif item.change_type == 'D':
191
+ # Unstaged deleted file (working tree deleted, still in index)
192
+ status_map[item.a_path] = 'deleted'
193
+ elif item.change_type in ('A', 'R', 'T'):
194
+ # These are unlikely for unstaged changes, but handle them
195
+ status_map[item.a_path] = 'modified'
196
+
197
+ # Add untracked files
198
+ for item in repo.untracked_files:
199
+ status_map[item] = 'untracked'
200
+
201
+ if changed_only:
202
+ # Only return changed files
203
+ for path, status in status_map.items():
204
+ # Try to get file size for tracked files
205
+ file_size = 0
206
+ try:
207
+ if status != 'untracked':
208
+ item = repo.head.commit.tree / path
209
+ if item.type == 'blob':
210
+ file_size = item.size
211
+ except (KeyError, ValueError, BadName):
212
+ pass
213
+
214
+ tracked_files.append({
215
+ 'path': path,
216
+ 'type': 'file',
217
+ 'size': file_size,
218
+ 'status': status
219
+ })
220
+ else:
221
+ # Update statuses for all files
222
+ for file_info in tracked_files:
223
+ if file_info['path'] in status_map:
224
+ file_info['status'] = status_map[file_info['path']]
225
+
226
+ # Add untracked files
227
+ for untracked in repo.untracked_files:
228
+ tracked_files.append({
229
+ 'path': untracked,
230
+ 'type': 'file',
231
+ 'size': 0,
232
+ 'status': 'untracked'
233
+ })
234
+
235
+ # Build tree structure
236
+ tree = {
237
+ 'files': tracked_files,
238
+ 'branch': repo.active_branch.name if not repo.head.is_detached else repo.head.commit.hexsha[:7],
239
+ 'is_detached': repo.head.is_detached,
240
+ 'changed_only': changed_only
241
+ }
242
+
243
+ return tree
244
+
245
+ except PermissionError as e:
246
+ raise GitPermissionError(f"Permission denied accessing repository: {e}")
247
+ except InvalidGitRepositoryError:
248
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
249
+
250
+
251
+ def browse_directory(repo_path: str, dir_path: str) -> Dict[str, Any]:
252
+ """
253
+ Get contents of a specific directory within the repository.
254
+
255
+ Args:
256
+ repo_path: Path to Git repository
257
+ dir_path: Directory path relative to repository root (e.g., '/', 'src/components')
258
+
259
+ Returns:
260
+ Dictionary with directory contents:
261
+ {
262
+ 'path': str,
263
+ 'contents': [
264
+ {
265
+ 'name': str,
266
+ 'path': str,
267
+ 'type': 'file' | 'directory',
268
+ 'size': int (for files),
269
+ 'status': str (for files),
270
+ 'has_changes': bool (for directories)
271
+ }
272
+ ]
273
+ }
274
+
275
+ Raises:
276
+ InvalidGitRepositoryError: If path is not a valid Git repository
277
+ GitPermissionError: If permission denied accessing repository
278
+ ValueError: If path is invalid or contains traversal patterns
279
+ FileNotFoundError: If directory does not exist
280
+ """
281
+ try:
282
+ # Validate and normalize the directory path
283
+ if '..' in dir_path or dir_path.startswith('/..'):
284
+ raise ValueError("Invalid path: directory traversal detected")
285
+
286
+ # Normalize directory path
287
+ dir_path = dir_path.strip('/')
288
+ if dir_path and not dir_path.endswith('/'):
289
+ dir_path += '/'
290
+
291
+ normalized_repo_path = _normalize_path(repo_path)
292
+ repo = Repo(str(normalized_repo_path))
293
+
294
+ # Get file statuses for the entire repository
295
+ status_map = {}
296
+
297
+ # Unstaged changes (working tree vs index)
298
+ for item in repo.index.diff(None):
299
+ if item.change_type == 'M':
300
+ status_map[item.a_path] = 'modified'
301
+ elif item.change_type == 'D':
302
+ # Unstaged deleted file
303
+ status_map[item.a_path] = 'deleted'
304
+
305
+ # Staged changes (index vs HEAD)
306
+ # repo.index.diff('HEAD') compares: index (a) to HEAD (b)
307
+ for item in repo.index.diff('HEAD'):
308
+ if item.a_path in status_map:
309
+ # If file has unstaged changes, keep that status (it takes precedence)
310
+ continue
311
+ # a_blob = index, b_blob = HEAD
312
+ if item.a_blob is not None and item.b_blob is None:
313
+ status_map[item.a_path] = 'added'
314
+ elif item.a_blob is None and item.b_blob is not None:
315
+ # Staged deleted file
316
+ status_map[item.a_path or item.b_path] = 'deleted-staged'
317
+ else:
318
+ status_map[item.a_path] = 'staged'
319
+
320
+ # Untracked files
321
+ for item in repo.untracked_files:
322
+ status_map[item] = 'untracked'
323
+
324
+ contents = []
325
+ seen_dirs = set()
326
+
327
+ # Browse tracked files
328
+ try:
329
+ if dir_path:
330
+ # Navigate to the specific directory in the tree
331
+ try:
332
+ tree_obj = repo.head.commit.tree / dir_path.rstrip('/')
333
+ if tree_obj.type != 'tree':
334
+ raise ValueError("Path is not a directory")
335
+ except KeyError:
336
+ # Directory doesn't exist in tree, might be new/untracked
337
+ pass
338
+ else:
339
+ for item in tree_obj:
340
+ if item.type == 'tree':
341
+ # It's a subdirectory
342
+ subdir_path = f"{dir_path}{item.name}/"
343
+ has_changes = any(p.startswith(subdir_path) for p in status_map.keys())
344
+ contents.append({
345
+ 'name': item.name,
346
+ 'path': subdir_path.rstrip('/'),
347
+ 'type': 'directory',
348
+ 'has_changes': has_changes
349
+ })
350
+ seen_dirs.add(item.name)
351
+ elif item.type == 'blob':
352
+ # It's a file
353
+ file_path = f"{dir_path}{item.name}"
354
+ contents.append({
355
+ 'name': item.name,
356
+ 'path': file_path,
357
+ 'type': 'file',
358
+ 'size': item.size,
359
+ 'status': status_map.get(file_path, 'unmodified')
360
+ })
361
+ else:
362
+ # Root directory
363
+ for item in repo.head.commit.tree:
364
+ if item.type == 'tree':
365
+ subdir_path = f"{item.name}/"
366
+ has_changes = any(p.startswith(subdir_path) for p in status_map.keys())
367
+ contents.append({
368
+ 'name': item.name,
369
+ 'path': item.name,
370
+ 'type': 'directory',
371
+ 'has_changes': has_changes
372
+ })
373
+ seen_dirs.add(item.name)
374
+ elif item.type == 'blob':
375
+ file_path = item.name
376
+ contents.append({
377
+ 'name': item.name,
378
+ 'path': file_path,
379
+ 'type': 'file',
380
+ 'size': item.size,
381
+ 'status': status_map.get(file_path, 'unmodified')
382
+ })
383
+ except (ValueError, BadName):
384
+ # Empty repository or no commits
385
+ pass
386
+
387
+ # Add untracked files/directories in this directory
388
+ for untracked in repo.untracked_files:
389
+ # Check if untracked file is in the current directory
390
+ if dir_path:
391
+ if untracked.startswith(dir_path):
392
+ relative_path = untracked[len(dir_path):]
393
+ if '/' in relative_path:
394
+ # It's in a subdirectory
395
+ dir_name = relative_path.split('/')[0]
396
+ if dir_name not in seen_dirs:
397
+ contents.append({
398
+ 'name': dir_name,
399
+ 'path': f"{dir_path}{dir_name}",
400
+ 'type': 'directory',
401
+ 'has_changes': True
402
+ })
403
+ seen_dirs.add(dir_name)
404
+ else:
405
+ # It's a file in this directory
406
+ contents.append({
407
+ 'name': relative_path,
408
+ 'path': untracked,
409
+ 'type': 'file',
410
+ 'size': 0,
411
+ 'status': 'untracked'
412
+ })
413
+ else:
414
+ # Root directory
415
+ if '/' in untracked:
416
+ dir_name = untracked.split('/')[0]
417
+ if dir_name not in seen_dirs:
418
+ contents.append({
419
+ 'name': dir_name,
420
+ 'path': dir_name,
421
+ 'type': 'directory',
422
+ 'has_changes': True
423
+ })
424
+ seen_dirs.add(dir_name)
425
+ else:
426
+ contents.append({
427
+ 'name': untracked,
428
+ 'path': untracked,
429
+ 'type': 'file',
430
+ 'size': 0,
431
+ 'status': 'untracked'
432
+ })
433
+
434
+ # Sort contents: directories first, then files, alphabetically
435
+ contents.sort(key=lambda x: (x['type'] == 'file', x['name'].lower()))
436
+
437
+ return {
438
+ 'path': dir_path.rstrip('/') if dir_path else '/',
439
+ 'contents': contents
440
+ }
441
+
442
+ except PermissionError as e:
443
+ raise GitPermissionError(f"Permission denied accessing repository: {e}")
444
+ except InvalidGitRepositoryError:
445
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
446
+
447
+
448
+ def _build_untracked_file_diff(full_file_path: Path, file_path: str) -> Dict[str, Any]:
449
+ """Build a synthetic added-file diff for an untracked working tree file."""
450
+ file_size = full_file_path.stat().st_size
451
+ sample_size = min(8192, file_size)
452
+ with open(full_file_path, 'rb') as file_obj:
453
+ sample = file_obj.read(sample_size)
454
+
455
+ if not is_text_file(str(full_file_path), sample):
456
+ return {
457
+ 'is_binary': True,
458
+ 'file_path': file_path,
459
+ 'size': file_size
460
+ }
461
+
462
+ content = full_file_path.read_text(encoding='utf-8', errors='replace')
463
+ lines = content.splitlines()
464
+ diff_lines = [f"@@ -0,0 +1,{len(lines)} @@"] if lines else ['@@ -0,0 +0,0 @@']
465
+ diff_lines.extend(f'+{line}' for line in lines)
466
+ diff_text = '\n'.join(diff_lines)
467
+ if lines or content.endswith('\n'):
468
+ diff_text += '\n'
469
+
470
+ return {
471
+ 'is_binary': False,
472
+ 'diff': diff_text,
473
+ 'additions': len(lines),
474
+ 'deletions': 0,
475
+ 'file_path': file_path,
476
+ 'change_type': 'A'
477
+ }
478
+
479
+
480
+ def get_file_diff(repo_path: str, file_path: str, staged: bool = False, context_lines: int = 3) -> Dict[str, Any]:
481
+ """
482
+ Get diff for a specific file.
483
+
484
+ Args:
485
+ repo_path: Path to Git repository
486
+ file_path: Path to file within repository (relative to repo root)
487
+ staged: If True, get staged diff (index vs HEAD), else unstaged (working vs index)
488
+ context_lines: Number of context lines around changes
489
+
490
+ Returns:
491
+ Dictionary with diff information including:
492
+ - is_binary: Whether file is binary
493
+ - diff: Unified diff string
494
+ - additions: Number of lines added
495
+ - deletions: Number of lines deleted
496
+
497
+ Raises:
498
+ FileNotFoundError: If file doesn't exist in repository
499
+ InvalidGitRepositoryError: If path is not a valid Git repository
500
+ """
501
+ try:
502
+ normalized_path = _normalize_path(repo_path)
503
+ repo = Repo(str(normalized_path))
504
+
505
+ # Validate file path
506
+ file_path = file_path.lstrip('/')
507
+ full_file_path = normalized_path / file_path
508
+
509
+ if not full_file_path.exists():
510
+ # Check if it's a deleted file
511
+ pass
512
+
513
+ # Get diff
514
+ if staged:
515
+ # Staged changes: index vs HEAD (use R=True to show additions correctly)
516
+ diffs = repo.index.diff('HEAD', paths=file_path, create_patch=True, unified=context_lines, R=True)
517
+ else:
518
+ # Unstaged changes: working tree vs index
519
+ diffs = repo.index.diff(None, paths=file_path, create_patch=True, unified=context_lines)
520
+
521
+ # If no unstaged diff found, check if it's a new file that's staged
522
+ # In this case, show the staged diff so users can see the content
523
+ if not diffs:
524
+ staged_diffs = repo.index.diff('HEAD', paths=file_path, create_patch=True, unified=context_lines, R=True)
525
+ if staged_diffs:
526
+ # Check if it's a new file (with R=True: a_blob is HEAD, b_blob is index)
527
+ # For new files: a_blob (HEAD) is None, b_blob (index) exists
528
+ diff_item = staged_diffs[0]
529
+ if diff_item.a_blob is None and diff_item.b_blob is not None:
530
+ diffs = staged_diffs
531
+
532
+ if not diffs and file_path in repo.untracked_files and full_file_path.exists():
533
+ return _build_untracked_file_diff(full_file_path, file_path)
534
+
535
+ if not diffs:
536
+ return {
537
+ 'is_binary': False,
538
+ 'diff': '',
539
+ 'additions': 0,
540
+ 'deletions': 0,
541
+ 'file_path': file_path
542
+ }
543
+
544
+ diff_item = diffs[0]
545
+
546
+ # Check if binary
547
+ if diff_item.a_blob and diff_item.a_blob.size > 0:
548
+ # Simple heuristic: check for null bytes
549
+ try:
550
+ if diff_item.a_blob:
551
+ sample = diff_item.a_blob.data_stream.read(8192)
552
+ if b'\x00' in sample:
553
+ return {
554
+ 'is_binary': True,
555
+ 'file_path': file_path,
556
+ 'size': diff_item.a_blob.size if diff_item.a_blob else 0
557
+ }
558
+ except:
559
+ pass
560
+
561
+ # Get diff text
562
+ diff_text = diff_item.diff.decode('utf-8', errors='replace') if diff_item.diff else ''
563
+
564
+ # Count additions/deletions
565
+ additions = 0
566
+ deletions = 0
567
+ for line in diff_text.split('\n'):
568
+ if line.startswith('+') and not line.startswith('+++'):
569
+ additions += 1
570
+ elif line.startswith('-') and not line.startswith('---'):
571
+ deletions += 1
572
+
573
+ return {
574
+ 'is_binary': False,
575
+ 'diff': diff_text,
576
+ 'additions': additions,
577
+ 'deletions': deletions,
578
+ 'file_path': file_path,
579
+ 'change_type': diff_item.change_type
580
+ }
581
+
582
+ except PermissionError as e:
583
+ raise GitPermissionError(f"Permission denied accessing file: {e}")
584
+ except InvalidGitRepositoryError:
585
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
586
+
587
+
588
+ def get_commit_history(repo_path: str, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
589
+ """
590
+ Get commit history with pagination.
591
+
592
+ Args:
593
+ repo_path: Path to Git repository
594
+ limit: Maximum number of commits to return
595
+ offset: Number of commits to skip
596
+
597
+ Returns:
598
+ List of commit dictionaries with hash, author, date, and message
599
+
600
+ Raises:
601
+ InvalidGitRepositoryError: If path is not a valid Git repository
602
+ """
603
+ try:
604
+ normalized_path = _normalize_path(repo_path)
605
+ repo = Repo(str(normalized_path))
606
+
607
+ commits = []
608
+ for i, commit in enumerate(repo.iter_commits()):
609
+ if i < offset:
610
+ continue
611
+ if len(commits) >= limit:
612
+ break
613
+
614
+ commits.append({
615
+ 'hash': commit.hexsha,
616
+ 'short_hash': commit.hexsha[:7],
617
+ 'author': str(commit.author),
618
+ 'author_email': commit.author.email,
619
+ 'date': commit.committed_datetime.isoformat(),
620
+ 'message': commit.message.strip(),
621
+ 'parent_count': len(commit.parents)
622
+ })
623
+
624
+ return commits
625
+
626
+ except PermissionError as e:
627
+ raise GitPermissionError(f"Permission denied accessing repository: {e}")
628
+ except InvalidGitRepositoryError:
629
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
630
+
631
+
632
+ def get_commit_details(repo_path: str, commit_hash: str) -> Dict[str, Any]:
633
+ """
634
+ Get detailed information about a specific commit including changed files and diffs.
635
+
636
+ Args:
637
+ repo_path: Path to Git repository
638
+ commit_hash: Full or abbreviated commit hash
639
+
640
+ Returns:
641
+ Dictionary with commit metadata, changed files, and diffs
642
+
643
+ Raises:
644
+ ValueError: If commit hash is invalid or commit not found
645
+ InvalidGitRepositoryError: If path is not a valid Git repository
646
+ GitPermissionError: If permission denied accessing repository
647
+ """
648
+ try:
649
+ # Validate hash format (at least 4 hex characters)
650
+ if not commit_hash or len(commit_hash) < 4:
651
+ raise ValueError("Commit hash too short (minimum 4 characters)")
652
+ if not all(c in '0123456789abcdefABCDEF' for c in commit_hash):
653
+ raise ValueError("Invalid commit hash format")
654
+
655
+ normalized_path = _normalize_path(repo_path)
656
+ repo = Repo(str(normalized_path))
657
+
658
+ # Resolve abbreviated hash to full commit
659
+ try:
660
+ commit = repo.commit(commit_hash)
661
+ except (BadName, ValueError):
662
+ raise ValueError(f"Commit not found: {commit_hash}")
663
+
664
+ # Get commit metadata
665
+ result = {
666
+ 'hash': commit.hexsha,
667
+ 'short_hash': commit.hexsha[:7],
668
+ 'author_name': str(commit.author),
669
+ 'author_email': commit.author.email,
670
+ 'committed_date': commit.committed_datetime.isoformat(),
671
+ 'message': commit.message.strip(),
672
+ 'parents': [p.hexsha for p in commit.parents],
673
+ 'files': []
674
+ }
675
+
676
+ # Get parent for diff comparison (use empty tree for initial commit)
677
+ if commit.parents:
678
+ parent = commit.parents[0]
679
+ diffs = parent.diff(commit, create_patch=True)
680
+ else:
681
+ # Initial commit - diff against empty tree (4b825dc... is Git's empty tree hash)
682
+ diffs = commit.diff('4b825dc642cb6eb9a060e54bf8d69288fbee4904', create_patch=True)
683
+
684
+ # Process each changed file
685
+ for diff in diffs:
686
+ file_info = {
687
+ 'path': diff.b_path or diff.a_path,
688
+ 'status': _get_diff_status(diff),
689
+ 'additions': 0,
690
+ 'deletions': 0,
691
+ 'is_binary': diff.b_blob and diff.b_blob.mime_type and not diff.b_blob.mime_type.startswith('text/'),
692
+ 'diff': None
693
+ }
694
+
695
+ # Handle renamed files
696
+ if diff.renamed:
697
+ file_info['old_path'] = diff.a_path
698
+ file_info['new_path'] = diff.b_path
699
+
700
+ # Generate diff for text files only
701
+ if not file_info['is_binary'] and diff.diff:
702
+ try:
703
+ diff_text = diff.diff.decode('utf-8')
704
+ file_info['diff'] = diff_text
705
+
706
+ # Count additions/deletions
707
+ for line in diff_text.split('\n'):
708
+ if line.startswith('+') and not line.startswith('+++'):
709
+ file_info['additions'] += 1
710
+ elif line.startswith('-') and not line.startswith('---'):
711
+ file_info['deletions'] += 1
712
+ except UnicodeDecodeError:
713
+ file_info['is_binary'] = True
714
+
715
+ result['files'].append(file_info)
716
+
717
+ return result
718
+
719
+ except PermissionError as e:
720
+ raise GitPermissionError(f"Permission denied accessing repository: {e}")
721
+ except InvalidGitRepositoryError:
722
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
723
+
724
+
725
+ def _get_diff_status(diff) -> str:
726
+ """Get human-readable status from git diff object."""
727
+ if diff.new_file:
728
+ return 'added'
729
+ elif diff.deleted_file:
730
+ return 'deleted'
731
+ elif diff.renamed:
732
+ return 'renamed'
733
+ elif diff.copied_file:
734
+ return 'copied'
735
+ else:
736
+ return 'modified'
737
+
738
+
739
+ def get_repo_status(repo_path: str) -> Dict[str, Any]:
740
+ """
741
+ Get current repository status.
742
+
743
+ Args:
744
+ repo_path: Path to Git repository
745
+
746
+ Returns:
747
+ Dictionary with:
748
+ - branch: Current branch name or commit hash if detached
749
+ - is_detached: Whether HEAD is detached
750
+ - modified: List of modified files
751
+ - added: List of added files
752
+ - deleted: List of deleted files
753
+ - untracked: List of untracked files
754
+ - ahead: Number of commits ahead of remote
755
+ - behind: Number of commits behind remote
756
+
757
+ Raises:
758
+ InvalidGitRepositoryError: If path is not a valid Git repository
759
+ """
760
+ try:
761
+ normalized_path = _normalize_path(repo_path)
762
+ repo = Repo(str(normalized_path))
763
+
764
+ # Get branch info
765
+ if repo.head.is_detached:
766
+ branch_name = repo.head.commit.hexsha[:7]
767
+ is_detached = True
768
+ else:
769
+ branch_name = repo.active_branch.name
770
+ is_detached = False
771
+
772
+ # Get file statuses
773
+ modified = []
774
+ added = []
775
+ deleted = []
776
+
777
+ # Unstaged changes (working tree vs index)
778
+ for item in repo.index.diff(None):
779
+ if item.change_type == 'M':
780
+ modified.append(item.a_path)
781
+ elif item.change_type == 'D':
782
+ deleted.append(item.a_path)
783
+
784
+ # Staged changes (index vs HEAD)
785
+ # Use blob checking for reliable detection
786
+ for item in repo.index.diff('HEAD'):
787
+ # Skip if already in unstaged (unstaged takes precedence)
788
+ if item.a_path in modified or item.a_path in deleted:
789
+ continue
790
+
791
+ # a_blob = index, b_blob = HEAD
792
+ if item.a_blob is not None and item.b_blob is None:
793
+ # File exists in index but not in HEAD = newly added file
794
+ added.append(item.a_path)
795
+ elif item.a_blob is None and item.b_blob is not None:
796
+ # File doesn't exist in index but exists in HEAD = deleted file
797
+ deleted.append(item.a_path or item.b_path)
798
+ elif item.a_blob is not None and item.b_blob is not None:
799
+ # File exists in both = modified file
800
+ modified.append(item.a_path)
801
+
802
+ untracked = list(repo.untracked_files)
803
+
804
+ # Get ahead/behind info
805
+ ahead = 0
806
+ behind = 0
807
+ if not is_detached:
808
+ try:
809
+ tracking_branch = repo.active_branch.tracking_branch()
810
+ if tracking_branch:
811
+ ahead = len(list(repo.iter_commits(f'{tracking_branch.name}..{branch_name}')))
812
+ behind = len(list(repo.iter_commits(f'{branch_name}..{tracking_branch.name}')))
813
+ except:
814
+ pass
815
+
816
+ return {
817
+ 'branch': branch_name,
818
+ 'is_detached': is_detached,
819
+ 'modified': modified,
820
+ 'added': added,
821
+ 'deleted': deleted,
822
+ 'untracked': untracked,
823
+ 'ahead': ahead,
824
+ 'behind': behind
825
+ }
826
+
827
+ except PermissionError as e:
828
+ raise GitPermissionError(f"Permission denied accessing repository: {e}")
829
+ except InvalidGitRepositoryError:
830
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
831
+
832
+
833
+ def validate_branch_name(branch_name: str) -> bool:
834
+ """
835
+ Validate branch name according to Git naming rules.
836
+
837
+ Args:
838
+ branch_name: Branch name to validate
839
+
840
+ Returns:
841
+ True if valid
842
+
843
+ Raises:
844
+ ValueError: If branch name is invalid with descriptive message
845
+ """
846
+ if not branch_name or not branch_name.strip():
847
+ raise ValueError("Branch name cannot be empty")
848
+
849
+ # Check for invalid characters
850
+ invalid_chars = [' ', '..', '~', '^', ':', '?', '*', '[', '\\', '\x7f']
851
+ for char in invalid_chars:
852
+ if char in branch_name:
853
+ raise ValueError(f"Branch name cannot contain '{char}'")
854
+
855
+ # Check invalid patterns
856
+ if branch_name.startswith('-'):
857
+ raise ValueError("Branch name cannot start with '-'")
858
+ if branch_name.endswith('.'):
859
+ raise ValueError("Branch name cannot end with '.'")
860
+ if branch_name.endswith('.lock'):
861
+ raise ValueError("Branch name cannot end with '.lock'")
862
+ if branch_name == '/':
863
+ raise ValueError("Branch name cannot be just '/'")
864
+
865
+ return True
866
+
867
+
868
+ def list_branches(repo_path: str) -> Dict[str, Any]:
869
+ """
870
+ List all branches in the repository.
871
+
872
+ Args:
873
+ repo_path: Path to Git repository
874
+
875
+ Returns:
876
+ Dictionary with local and remote branches:
877
+ {
878
+ 'local': [{'name': str, 'commit': str, 'message': str, ...}],
879
+ 'remote': [{'name': str, 'commit': str, ...}],
880
+ 'current': str or None
881
+ }
882
+
883
+ Raises:
884
+ InvalidGitRepositoryError: If path is not a valid Git repository
885
+ """
886
+ try:
887
+ normalized_path = _normalize_path(repo_path)
888
+ repo = Repo(str(normalized_path))
889
+
890
+ local_branches = []
891
+ current_branch = None
892
+
893
+ # Get current branch
894
+ if not repo.head.is_detached:
895
+ current_branch = repo.active_branch.name
896
+
897
+ # Get local branches
898
+ for branch in repo.heads:
899
+ branch_info = {
900
+ 'name': branch.name,
901
+ 'commit': branch.commit.hexsha,
902
+ 'short_commit': branch.commit.hexsha[:7],
903
+ 'message': branch.commit.message.strip().split('\n')[0],
904
+ 'author': str(branch.commit.author),
905
+ 'date': branch.commit.committed_datetime.isoformat(),
906
+ 'is_current': branch.name == current_branch
907
+ }
908
+
909
+ # Check if merged into current branch
910
+ if current_branch and branch.name != current_branch:
911
+ try:
912
+ merge_base = repo.merge_base(branch.commit, repo.active_branch.commit)
913
+ branch_info['is_merged'] = merge_base and merge_base[0] == branch.commit
914
+ except:
915
+ branch_info['is_merged'] = False
916
+ else:
917
+ branch_info['is_merged'] = False
918
+
919
+ local_branches.append(branch_info)
920
+
921
+ # Get remote branches
922
+ remote_branches = []
923
+ for ref in repo.remotes.origin.refs if repo.remotes else []:
924
+ if ref.name != 'origin/HEAD':
925
+ remote_branches.append({
926
+ 'name': ref.name,
927
+ 'commit': ref.commit.hexsha,
928
+ 'short_commit': ref.commit.hexsha[:7],
929
+ 'message': ref.commit.message.strip().split('\n')[0],
930
+ 'author': str(ref.commit.author),
931
+ 'date': ref.commit.committed_datetime.isoformat()
932
+ })
933
+
934
+ return {
935
+ 'local': local_branches,
936
+ 'remote': remote_branches,
937
+ 'current': current_branch
938
+ }
939
+
940
+ except PermissionError as e:
941
+ raise GitPermissionError(f"Permission denied accessing repository: {e}")
942
+ except InvalidGitRepositoryError:
943
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
944
+
945
+
946
+ def create_branch(repo_path: str, branch_name: str, start_point: Optional[str] = None) -> Dict[str, Any]:
947
+ """
948
+ Create a new branch.
949
+
950
+ Args:
951
+ repo_path: Path to Git repository
952
+ branch_name: Name for the new branch
953
+ start_point: Commit/branch/tag to start from (default: HEAD)
954
+
955
+ Returns:
956
+ Dictionary with new branch details
957
+
958
+ Raises:
959
+ ValueError: If branch name is invalid or already exists
960
+ InvalidGitRepositoryError: If path is not a valid Git repository
961
+ """
962
+ try:
963
+ validate_branch_name(branch_name)
964
+ normalized_path = _normalize_path(repo_path)
965
+ repo = Repo(str(normalized_path))
966
+
967
+ # Check if branch already exists
968
+ if branch_name in [b.name for b in repo.heads]:
969
+ raise ValueError(f"Branch '{branch_name}' already exists")
970
+
971
+ # Create branch
972
+ if start_point:
973
+ try:
974
+ commit = repo.commit(start_point)
975
+ new_branch = repo.create_head(branch_name, commit)
976
+ except BadName:
977
+ raise ValueError(f"Invalid start point: {start_point}")
978
+ else:
979
+ new_branch = repo.create_head(branch_name)
980
+
981
+ return {
982
+ 'name': new_branch.name,
983
+ 'commit': new_branch.commit.hexsha,
984
+ 'short_commit': new_branch.commit.hexsha[:7],
985
+ 'message': new_branch.commit.message.strip()
986
+ }
987
+
988
+ except PermissionError as e:
989
+ raise GitPermissionError(f"Permission denied creating branch: {e}")
990
+ except InvalidGitRepositoryError:
991
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
992
+
993
+
994
+ def switch_branch(repo_path: str, branch_name: str) -> Dict[str, Any]:
995
+ """
996
+ Switch to a different branch (checkout).
997
+
998
+ Args:
999
+ repo_path: Path to Git repository
1000
+ branch_name: Name of branch to switch to
1001
+
1002
+ Returns:
1003
+ Dictionary with switched branch details and status
1004
+
1005
+ Raises:
1006
+ ValueError: If branch doesn't exist
1007
+ GitPermissionError: If there are uncommitted changes that would be overwritten
1008
+ InvalidGitRepositoryError: If path is not a valid Git repository
1009
+ """
1010
+ try:
1011
+ normalized_path = _normalize_path(repo_path)
1012
+ repo = Repo(str(normalized_path))
1013
+
1014
+ # Check if branch exists
1015
+ if branch_name not in [b.name for b in repo.heads]:
1016
+ # Check if it's a remote branch
1017
+ remote_branch = f"origin/{branch_name}"
1018
+ if repo.remotes and remote_branch in [ref.name for ref in repo.remotes.origin.refs]:
1019
+ # Create local tracking branch
1020
+ new_branch = repo.create_head(branch_name, repo.remotes.origin.refs[branch_name])
1021
+ new_branch.set_tracking_branch(repo.remotes.origin.refs[branch_name])
1022
+ repo.heads[branch_name].checkout()
1023
+ else:
1024
+ raise ValueError(f"Branch '{branch_name}' not found")
1025
+ else:
1026
+ # Check for uncommitted changes
1027
+ if repo.is_dirty(untracked_files=False):
1028
+ modified_files = [item.a_path for item in repo.index.diff(None)]
1029
+ raise GitPermissionError(
1030
+ f"Uncommitted changes would be overwritten: {', '.join(modified_files[:5])}"
1031
+ )
1032
+
1033
+ # Switch branch
1034
+ repo.heads[branch_name].checkout()
1035
+
1036
+ # Return new status
1037
+ return get_repo_status(repo_path)
1038
+
1039
+ except PermissionError as e:
1040
+ raise GitPermissionError(f"Permission denied switching branch: {e}")
1041
+ except InvalidGitRepositoryError:
1042
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1043
+ except GitCommandError as e:
1044
+ raise GitPermissionError(f"Failed to switch branch: {e}")
1045
+
1046
+
1047
+ def delete_branch(repo_path: str, branch_name: str, force: bool = False) -> Dict[str, str]:
1048
+ """
1049
+ Delete a branch.
1050
+
1051
+ Args:
1052
+ repo_path: Path to Git repository
1053
+ branch_name: Name of branch to delete
1054
+ force: If True, delete even if not fully merged
1055
+
1056
+ Returns:
1057
+ Dictionary with success message
1058
+
1059
+ Raises:
1060
+ ValueError: If trying to delete current branch or branch doesn't exist
1061
+ GitPermissionError: If branch has unmerged commits and force=False
1062
+ InvalidGitRepositoryError: If path is not a valid Git repository
1063
+ """
1064
+ try:
1065
+ normalized_path = _normalize_path(repo_path)
1066
+ repo = Repo(str(normalized_path))
1067
+
1068
+ # Check if branch exists
1069
+ if branch_name not in [b.name for b in repo.heads]:
1070
+ raise ValueError(f"Branch '{branch_name}' not found")
1071
+
1072
+ # Check if it's the current branch
1073
+ if not repo.head.is_detached and repo.active_branch.name == branch_name:
1074
+ raise ValueError("Cannot delete current branch")
1075
+
1076
+ branch = repo.heads[branch_name]
1077
+
1078
+ # Check if branch is merged (unless force=True)
1079
+ if not force and not repo.head.is_detached:
1080
+ try:
1081
+ merge_base = repo.merge_base(branch.commit, repo.active_branch.commit)
1082
+ if not (merge_base and merge_base[0] == branch.commit):
1083
+ raise ValueError(
1084
+ f"Branch '{branch_name}' has unmerged commits. Use force=True to delete anyway."
1085
+ )
1086
+ except:
1087
+ pass
1088
+
1089
+ # Delete branch
1090
+ repo.delete_head(branch, force=force)
1091
+
1092
+ return {
1093
+ 'message': f"Branch '{branch_name}' deleted successfully",
1094
+ 'deleted': branch_name
1095
+ }
1096
+
1097
+ except PermissionError as e:
1098
+ raise GitPermissionError(f"Permission denied deleting branch: {e}")
1099
+ except InvalidGitRepositoryError:
1100
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1101
+
1102
+
1103
+ def merge_branch(repo_path: str, source_branch: str, message: Optional[str] = None) -> Dict[str, Any]:
1104
+ """
1105
+ Merge a branch into the current branch.
1106
+
1107
+ Args:
1108
+ repo_path: Path to Git repository
1109
+ source_branch: Name of branch to merge from
1110
+ message: Optional custom merge commit message
1111
+
1112
+ Returns:
1113
+ Dictionary with merge details
1114
+
1115
+ Raises:
1116
+ ValueError: If source branch doesn't exist
1117
+ GitConflictError: If merge results in conflicts
1118
+ InvalidGitRepositoryError: If path is not a valid Git repository
1119
+ """
1120
+ try:
1121
+ normalized_path = _normalize_path(repo_path)
1122
+ repo = Repo(str(normalized_path))
1123
+
1124
+ # Check if source branch exists
1125
+ if source_branch not in [b.name for b in repo.heads]:
1126
+ raise ValueError(f"Source branch '{source_branch}' not found")
1127
+
1128
+ current_branch = repo.active_branch.name if not repo.head.is_detached else None
1129
+ source = repo.heads[source_branch]
1130
+
1131
+ # Attempt merge
1132
+ try:
1133
+ merge_base = repo.merge_base(repo.head.commit, source.commit)
1134
+
1135
+ # Check if it's a fast-forward merge
1136
+ if merge_base and merge_base[0] == repo.head.commit:
1137
+ # Fast-forward merge
1138
+ repo.head.reference = source.commit
1139
+ repo.head.reset(index=True, working_tree=True)
1140
+
1141
+ return {
1142
+ 'type': 'fast-forward',
1143
+ 'message': f"Fast-forward merge of '{source_branch}' into '{current_branch}'",
1144
+ 'commit': source.commit.hexsha,
1145
+ 'short_commit': source.commit.hexsha[:7]
1146
+ }
1147
+ else:
1148
+ # Three-way merge
1149
+ merge_msg = message or f"Merge branch '{source_branch}' into '{current_branch}'"
1150
+ repo.index.merge_tree(source.commit, base=merge_base[0] if merge_base else None)
1151
+
1152
+ # Check for conflicts
1153
+ unmerged = repo.index.unmerged_blobs()
1154
+ if unmerged:
1155
+ # Abort merge
1156
+ repo.index.reset(repo.head.commit)
1157
+ conflicted_files = list(unmerged.keys())
1158
+ raise GitConflictError(
1159
+ f"Merge conflict in files: {', '.join(conflicted_files[:10])}"
1160
+ )
1161
+
1162
+ # Commit merge
1163
+ repo.index.commit(
1164
+ merge_msg,
1165
+ parent_commits=(repo.head.commit, source.commit)
1166
+ )
1167
+
1168
+ return {
1169
+ 'type': 'three-way',
1170
+ 'message': f"Merged '{source_branch}' into '{current_branch}'",
1171
+ 'commit': repo.head.commit.hexsha,
1172
+ 'short_commit': repo.head.commit.hexsha[:7]
1173
+ }
1174
+
1175
+ except GitCommandError as e:
1176
+ # Abort merge on any error
1177
+ try:
1178
+ repo.git.merge('--abort')
1179
+ except:
1180
+ pass
1181
+ raise GitConflictError(f"Merge failed: {str(e)}")
1182
+
1183
+ except PermissionError as e:
1184
+ raise GitPermissionError(f"Permission denied during merge: {e}")
1185
+ except InvalidGitRepositoryError:
1186
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1187
+
1188
+
1189
+ def is_text_file(file_path: str, sample_data: Optional[bytes] = None) -> bool:
1190
+ """
1191
+ Determine if a file is a text file that can be displayed in an editor.
1192
+
1193
+ Args:
1194
+ file_path: Path to the file
1195
+ sample_data: Optional first bytes of file for content-based detection
1196
+
1197
+ Returns:
1198
+ True if file appears to be text, False if binary
1199
+ """
1200
+ # Common text file extensions
1201
+ text_extensions = {
1202
+ '.txt', '.md', '.markdown', '.rst',
1203
+ '.py', '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte',
1204
+ '.html', '.htm', '.xml', '.svg',
1205
+ '.css', '.scss', '.sass', '.less',
1206
+ '.json', '.yaml', '.yml', '.toml', '.ini', '.conf', '.config',
1207
+ '.sh', '.bash', '.zsh', '.fish', '.bat', '.ps1',
1208
+ '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx',
1209
+ '.java', '.kt', '.scala', '.groovy',
1210
+ '.rb', '.php', '.pl', '.lua', '.r',
1211
+ '.go', '.rs', '.swift', '.m', '.mm',
1212
+ '.sql', '.graphql', '.proto',
1213
+ '.env', '.gitignore', '.gitattributes', '.dockerignore',
1214
+ '.dockerfile', '.editorconfig', '.prettierrc', '.eslintrc'
1215
+ }
1216
+
1217
+ # Common binary file extensions
1218
+ binary_extensions = {
1219
+ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.svg',
1220
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
1221
+ '.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar',
1222
+ '.exe', '.dll', '.so', '.dylib', '.bin',
1223
+ '.mp3', '.mp4', '.avi', '.mov', '.wav', '.flac',
1224
+ '.pyc', '.pyo', '.class', '.o', '.a', '.lib',
1225
+ '.ttf', '.otf', '.woff', '.woff2', '.eot'
1226
+ }
1227
+
1228
+ # Check extension
1229
+ file_ext = Path(file_path).suffix.lower()
1230
+
1231
+ if file_ext in text_extensions:
1232
+ return True
1233
+
1234
+ if file_ext in binary_extensions:
1235
+ return False
1236
+
1237
+ # Content-based detection if sample provided
1238
+ if sample_data:
1239
+ # Check for null bytes (strong indicator of binary file)
1240
+ if b'\x00' in sample_data:
1241
+ return False
1242
+
1243
+ # Try to decode as UTF-8
1244
+ try:
1245
+ sample_data.decode('utf-8')
1246
+ return True
1247
+ except UnicodeDecodeError:
1248
+ return False
1249
+
1250
+ # Default to text for unknown extensions (can be refined)
1251
+ return file_ext in ['', '.cfg', '.log', '.dat'] or not file_ext
1252
+
1253
+
1254
+ def read_file_content(repo_path: str, file_path: str, max_size_bytes: int = 2 * 1024 * 1024) -> Dict[str, Any]:
1255
+ """
1256
+ Read file content from repository for display in editor.
1257
+
1258
+ Args:
1259
+ repo_path: Path to Git repository
1260
+ file_path: Path to file relative to repository root
1261
+ max_size_bytes: Maximum file size to read (default 2MB)
1262
+
1263
+ Returns:
1264
+ Dictionary with file content and metadata:
1265
+ {
1266
+ 'file_path': str,
1267
+ 'content': str (if text file),
1268
+ 'encoding': str,
1269
+ 'size': int,
1270
+ 'mime_type': str,
1271
+ 'is_binary': bool,
1272
+ 'message': str (if binary or too large)
1273
+ }
1274
+
1275
+ Raises:
1276
+ ValueError: If file path is invalid or contains traversal patterns
1277
+ FileNotFoundError: If file doesn't exist
1278
+ GitPermissionError: If permission denied
1279
+ InvalidGitRepositoryError: If path is not a valid Git repository
1280
+ """
1281
+ try:
1282
+ # Validate paths
1283
+ if '..' in file_path or file_path.startswith('/..'):
1284
+ raise ValueError("Invalid file path: directory traversal detected")
1285
+
1286
+ normalized_repo_path = _normalize_path(repo_path)
1287
+ repo = Repo(str(normalized_repo_path))
1288
+
1289
+ # Clean file path
1290
+ file_path = file_path.lstrip('/')
1291
+ full_file_path = normalized_repo_path / file_path
1292
+
1293
+ # Validate file is within repository bounds
1294
+ try:
1295
+ full_file_path.relative_to(normalized_repo_path)
1296
+ except ValueError:
1297
+ raise ValueError("Access denied: file outside repository")
1298
+
1299
+ # Check if file exists
1300
+ if not full_file_path.exists():
1301
+ raise FileNotFoundError(f"File not found: {file_path}")
1302
+
1303
+ # Check if it's a file (not a directory)
1304
+ if not full_file_path.is_file():
1305
+ raise ValueError("Path is not a file")
1306
+
1307
+ # Get file size
1308
+ file_size = full_file_path.stat().st_size
1309
+
1310
+ # Check size limit
1311
+ if file_size > max_size_bytes:
1312
+ return {
1313
+ 'file_path': file_path,
1314
+ 'size': file_size,
1315
+ 'is_binary': False,
1316
+ 'encoding': None,
1317
+ 'mime_type': 'application/octet-stream',
1318
+ 'message': f'File too large for editor (max {max_size_bytes // (1024 * 1024)}MB)'
1319
+ }
1320
+
1321
+ # Read first 8KB for binary detection
1322
+ sample_size = min(8192, file_size)
1323
+ with open(full_file_path, 'rb') as f:
1324
+ sample = f.read(sample_size)
1325
+
1326
+ # Determine if text file
1327
+ if not is_text_file(str(full_file_path), sample):
1328
+ # Binary file - determine MIME type
1329
+ mime_type = 'application/octet-stream'
1330
+ file_ext = full_file_path.suffix.lower()
1331
+
1332
+ # Common MIME types
1333
+ mime_types = {
1334
+ '.png': 'image/png',
1335
+ '.jpg': 'image/jpeg',
1336
+ '.jpeg': 'image/jpeg',
1337
+ '.gif': 'image/gif',
1338
+ '.pdf': 'application/pdf',
1339
+ '.zip': 'application/zip',
1340
+ '.tar': 'application/x-tar',
1341
+ '.gz': 'application/gzip'
1342
+ }
1343
+ mime_type = mime_types.get(file_ext, mime_type)
1344
+
1345
+ return {
1346
+ 'file_path': file_path,
1347
+ 'size': file_size,
1348
+ 'is_binary': True,
1349
+ 'encoding': None,
1350
+ 'mime_type': mime_type,
1351
+ 'message': 'Binary file cannot be displayed in editor'
1352
+ }
1353
+
1354
+ # Read full text file content
1355
+ try:
1356
+ with open(full_file_path, 'r', encoding='utf-8') as f:
1357
+ content = f.read()
1358
+ encoding = 'utf-8'
1359
+ except UnicodeDecodeError:
1360
+ # Try other encodings
1361
+ for enc in ['latin-1', 'cp1252', 'iso-8859-1']:
1362
+ try:
1363
+ with open(full_file_path, 'r', encoding=enc) as f:
1364
+ content = f.read()
1365
+ encoding = enc
1366
+ break
1367
+ except UnicodeDecodeError:
1368
+ continue
1369
+ else:
1370
+ # Fallback: treat as binary
1371
+ return {
1372
+ 'file_path': file_path,
1373
+ 'size': file_size,
1374
+ 'is_binary': True,
1375
+ 'encoding': None,
1376
+ 'mime_type': 'application/octet-stream',
1377
+ 'message': 'File encoding not supported'
1378
+ }
1379
+
1380
+ # Determine MIME type based on extension
1381
+ file_ext = full_file_path.suffix.lower()
1382
+ mime_types = {
1383
+ '.js': 'text/javascript',
1384
+ '.jsx': 'text/javascript',
1385
+ '.ts': 'text/typescript',
1386
+ '.tsx': 'text/typescript',
1387
+ '.py': 'text/x-python',
1388
+ '.html': 'text/html',
1389
+ '.css': 'text/css',
1390
+ '.json': 'application/json',
1391
+ '.xml': 'text/xml',
1392
+ '.md': 'text/markdown',
1393
+ '.txt': 'text/plain',
1394
+ '.yaml': 'text/yaml',
1395
+ '.yml': 'text/yaml',
1396
+ '.sh': 'text/x-sh',
1397
+ '.sql': 'text/x-sql'
1398
+ }
1399
+ mime_type = mime_types.get(file_ext, 'text/plain')
1400
+
1401
+ return {
1402
+ 'file_path': file_path,
1403
+ 'content': content,
1404
+ 'encoding': encoding,
1405
+ 'size': file_size,
1406
+ 'mime_type': mime_type,
1407
+ 'is_binary': False
1408
+ }
1409
+
1410
+ except PermissionError as e:
1411
+ raise GitPermissionError(f"Permission denied: {e}")
1412
+ except InvalidGitRepositoryError:
1413
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1414
+
1415
+
1416
+ def write_file_content(repo_path: str, file_path: str, content: str, encoding: str = 'utf-8') -> Dict[str, Any]:
1417
+ """
1418
+ Write content to a file in the repository.
1419
+
1420
+ Args:
1421
+ repo_path: Path to Git repository
1422
+ file_path: Path to file relative to repository root
1423
+ content: Content to write to file
1424
+ encoding: Text encoding to use (default: utf-8)
1425
+
1426
+ Returns:
1427
+ Dictionary with success information:
1428
+ {
1429
+ 'file_path': str,
1430
+ 'size': int,
1431
+ 'message': str
1432
+ }
1433
+
1434
+ Raises:
1435
+ ValueError: If file path is invalid or contains traversal patterns
1436
+ GitPermissionError: If permission denied
1437
+ InvalidGitRepositoryError: If path is not a valid Git repository
1438
+ """
1439
+ try:
1440
+ # Validate paths
1441
+ if '..' in file_path or file_path.startswith('/..'):
1442
+ raise ValueError("Invalid file path: directory traversal detected")
1443
+
1444
+ normalized_repo_path = _normalize_path(repo_path)
1445
+ repo = Repo(str(normalized_repo_path))
1446
+
1447
+ # Clean file path
1448
+ file_path = file_path.lstrip('/')
1449
+ full_file_path = normalized_repo_path / file_path
1450
+
1451
+ # Validate file is within repository bounds
1452
+ try:
1453
+ full_file_path.relative_to(normalized_repo_path)
1454
+ except ValueError:
1455
+ raise ValueError("Access denied: file outside repository")
1456
+
1457
+ # Create parent directories if they don't exist
1458
+ full_file_path.parent.mkdir(parents=True, exist_ok=True)
1459
+
1460
+ # Write content to file
1461
+ with open(full_file_path, 'w', encoding=encoding) as f:
1462
+ f.write(content)
1463
+
1464
+ # Get file size
1465
+ file_size = full_file_path.stat().st_size
1466
+
1467
+ return {
1468
+ 'file_path': file_path,
1469
+ 'size': file_size,
1470
+ 'message': 'File saved successfully'
1471
+ }
1472
+
1473
+ except PermissionError as e:
1474
+ raise GitPermissionError(f"Permission denied: {e}")
1475
+ except InvalidGitRepositoryError:
1476
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1477
+
1478
+
1479
+ def stage_file(repo_path: str, file_path: str) -> Dict[str, Any]:
1480
+ """
1481
+ Stage a single file for commit.
1482
+
1483
+ Args:
1484
+ repo_path: Path to Git repository
1485
+ file_path: Path to file relative to repository root
1486
+
1487
+ Returns:
1488
+ Dictionary with success status and file details
1489
+
1490
+ Raises:
1491
+ ValueError: If file path is invalid or contains traversal patterns
1492
+ FileNotFoundError: If file doesn't exist and is not deleted
1493
+ GitPermissionError: If permission denied
1494
+ InvalidGitRepositoryError: If path is not a valid Git repository
1495
+ """
1496
+ try:
1497
+ # Validate paths
1498
+ if '..' in file_path or file_path.startswith('/..'):
1499
+ raise ValueError("Invalid file path: directory traversal detected")
1500
+
1501
+ normalized_repo_path = _normalize_path(repo_path)
1502
+ repo = Repo(str(normalized_repo_path))
1503
+
1504
+ # Clean file path
1505
+ file_path = file_path.lstrip('/')
1506
+ full_file_path = normalized_repo_path / file_path
1507
+
1508
+ # Check if file exists or is deleted
1509
+ file_exists = full_file_path.exists()
1510
+ is_deleted = False
1511
+ is_untracked = file_path in repo.untracked_files
1512
+
1513
+ # Check if file is already staged
1514
+ is_already_staged = False
1515
+ for item in repo.index.diff('HEAD'):
1516
+ if item.a_path == file_path or item.b_path == file_path:
1517
+ is_already_staged = True
1518
+ break
1519
+
1520
+ if not file_exists:
1521
+ # Check if it's a deleted file in Git
1522
+ try:
1523
+ repo.head.commit.tree / file_path
1524
+ is_deleted = True
1525
+ except (KeyError, ValueError, BadName):
1526
+ raise FileNotFoundError(f"File not found: {file_path}")
1527
+
1528
+ # Stage the file (if not already staged)
1529
+ if not is_already_staged:
1530
+ if is_deleted:
1531
+ # For deleted files, use git command directly since GitPython's add() checks file existence
1532
+ try:
1533
+ repo.git.add(file_path)
1534
+ except GitCommandError as e:
1535
+ raise ValueError(f"Failed to stage deleted file: {e}")
1536
+ else:
1537
+ # For added/modified files, use index.add
1538
+ repo.index.add([file_path])
1539
+
1540
+ # Determine new status
1541
+ if is_deleted:
1542
+ new_status = 'deleted'
1543
+ elif is_untracked:
1544
+ # File was untracked before staging
1545
+ new_status = 'added'
1546
+ else:
1547
+ # Check if file exists in HEAD (to distinguish 'added' from 'staged')
1548
+ try:
1549
+ repo.head.commit.tree / file_path
1550
+ # File exists in HEAD = modified file
1551
+ new_status = 'staged'
1552
+ except (KeyError, ValueError, BadName):
1553
+ # File doesn't exist in HEAD = newly added file
1554
+ new_status = 'added'
1555
+
1556
+ return {
1557
+ 'success': True,
1558
+ 'file_path': file_path,
1559
+ 'status': new_status,
1560
+ 'message': f'File staged: {file_path}'
1561
+ }
1562
+
1563
+ except PermissionError as e:
1564
+ raise GitPermissionError(f"Permission denied: {e}")
1565
+ except InvalidGitRepositoryError:
1566
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1567
+
1568
+
1569
+ def stage_all_files(repo_path: str) -> Dict[str, Any]:
1570
+ """
1571
+ Stage all changed files in the repository.
1572
+
1573
+ Args:
1574
+ repo_path: Path to Git repository
1575
+
1576
+ Returns:
1577
+ Dictionary with success status and count of staged files
1578
+
1579
+ Raises:
1580
+ GitPermissionError: If permission denied
1581
+ InvalidGitRepositoryError: If path is not a valid Git repository
1582
+ """
1583
+ try:
1584
+ normalized_repo_path = _normalize_path(repo_path)
1585
+ repo = Repo(str(normalized_repo_path))
1586
+
1587
+ # Get all changed and untracked files
1588
+ changed_files = []
1589
+ deleted_files = []
1590
+
1591
+ # Modified and deleted files
1592
+ for item in repo.index.diff(None):
1593
+ if item.change_type == 'D':
1594
+ deleted_files.append(item.a_path)
1595
+ else:
1596
+ changed_files.append(item.a_path)
1597
+
1598
+ # Untracked files
1599
+ changed_files.extend(repo.untracked_files)
1600
+
1601
+ if not changed_files and not deleted_files:
1602
+ return {
1603
+ 'success': True,
1604
+ 'staged_count': 0,
1605
+ 'files': [],
1606
+ 'message': 'No changes to stage'
1607
+ }
1608
+
1609
+ # Stage all non-deleted files
1610
+ if changed_files:
1611
+ repo.index.add(changed_files)
1612
+
1613
+ # Stage deleted files using git command
1614
+ if deleted_files:
1615
+ for deleted_file in deleted_files:
1616
+ try:
1617
+ repo.git.add(deleted_file)
1618
+ except GitCommandError:
1619
+ # File might already be staged, ignore
1620
+ pass
1621
+
1622
+ total_staged = len(changed_files) + len(deleted_files)
1623
+ all_files = changed_files + deleted_files
1624
+
1625
+ return {
1626
+ 'success': True,
1627
+ 'staged_count': total_staged,
1628
+ 'files': all_files,
1629
+ 'message': f'Staged {total_staged} file(s)'
1630
+ }
1631
+
1632
+ except PermissionError as e:
1633
+ raise GitPermissionError(f"Permission denied: {e}")
1634
+ except InvalidGitRepositoryError:
1635
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1636
+
1637
+
1638
+ def unstage_file(repo_path: str, file_path: str) -> Dict[str, Any]:
1639
+ """
1640
+ Remove a file from the staging area.
1641
+
1642
+ Args:
1643
+ repo_path: Path to Git repository
1644
+ file_path: Path to file relative to repository root
1645
+
1646
+ Returns:
1647
+ Dictionary with success status
1648
+
1649
+ Raises:
1650
+ ValueError: If file path is invalid or contains traversal patterns
1651
+ GitPermissionError: If permission denied
1652
+ InvalidGitRepositoryError: If path is not a valid Git repository
1653
+ """
1654
+ try:
1655
+ # Validate paths
1656
+ if '..' in file_path or file_path.startswith('/..'):
1657
+ raise ValueError("Invalid file path: directory traversal detected")
1658
+
1659
+ normalized_repo_path = _normalize_path(repo_path)
1660
+ repo = Repo(str(normalized_repo_path))
1661
+
1662
+ # Clean file path
1663
+ file_path = file_path.lstrip('/')
1664
+
1665
+ # Check if file is in index
1666
+ try:
1667
+ # Use git reset HEAD for unstaging (works for all file types including deleted)
1668
+ repo.git.reset('HEAD', file_path)
1669
+ was_staged = True
1670
+ except GitCommandError as e:
1671
+ # File was not staged or other error
1672
+ was_staged = False
1673
+
1674
+ return {
1675
+ 'success': True,
1676
+ 'file_path': file_path,
1677
+ 'was_staged': was_staged,
1678
+ 'message': f'File unstaged: {file_path}' if was_staged else 'File was not staged'
1679
+ }
1680
+
1681
+ except PermissionError as e:
1682
+ raise GitPermissionError(f"Permission denied: {e}")
1683
+ except InvalidGitRepositoryError:
1684
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1685
+
1686
+
1687
+ def create_commit(repo_path: str, message: str, author_name: Optional[str] = None,
1688
+ author_email: Optional[str] = None) -> Dict[str, Any]:
1689
+ """
1690
+ Create a Git commit with staged changes.
1691
+
1692
+ Args:
1693
+ repo_path: Path to Git repository
1694
+ message: Commit message
1695
+ author_name: Optional author name (uses Git config if not provided)
1696
+ author_email: Optional author email (uses Git config if not provided)
1697
+
1698
+ Returns:
1699
+ Dictionary with commit details
1700
+
1701
+ Raises:
1702
+ ValueError: If message is empty, too long, or no changes staged
1703
+ GitPermissionError: If permission denied or repository locked
1704
+ GitCommandError: If commit hook fails
1705
+ InvalidGitRepositoryError: If path is not a valid Git repository
1706
+ """
1707
+ try:
1708
+ # Validate commit message
1709
+ if not message or not message.strip():
1710
+ raise ValueError("Commit message cannot be empty")
1711
+
1712
+ if len(message) > 5000:
1713
+ raise ValueError("Commit message too long (max 5000 characters)")
1714
+
1715
+ # Validate email format if provided
1716
+ if author_email and '@' not in author_email:
1717
+ raise ValueError("Invalid email format")
1718
+
1719
+ normalized_repo_path = _normalize_path(repo_path)
1720
+ repo = Repo(str(normalized_repo_path))
1721
+
1722
+ # Check if there are staged changes
1723
+ try:
1724
+ staged_diff = repo.index.diff('HEAD')
1725
+ if not staged_diff:
1726
+ raise ValueError("No changes staged for commit")
1727
+ except (ValueError, BadName):
1728
+ # Empty repository - check if there are files in index
1729
+ if not repo.index.entries:
1730
+ raise ValueError("No changes staged for commit")
1731
+
1732
+ # Prepare author if provided
1733
+ actor = None
1734
+ if author_name and author_email:
1735
+ from git import Actor
1736
+ actor = Actor(author_name, author_email)
1737
+
1738
+ # Create commit
1739
+ try:
1740
+ if actor:
1741
+ commit = repo.index.commit(message, author=actor, committer=actor)
1742
+ else:
1743
+ commit = repo.index.commit(message)
1744
+ except GitCommandError as e:
1745
+ if 'hook' in str(e).lower():
1746
+ raise GitCommandError(f"Commit hook failed: {e}", 1)
1747
+ elif 'lock' in str(e).lower():
1748
+ raise GitPermissionError("Repository is locked by another operation")
1749
+ else:
1750
+ raise
1751
+
1752
+ return {
1753
+ 'success': True,
1754
+ 'commit_hash': commit.hexsha,
1755
+ 'short_hash': commit.hexsha[:7],
1756
+ 'message': commit.message.strip(),
1757
+ 'author': str(commit.author),
1758
+ 'author_email': commit.author.email,
1759
+ 'date': commit.committed_datetime.isoformat()
1760
+ }
1761
+
1762
+ except PermissionError as e:
1763
+ raise GitPermissionError(f"Permission denied: {e}")
1764
+ except InvalidGitRepositoryError:
1765
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1766
+
1767
+
1768
+
1769
+
1770
+ def list_remotes(repo_path: str) -> Dict[str, Any]:
1771
+ """List all configured remotes with their fetch/push URLs."""
1772
+ try:
1773
+ normalized_path = _normalize_path(repo_path)
1774
+ repo = Repo(str(normalized_path))
1775
+ remotes: List[Dict[str, Any]] = []
1776
+ for remote in repo.remotes:
1777
+ try:
1778
+ urls = list(remote.urls)
1779
+ except GitCommandError:
1780
+ urls = []
1781
+ config_reader = remote.config_reader
1782
+ try:
1783
+ push_url = config_reader.get('pushurl')
1784
+ except Exception:
1785
+ push_url = None
1786
+ remotes.append({
1787
+ 'name': remote.name,
1788
+ 'url': urls[0] if urls else '',
1789
+ 'fetch_url': urls[0] if urls else '',
1790
+ 'push_url': push_url or (urls[0] if urls else ''),
1791
+ })
1792
+ return {'remotes': remotes}
1793
+ except InvalidGitRepositoryError:
1794
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1795
+
1796
+
1797
+ def add_remote(repo_path: str, name: str, url: str) -> Dict[str, Any]:
1798
+ """Add a new remote by name/URL. Raises ValueError if the name already exists."""
1799
+ if not name or not name.strip():
1800
+ raise ValueError("Remote name cannot be empty")
1801
+ if not url or not url.strip():
1802
+ raise ValueError("Remote URL cannot be empty")
1803
+ normalized_path = _normalize_path(repo_path)
1804
+ try:
1805
+ repo = Repo(str(normalized_path))
1806
+ except InvalidGitRepositoryError:
1807
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1808
+ name = name.strip()
1809
+ url = url.strip()
1810
+ if name in [remote.name for remote in repo.remotes]:
1811
+ raise ValueError(f"Remote '{name}' already exists")
1812
+ try:
1813
+ remote = repo.create_remote(name, url)
1814
+ except GitCommandError as exc:
1815
+ raise GitCommandError(f"Failed to add remote: {exc}", 1)
1816
+ return {
1817
+ 'success': True,
1818
+ 'name': remote.name,
1819
+ 'url': url,
1820
+ 'message': f"Remote '{name}' added",
1821
+ }
1822
+
1823
+
1824
+ def remove_remote(repo_path: str, name: str) -> Dict[str, Any]:
1825
+ """Remove a remote by name."""
1826
+ if not name:
1827
+ raise ValueError("Remote name cannot be empty")
1828
+ normalized_path = _normalize_path(repo_path)
1829
+ try:
1830
+ repo = Repo(str(normalized_path))
1831
+ except InvalidGitRepositoryError:
1832
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1833
+ if name not in [remote.name for remote in repo.remotes]:
1834
+ raise ValueError(f"Remote '{name}' not found")
1835
+ try:
1836
+ from git.remote import Remote as _Remote
1837
+ _Remote.remove(repo, name)
1838
+ except GitCommandError as exc:
1839
+ raise GitCommandError(f"Failed to remove remote: {exc}", 1)
1840
+ return {'success': True, 'name': name, 'message': f"Remote '{name}' removed"}
1841
+
1842
+
1843
+ def _summarize_push_info(push_info_list) -> List[Dict[str, Any]]:
1844
+ summaries: List[Dict[str, Any]] = []
1845
+ for info in push_info_list or []:
1846
+ try:
1847
+ flags = int(getattr(info, 'flags', 0) or 0)
1848
+ except Exception:
1849
+ flags = 0
1850
+ summaries.append({
1851
+ 'local_ref': getattr(getattr(info, 'local_ref', None), 'name', None),
1852
+ 'remote_ref': getattr(info, 'remote_ref_string', None) or getattr(getattr(info, 'remote_ref', None), 'name', None),
1853
+ 'summary': str(getattr(info, 'summary', '') or '').strip(),
1854
+ 'flags': flags,
1855
+ 'rejected': bool(flags & 64) if flags else False,
1856
+ 'error': bool(flags & 1024) if flags else False,
1857
+ })
1858
+ return summaries
1859
+
1860
+
1861
+ def _summarize_fetch_info(fetch_info_list) -> List[Dict[str, Any]]:
1862
+ summaries: List[Dict[str, Any]] = []
1863
+ for info in fetch_info_list or []:
1864
+ try:
1865
+ flags = int(getattr(info, 'flags', 0) or 0)
1866
+ except Exception:
1867
+ flags = 0
1868
+ ref = getattr(info, 'ref', None)
1869
+ summaries.append({
1870
+ 'ref': getattr(ref, 'name', None),
1871
+ 'note': str(getattr(info, 'note', '') or '').strip(),
1872
+ 'flags': flags,
1873
+ 'commit': getattr(getattr(info, 'commit', None), 'hexsha', None),
1874
+ })
1875
+ return summaries
1876
+
1877
+
1878
+ def push_to_remote(
1879
+ repo_path: str,
1880
+ remote_name: str,
1881
+ local_branch: Optional[str] = None,
1882
+ remote_branch: Optional[str] = None,
1883
+ set_upstream: bool = False,
1884
+ force: bool = False,
1885
+ ) -> Dict[str, Any]:
1886
+ """Push ``local_branch`` to ``remote_branch`` on ``remote_name``."""
1887
+ if not remote_name:
1888
+ raise ValueError("Remote name cannot be empty")
1889
+ normalized_path = _normalize_path(repo_path)
1890
+ try:
1891
+ repo = Repo(str(normalized_path))
1892
+ except InvalidGitRepositoryError:
1893
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1894
+ if remote_name not in [remote.name for remote in repo.remotes]:
1895
+ raise ValueError(f"Remote '{remote_name}' not found")
1896
+
1897
+ if not local_branch:
1898
+ if repo.head.is_detached:
1899
+ raise ValueError("HEAD is detached; specify local_branch explicitly")
1900
+ local_branch = repo.active_branch.name
1901
+ if not remote_branch:
1902
+ remote_branch = local_branch
1903
+
1904
+ remote = repo.remote(remote_name)
1905
+ refspec = f"{local_branch}:{remote_branch}"
1906
+ kwargs = {}
1907
+ if set_upstream:
1908
+ kwargs['set_upstream'] = True
1909
+ if force:
1910
+ kwargs['force'] = True
1911
+ try:
1912
+ push_infos = remote.push(refspec=refspec, **kwargs)
1913
+ except GitCommandError as exc:
1914
+ raise GitCommandError(f"Push failed: {exc}", 1)
1915
+ summaries = _summarize_push_info(push_infos)
1916
+ rejected = [entry for entry in summaries if entry.get('rejected') or entry.get('error')]
1917
+ success = not rejected
1918
+ message = 'Push completed' if success else 'Push rejected'
1919
+ return {
1920
+ 'success': success,
1921
+ 'remote': remote_name,
1922
+ 'local_branch': local_branch,
1923
+ 'remote_branch': remote_branch,
1924
+ 'refspec': refspec,
1925
+ 'results': summaries,
1926
+ 'message': message,
1927
+ }
1928
+
1929
+
1930
+ def pull_from_remote(
1931
+ repo_path: str,
1932
+ remote_name: str,
1933
+ local_branch: Optional[str] = None,
1934
+ remote_branch: Optional[str] = None,
1935
+ rebase: bool = False,
1936
+ ) -> Dict[str, Any]:
1937
+ """Pull ``remote_branch`` from ``remote_name`` into ``local_branch``.
1938
+
1939
+ Uses a fast-forward pull by default; pass ``rebase=True`` to rebase local
1940
+ commits on top of the remote branch.
1941
+ """
1942
+ if not remote_name:
1943
+ raise ValueError("Remote name cannot be empty")
1944
+ normalized_path = _normalize_path(repo_path)
1945
+ try:
1946
+ repo = Repo(str(normalized_path))
1947
+ except InvalidGitRepositoryError:
1948
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {repo_path}")
1949
+ if remote_name not in [remote.name for remote in repo.remotes]:
1950
+ raise ValueError(f"Remote '{remote_name}' not found")
1951
+
1952
+ if not local_branch:
1953
+ if repo.head.is_detached:
1954
+ raise ValueError("HEAD is detached; specify local_branch explicitly")
1955
+ local_branch = repo.active_branch.name
1956
+ if not remote_branch:
1957
+ remote_branch = local_branch
1958
+
1959
+ current = repo.active_branch.name if not repo.head.is_detached else None
1960
+ if current != local_branch:
1961
+ raise ValueError(
1962
+ f"Cannot pull into '{local_branch}': current branch is '{current or 'detached HEAD'}'. "
1963
+ f"Switch to it first."
1964
+ )
1965
+
1966
+ remote = repo.remote(remote_name)
1967
+ refspec = f"{remote_branch}:{local_branch}" if remote_branch != local_branch else remote_branch
1968
+ kwargs = {}
1969
+ if rebase:
1970
+ kwargs['rebase'] = True
1971
+ try:
1972
+ fetch_infos = remote.pull(refspec=refspec, **kwargs)
1973
+ except GitCommandError as exc:
1974
+ raise GitCommandError(f"Pull failed: {exc}", 1)
1975
+ return {
1976
+ 'success': True,
1977
+ 'remote': remote_name,
1978
+ 'local_branch': local_branch,
1979
+ 'remote_branch': remote_branch,
1980
+ 'refspec': refspec,
1981
+ 'results': _summarize_fetch_info(fetch_infos),
1982
+ 'message': 'Pull completed',
1983
+ }
1984
+
1985
+
1986
+ def is_git_repository_path(path: str) -> bool:
1987
+ """Return True when the path is a Git repository or is inside one."""
1988
+ try:
1989
+ normalized_path = _normalize_path(path)
1990
+ candidate = normalized_path if normalized_path.is_dir() else normalized_path.parent
1991
+ repo = Repo(str(candidate), search_parent_directories=True)
1992
+ return bool(repo.working_tree_dir)
1993
+ except (InvalidGitRepositoryError, NoSuchPathError, ValueError):
1994
+ return False
1995
+
1996
+
1997
+
1998
+ def resolve_git_repository_path(path: str) -> str:
1999
+ """Resolve any path inside a repository to the repository working tree root."""
2000
+ normalized_path = _normalize_path(path)
2001
+ candidate = normalized_path if normalized_path.is_dir() else normalized_path.parent
2002
+ try:
2003
+ repo = Repo(str(candidate), search_parent_directories=True)
2004
+ except InvalidGitRepositoryError:
2005
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {path}")
2006
+ if not repo.working_tree_dir:
2007
+ raise InvalidGitRepositoryError(f"Not a valid Git repository: {path}")
2008
+ return str(Path(repo.working_tree_dir).resolve())
2009
+
2010
+
2011
+
2012
+ def browse_workspace(path: str) -> Dict[str, Any]:
2013
+ """Browse a file or directory without requiring a Git repository."""
2014
+ normalized_path = _normalize_path(path)
2015
+ target = Path(normalized_path)
2016
+ if not target.exists():
2017
+ raise FileNotFoundError(f"Path not found: {path}")
2018
+ if target.is_file():
2019
+ return {
2020
+ 'path': str(target),
2021
+ 'kind': 'file',
2022
+ 'entries': [],
2023
+ 'is_git_repo': is_git_repository_path(str(target)),
2024
+ }
2025
+
2026
+ entries = []
2027
+ for child in sorted(target.iterdir(), key=lambda item: (item.is_file(), item.name.lower())):
2028
+ item = {
2029
+ 'name': child.name,
2030
+ 'path': str(child.resolve()),
2031
+ 'type': 'file' if child.is_file() else 'directory',
2032
+ }
2033
+ if child.is_file():
2034
+ item['size'] = child.stat().st_size
2035
+ entries.append(item)
2036
+ return {
2037
+ 'path': str(target),
2038
+ 'kind': 'directory',
2039
+ 'entries': entries,
2040
+ 'is_git_repo': is_git_repository_path(str(target)),
2041
+ }
2042
+
2043
+
2044
+
2045
+ def get_workspace_tree(path: str, include_nested: bool = True) -> Dict[str, Any]:
2046
+ """Return a file listing for a file or directory without requiring a Git repository."""
2047
+ normalized_path = _normalize_path(path)
2048
+ target = Path(normalized_path)
2049
+ if not target.exists():
2050
+ raise FileNotFoundError(f"Path not found: {path}")
2051
+
2052
+ files = []
2053
+ if target.is_file():
2054
+ files.append({'path': str(target), 'type': 'file', 'size': target.stat().st_size})
2055
+ else:
2056
+ iterator = target.rglob('*') if include_nested else target.iterdir()
2057
+ for child in iterator:
2058
+ if child.is_file():
2059
+ files.append({'path': str(child.resolve()), 'type': 'file', 'size': child.stat().st_size})
2060
+ return {
2061
+ 'root': str(target),
2062
+ 'files': files,
2063
+ 'is_git_repo': is_git_repository_path(str(target)),
2064
+ }
2065
+
2066
+
2067
+
2068
+ def read_workspace_file(path: str, max_size_bytes: int = 2 * 1024 * 1024) -> Dict[str, Any]:
2069
+ """Read a text file by absolute path without requiring a Git repository."""
2070
+ normalized_path = _normalize_path(path)
2071
+ file_path = Path(normalized_path)
2072
+ if not file_path.exists():
2073
+ raise FileNotFoundError(f"File not found: {path}")
2074
+ if not file_path.is_file():
2075
+ raise ValueError("Path is not a file")
2076
+
2077
+ file_size = file_path.stat().st_size
2078
+ if file_size > max_size_bytes:
2079
+ return {
2080
+ 'path': str(file_path),
2081
+ 'size': file_size,
2082
+ 'is_binary': False,
2083
+ 'encoding': None,
2084
+ 'mime_type': 'application/octet-stream',
2085
+ 'message': f'File too large for editor (max {max_size_bytes // (1024 * 1024)}MB)',
2086
+ }
2087
+
2088
+ sample_size = min(8192, file_size)
2089
+ sample = file_path.read_bytes()[:sample_size]
2090
+ if not is_text_file(str(file_path), sample):
2091
+ return {
2092
+ 'path': str(file_path),
2093
+ 'size': file_size,
2094
+ 'is_binary': True,
2095
+ 'encoding': None,
2096
+ 'mime_type': 'application/octet-stream',
2097
+ 'message': 'Binary file cannot be displayed in editor',
2098
+ }
2099
+
2100
+ content = file_path.read_text(encoding='utf-8')
2101
+ return {
2102
+ 'path': str(file_path),
2103
+ 'content': content,
2104
+ 'encoding': 'utf-8',
2105
+ 'size': file_size,
2106
+ 'mime_type': 'text/plain',
2107
+ 'is_binary': False,
2108
+ }
2109
+
2110
+
2111
+
2112
+ def read_workspace_file_bytes(path: str, max_size_bytes: int = 25 * 1024 * 1024) -> Dict[str, Any]:
2113
+ """Read an arbitrary file as raw bytes encoded in base64.
2114
+
2115
+ Used by the frontend to preview images/videos/PDFs without needing the
2116
+ controller and the client to share a filesystem.
2117
+ """
2118
+ import base64
2119
+ import mimetypes
2120
+
2121
+ normalized_path = _normalize_path(path)
2122
+ file_path = Path(normalized_path)
2123
+ if not file_path.exists():
2124
+ raise FileNotFoundError(f"File not found: {path}")
2125
+ if not file_path.is_file():
2126
+ raise ValueError("Path is not a file")
2127
+
2128
+ file_size = file_path.stat().st_size
2129
+ if file_size > max_size_bytes:
2130
+ return {
2131
+ 'path': str(file_path),
2132
+ 'size': file_size,
2133
+ 'too_large': True,
2134
+ 'max_size': max_size_bytes,
2135
+ 'mime_type': mimetypes.guess_type(str(file_path))[0] or 'application/octet-stream',
2136
+ 'message': f'File too large for preview (max {max_size_bytes // (1024 * 1024)}MB)',
2137
+ }
2138
+
2139
+ raw = file_path.read_bytes()
2140
+ mime_type = mimetypes.guess_type(str(file_path))[0] or 'application/octet-stream'
2141
+ return {
2142
+ 'path': str(file_path),
2143
+ 'size': file_size,
2144
+ 'mime_type': mime_type,
2145
+ 'base64_content': base64.b64encode(raw).decode('ascii'),
2146
+ }
2147
+
2148
+
2149
+ def write_workspace_file(path: str, content: str, encoding: str = 'utf-8') -> Dict[str, Any]:
2150
+ """Write a text file by absolute path without requiring a Git repository."""
2151
+ normalized_path = _normalize_path(path)
2152
+ file_path = Path(normalized_path)
2153
+ file_path.parent.mkdir(parents=True, exist_ok=True)
2154
+ file_path.write_text(content, encoding=encoding)
2155
+ return {
2156
+ 'path': str(file_path),
2157
+ 'size': file_path.stat().st_size,
2158
+ 'message': 'File saved successfully',
2159
+ }
2160
+
2161
+
2162
+ def create_workspace_folder(path: str, exist_ok: bool = True) -> Dict[str, Any]:
2163
+ """Create a directory (recursively) by absolute path.
2164
+
2165
+ Raises FileExistsError when ``exist_ok`` is False and the folder already exists.
2166
+ """
2167
+ normalized_path = _normalize_path(path)
2168
+ folder = Path(normalized_path)
2169
+ if folder.exists() and not folder.is_dir():
2170
+ raise FileExistsError(f'Path exists and is not a directory: {normalized_path}')
2171
+ folder.mkdir(parents=True, exist_ok=exist_ok)
2172
+ return {
2173
+ 'path': str(folder),
2174
+ 'message': 'Folder created successfully',
2175
+ }
2176
+
2177
+
2178
+ def delete_workspace_path(path: str, recursive: bool = False) -> Dict[str, Any]:
2179
+ """Delete a file or directory by absolute path.
2180
+
2181
+ For directories, ``recursive`` must be True unless the directory is empty.
2182
+ Raises FileNotFoundError if the path does not exist.
2183
+ """
2184
+ import shutil
2185
+
2186
+ normalized_path = _normalize_path(path)
2187
+ target = Path(normalized_path)
2188
+ if not target.exists() and not target.is_symlink():
2189
+ raise FileNotFoundError(f'Path not found: {normalized_path}')
2190
+
2191
+ if target.is_symlink() or target.is_file():
2192
+ target.unlink()
2193
+ kind = 'file'
2194
+ elif target.is_dir():
2195
+ if recursive:
2196
+ shutil.rmtree(target)
2197
+ else:
2198
+ try:
2199
+ target.rmdir()
2200
+ except OSError as exc:
2201
+ raise OSError(
2202
+ f'Folder is not empty (pass recursive=true to delete): {normalized_path}'
2203
+ ) from exc
2204
+ kind = 'folder'
2205
+ else:
2206
+ raise OSError(f'Unsupported path type: {normalized_path}')
2207
+
2208
+ return {
2209
+ 'path': str(target),
2210
+ 'type': kind,
2211
+ 'message': f'{kind.capitalize()} deleted successfully',
2212
+ }
2213
+
2214
+
2215
+ def rename_workspace_path(path: str, new_path: str) -> Dict[str, Any]:
2216
+ """Rename or move a filesystem path. Both ``path`` and ``new_path`` must be absolute.
2217
+
2218
+ Raises FileNotFoundError if the source does not exist.
2219
+ Raises FileExistsError if the destination already exists.
2220
+ """
2221
+ normalized_src = _normalize_path(path)
2222
+ normalized_dst = _normalize_path(new_path)
2223
+ src = Path(normalized_src)
2224
+ dst = Path(normalized_dst)
2225
+
2226
+ if not src.exists() and not src.is_symlink():
2227
+ raise FileNotFoundError(f'Path not found: {normalized_src}')
2228
+ if dst.exists():
2229
+ raise FileExistsError(f'Destination already exists: {normalized_dst}')
2230
+
2231
+ dst.parent.mkdir(parents=True, exist_ok=True)
2232
+ src.rename(dst)
2233
+
2234
+ return {
2235
+ 'path': str(dst),
2236
+ 'previous_path': str(src),
2237
+ 'message': 'Path renamed successfully',
2238
+ }