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.
- agent/__init__.py +0 -0
- agent/bamboo_git_agent/__init__.py +0 -0
- agent/bamboo_git_agent/capabilities/__init__.py +0 -0
- agent/bamboo_git_agent/capabilities/repo.py +189 -0
- agent/bamboo_git_agent/config.py +76 -0
- agent/bamboo_git_agent/controller_client.py +73 -0
- agent/bamboo_git_agent/journal.py +209 -0
- agent/bamboo_git_agent/main.py +164 -0
- agent/bamboo_git_agent/protocol.py +21 -0
- agent/bamboo_git_agent/status.py +59 -0
- api/__init__.py +2 -0
- api/commit.py +82 -0
- api/repository.py +592 -0
- bamboo_coding/__init__.py +3 -0
- bamboo_coding/client/__init__.py +3 -0
- bamboo_coding/client/capabilities/__init__.py +1 -0
- bamboo_coding/client/capabilities/repo.py +279 -0
- bamboo_coding/client/config.py +179 -0
- bamboo_coding/client/controller_client.py +112 -0
- bamboo_coding/client/journal.py +1 -0
- bamboo_coding/client/main.py +425 -0
- bamboo_coding/client/status.py +59 -0
- bamboo_coding/client/terminal_runtime.py +251 -0
- bamboo_coding/server/__init__.py +3 -0
- bamboo_coding/server/core/__init__.py +1 -0
- bamboo_coding/server/core/config.py +1 -0
- bamboo_coding/server/main.py +39 -0
- bamboo_coding/shared/__init__.py +39 -0
- bamboo_coding/shared/protocol.py +1 -0
- bamboo_coding-0.1.0.dist-info/METADATA +284 -0
- bamboo_coding-0.1.0.dist-info/RECORD +62 -0
- bamboo_coding-0.1.0.dist-info/WHEEL +5 -0
- bamboo_coding-0.1.0.dist-info/entry_points.txt +3 -0
- bamboo_coding-0.1.0.dist-info/top_level.txt +6 -0
- controller/__init__.py +0 -0
- controller/app/__init__.py +0 -0
- controller/app/api/__init__.py +0 -0
- controller/app/api/agents/__init__.py +3 -0
- controller/app/api/agents/ws.py +133 -0
- controller/app/api/public/__init__.py +11 -0
- controller/app/api/public/repos.py +207 -0
- controller/app/api/public/terminals.py +64 -0
- controller/app/core/__init__.py +0 -0
- controller/app/core/config.py +20 -0
- controller/app/core/errors.py +14 -0
- controller/app/db/__init__.py +0 -0
- controller/app/db/models.py +42 -0
- controller/app/db/session.py +74 -0
- controller/app/main.py +71 -0
- controller/app/schemas/__init__.py +23 -0
- controller/app/schemas/agent_messages.py +21 -0
- controller/app/schemas/public.py +3 -0
- controller/app/services/__init__.py +0 -0
- controller/app/services/agents.py +108 -0
- controller/app/services/registrations.py +17 -0
- controller/app/services/repositories.py +85 -0
- controller/app/services/router.py +140 -0
- controller/app/services/tasks.py +104 -0
- controller/app/services/terminals.py +148 -0
- git_utils.py +2238 -0
- shared/__init__.py +39 -0
- 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
|
+
}
|