devsync 0.5.5__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.
- aiconfigkit/__init__.py +0 -0
- aiconfigkit/__main__.py +6 -0
- aiconfigkit/ai_tools/__init__.py +0 -0
- aiconfigkit/ai_tools/base.py +236 -0
- aiconfigkit/ai_tools/capability_registry.py +262 -0
- aiconfigkit/ai_tools/claude.py +91 -0
- aiconfigkit/ai_tools/claude_desktop.py +97 -0
- aiconfigkit/ai_tools/cline.py +92 -0
- aiconfigkit/ai_tools/copilot.py +92 -0
- aiconfigkit/ai_tools/cursor.py +109 -0
- aiconfigkit/ai_tools/detector.py +169 -0
- aiconfigkit/ai_tools/kiro.py +85 -0
- aiconfigkit/ai_tools/mcp_syncer.py +291 -0
- aiconfigkit/ai_tools/roo.py +110 -0
- aiconfigkit/ai_tools/translator.py +390 -0
- aiconfigkit/ai_tools/winsurf.py +102 -0
- aiconfigkit/cli/__init__.py +0 -0
- aiconfigkit/cli/delete.py +118 -0
- aiconfigkit/cli/download.py +274 -0
- aiconfigkit/cli/install.py +237 -0
- aiconfigkit/cli/install_new.py +937 -0
- aiconfigkit/cli/list.py +275 -0
- aiconfigkit/cli/main.py +454 -0
- aiconfigkit/cli/mcp_configure.py +232 -0
- aiconfigkit/cli/mcp_install.py +166 -0
- aiconfigkit/cli/mcp_sync.py +165 -0
- aiconfigkit/cli/package.py +383 -0
- aiconfigkit/cli/package_create.py +323 -0
- aiconfigkit/cli/package_install.py +472 -0
- aiconfigkit/cli/template.py +19 -0
- aiconfigkit/cli/template_backup.py +261 -0
- aiconfigkit/cli/template_init.py +499 -0
- aiconfigkit/cli/template_install.py +261 -0
- aiconfigkit/cli/template_list.py +172 -0
- aiconfigkit/cli/template_uninstall.py +146 -0
- aiconfigkit/cli/template_update.py +225 -0
- aiconfigkit/cli/template_validate.py +234 -0
- aiconfigkit/cli/tools.py +47 -0
- aiconfigkit/cli/uninstall.py +125 -0
- aiconfigkit/cli/update.py +309 -0
- aiconfigkit/core/__init__.py +0 -0
- aiconfigkit/core/checksum.py +211 -0
- aiconfigkit/core/component_detector.py +905 -0
- aiconfigkit/core/conflict_resolution.py +329 -0
- aiconfigkit/core/git_operations.py +539 -0
- aiconfigkit/core/mcp/__init__.py +1 -0
- aiconfigkit/core/mcp/credentials.py +279 -0
- aiconfigkit/core/mcp/manager.py +308 -0
- aiconfigkit/core/mcp/set_manager.py +1 -0
- aiconfigkit/core/mcp/validator.py +1 -0
- aiconfigkit/core/models.py +1661 -0
- aiconfigkit/core/package_creator.py +743 -0
- aiconfigkit/core/package_manifest.py +248 -0
- aiconfigkit/core/repository.py +298 -0
- aiconfigkit/core/secret_detector.py +438 -0
- aiconfigkit/core/template_manifest.py +283 -0
- aiconfigkit/core/version.py +201 -0
- aiconfigkit/storage/__init__.py +0 -0
- aiconfigkit/storage/library.py +429 -0
- aiconfigkit/storage/mcp_tracker.py +1 -0
- aiconfigkit/storage/package_tracker.py +234 -0
- aiconfigkit/storage/template_library.py +229 -0
- aiconfigkit/storage/template_tracker.py +296 -0
- aiconfigkit/storage/tracker.py +416 -0
- aiconfigkit/tui/__init__.py +5 -0
- aiconfigkit/tui/installer.py +511 -0
- aiconfigkit/utils/__init__.py +0 -0
- aiconfigkit/utils/atomic_write.py +90 -0
- aiconfigkit/utils/backup.py +169 -0
- aiconfigkit/utils/dotenv.py +128 -0
- aiconfigkit/utils/git_helpers.py +187 -0
- aiconfigkit/utils/logging.py +60 -0
- aiconfigkit/utils/namespace.py +134 -0
- aiconfigkit/utils/paths.py +205 -0
- aiconfigkit/utils/project.py +109 -0
- aiconfigkit/utils/streaming.py +216 -0
- aiconfigkit/utils/ui.py +194 -0
- aiconfigkit/utils/validation.py +187 -0
- devsync-0.5.5.dist-info/LICENSE +21 -0
- devsync-0.5.5.dist-info/METADATA +477 -0
- devsync-0.5.5.dist-info/RECORD +84 -0
- devsync-0.5.5.dist-info/WHEEL +5 -0
- devsync-0.5.5.dist-info/entry_points.txt +2 -0
- devsync-0.5.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""Git operations for cloning and managing instruction repositories."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from contextlib import AbstractContextManager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterator, Optional
|
|
10
|
+
|
|
11
|
+
import git
|
|
12
|
+
from git import Repo
|
|
13
|
+
from git.exc import GitCommandError
|
|
14
|
+
|
|
15
|
+
from aiconfigkit.core.models import RefType
|
|
16
|
+
from aiconfigkit.utils.validation import is_valid_git_url
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitOperationError(Exception):
|
|
20
|
+
"""Raised when a Git operation fails."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RepositoryOperationError(Exception):
|
|
26
|
+
"""Custom exception for repository operations with detailed error information."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, error_type: str, original_error: Optional[Exception] = None):
|
|
29
|
+
"""
|
|
30
|
+
Initialize repository operation error.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
message: Human-readable error message
|
|
34
|
+
error_type: Error category (e.g., 'network_error', 'invalid_reference')
|
|
35
|
+
original_error: Original exception that caused this error
|
|
36
|
+
"""
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.error_type = error_type
|
|
39
|
+
self.original_error = original_error
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class GitOperations:
|
|
43
|
+
"""Handle Git repository operations."""
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def is_local_path(repo_url: str) -> bool:
|
|
47
|
+
"""
|
|
48
|
+
Check if the repository URL is a local file path.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
repo_url: Repository URL or path
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
True if it's a local path, False if it's a remote Git URL
|
|
55
|
+
"""
|
|
56
|
+
# Remote URLs have protocols or SSH format
|
|
57
|
+
if repo_url.startswith(("https://", "http://", "git://", "ssh://")):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
# SSH format (git@host:path)
|
|
61
|
+
if "@" in repo_url and ":" in repo_url and not repo_url.startswith("/"):
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
# Everything else is treated as a local path
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def clone_repository(
|
|
69
|
+
repo_url: str, target_dir: Optional[Path] = None, branch: Optional[str] = None, depth: int = 1
|
|
70
|
+
) -> Path:
|
|
71
|
+
"""
|
|
72
|
+
Clone a Git repository or use a local directory.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
repo_url: URL of Git repository to clone or path to local directory
|
|
76
|
+
target_dir: Directory to clone into (creates temp dir if None)
|
|
77
|
+
branch: Specific branch to clone (defaults to default branch)
|
|
78
|
+
depth: Clone depth (1 for shallow clone)
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Path to repository
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
GitOperationError: If clone fails
|
|
85
|
+
ValueError: If repo_url is invalid
|
|
86
|
+
"""
|
|
87
|
+
# Validate URL
|
|
88
|
+
if not is_valid_git_url(repo_url):
|
|
89
|
+
raise ValueError(f"Invalid Git repository URL: {repo_url}")
|
|
90
|
+
|
|
91
|
+
# Handle local directories
|
|
92
|
+
if GitOperations.is_local_path(repo_url):
|
|
93
|
+
local_path = Path(repo_url).resolve()
|
|
94
|
+
if not local_path.exists():
|
|
95
|
+
raise GitOperationError(f"Local directory does not exist: {local_path}")
|
|
96
|
+
if not local_path.is_dir():
|
|
97
|
+
raise GitOperationError(f"Path is not a directory: {local_path}")
|
|
98
|
+
return local_path
|
|
99
|
+
|
|
100
|
+
# Create target directory if not provided
|
|
101
|
+
if target_dir is None:
|
|
102
|
+
target_dir = Path(tempfile.mkdtemp(prefix="instructionkit-"))
|
|
103
|
+
else:
|
|
104
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
|
|
106
|
+
# Build git clone command
|
|
107
|
+
cmd = ["git", "clone"]
|
|
108
|
+
|
|
109
|
+
# Add depth for shallow clone
|
|
110
|
+
if depth > 0:
|
|
111
|
+
cmd.extend(["--depth", str(depth)])
|
|
112
|
+
|
|
113
|
+
# Add branch if specified
|
|
114
|
+
if branch:
|
|
115
|
+
cmd.extend(["--branch", branch])
|
|
116
|
+
|
|
117
|
+
# Add URL and target directory
|
|
118
|
+
cmd.extend([repo_url, str(target_dir)])
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
# Execute git clone
|
|
122
|
+
subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=300) # 5 minute timeout
|
|
123
|
+
|
|
124
|
+
return target_dir
|
|
125
|
+
|
|
126
|
+
except subprocess.CalledProcessError as e:
|
|
127
|
+
# Clean up target directory on failure
|
|
128
|
+
if target_dir.exists():
|
|
129
|
+
shutil.rmtree(target_dir, ignore_errors=True)
|
|
130
|
+
|
|
131
|
+
error_msg = e.stderr if e.stderr else str(e)
|
|
132
|
+
raise GitOperationError(f"Failed to clone repository: {error_msg}")
|
|
133
|
+
|
|
134
|
+
except subprocess.TimeoutExpired:
|
|
135
|
+
# Clean up on timeout
|
|
136
|
+
if target_dir.exists():
|
|
137
|
+
shutil.rmtree(target_dir, ignore_errors=True)
|
|
138
|
+
|
|
139
|
+
raise GitOperationError("Repository clone timed out after 5 minutes")
|
|
140
|
+
|
|
141
|
+
except Exception as e:
|
|
142
|
+
# Clean up on any other error
|
|
143
|
+
if target_dir.exists():
|
|
144
|
+
shutil.rmtree(target_dir, ignore_errors=True)
|
|
145
|
+
|
|
146
|
+
raise GitOperationError(f"Unexpected error during clone: {str(e)}")
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def is_git_installed() -> bool:
|
|
150
|
+
"""
|
|
151
|
+
Check if Git is installed and accessible.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
True if git command is available
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
result = subprocess.run(["git", "--version"], capture_output=True, timeout=5)
|
|
158
|
+
return result.returncode == 0
|
|
159
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def get_git_version() -> Optional[str]:
|
|
164
|
+
"""
|
|
165
|
+
Get installed Git version.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Git version string or None if not installed
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
result = subprocess.run(["git", "--version"], capture_output=True, text=True, timeout=5)
|
|
172
|
+
if result.returncode == 0:
|
|
173
|
+
# Output format: "git version 2.x.x"
|
|
174
|
+
return result.stdout.strip()
|
|
175
|
+
return None
|
|
176
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def cleanup_repository(repo_path: Path, is_temp: bool = True) -> None:
|
|
181
|
+
"""
|
|
182
|
+
Clean up a cloned repository directory.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
repo_path: Path to repository to clean up
|
|
186
|
+
is_temp: Whether this is a temporary directory (safe to delete)
|
|
187
|
+
"""
|
|
188
|
+
# Only delete if it's a temporary directory
|
|
189
|
+
if is_temp and repo_path.exists() and repo_path.is_dir():
|
|
190
|
+
# Safety check: only delete if it's in temp directory
|
|
191
|
+
if "instructionkit-" in str(repo_path) or "/tmp/" in str(repo_path):
|
|
192
|
+
shutil.rmtree(repo_path, ignore_errors=True)
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def detect_ref_type(repo_url: str, ref: Optional[str]) -> tuple[Optional[str], RefType]:
|
|
196
|
+
"""
|
|
197
|
+
Detect and validate reference type for a remote repository.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
repo_url: Git repository URL
|
|
201
|
+
ref: Reference to check (None = default branch)
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Tuple of (validated_ref, ref_type)
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
RepositoryOperationError: If reference validation fails
|
|
208
|
+
"""
|
|
209
|
+
if ref is None:
|
|
210
|
+
# Use default branch
|
|
211
|
+
return (None, RefType.BRANCH)
|
|
212
|
+
|
|
213
|
+
# Check if it looks like a commit hash (40-char hex or 7+ char hex)
|
|
214
|
+
if re.match(r"^[0-9a-f]{7,40}$", ref.lower()):
|
|
215
|
+
return (ref, RefType.COMMIT)
|
|
216
|
+
|
|
217
|
+
# Check remote refs via ls-remote
|
|
218
|
+
g = git.cmd.Git()
|
|
219
|
+
try:
|
|
220
|
+
remote_refs = {}
|
|
221
|
+
for line in g.ls_remote(repo_url).split("\n"):
|
|
222
|
+
if line and "\t" in line:
|
|
223
|
+
hash_ref = line.split("\t")
|
|
224
|
+
remote_refs[hash_ref[1]] = hash_ref[0]
|
|
225
|
+
|
|
226
|
+
# Priority: tags > branches (Git's default behavior)
|
|
227
|
+
tag_ref = f"refs/tags/{ref}"
|
|
228
|
+
if tag_ref in remote_refs:
|
|
229
|
+
return (ref, RefType.TAG)
|
|
230
|
+
|
|
231
|
+
branch_ref = f"refs/heads/{ref}"
|
|
232
|
+
if branch_ref in remote_refs:
|
|
233
|
+
return (ref, RefType.BRANCH)
|
|
234
|
+
|
|
235
|
+
raise RepositoryOperationError(f"Reference '{ref}' not found in repository", error_type="invalid_reference")
|
|
236
|
+
|
|
237
|
+
except GitCommandError as e:
|
|
238
|
+
raise RepositoryOperationError(
|
|
239
|
+
f"Failed to access repository: {e}", error_type="network_error", original_error=e
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def validate_remote_ref(repo_url: str, ref: str, ref_type: RefType) -> bool:
|
|
244
|
+
"""
|
|
245
|
+
Validate that a specific reference exists on remote repository.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
repo_url: Git repository URL
|
|
249
|
+
ref: Reference name
|
|
250
|
+
ref_type: Expected reference type ('tag' or 'branch')
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
True if reference exists, False otherwise
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
RepositoryOperationError: If validation fails due to network/access issues
|
|
257
|
+
"""
|
|
258
|
+
g = git.cmd.Git()
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
if ref_type == RefType.BRANCH:
|
|
262
|
+
# Check for branch (heads)
|
|
263
|
+
g.ls_remote("--exit-code", "--heads", repo_url, ref)
|
|
264
|
+
elif ref_type == RefType.TAG:
|
|
265
|
+
# Check for tag
|
|
266
|
+
g.ls_remote("--exit-code", "--tags", repo_url, ref)
|
|
267
|
+
elif ref_type == RefType.COMMIT:
|
|
268
|
+
# Commits can't be validated via ls-remote
|
|
269
|
+
# Will be validated during fetch/checkout
|
|
270
|
+
return True
|
|
271
|
+
return True
|
|
272
|
+
except GitCommandError as e:
|
|
273
|
+
# exit-code 2 means reference not found
|
|
274
|
+
if e.status == 2:
|
|
275
|
+
return False
|
|
276
|
+
# Other errors are network/access issues
|
|
277
|
+
raise RepositoryOperationError(
|
|
278
|
+
f"Failed to validate reference: {e}", error_type="network_error", original_error=e
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def clone_at_ref(
|
|
283
|
+
repo_url: str, destination: Path, ref: Optional[str] = None, ref_type: Optional[RefType] = None, depth: int = 1
|
|
284
|
+
) -> Repo:
|
|
285
|
+
"""
|
|
286
|
+
Clone repository at a specific reference.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
repo_url: Git repository URL
|
|
290
|
+
destination: Local directory to clone into
|
|
291
|
+
ref: Reference to clone (tag, branch, or commit hash)
|
|
292
|
+
ref_type: Type of reference (if known)
|
|
293
|
+
depth: Clone depth (1 for shallow, 0 for full)
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
GitPython Repo object
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
RepositoryOperationError: If clone fails
|
|
300
|
+
"""
|
|
301
|
+
# Create destination directory
|
|
302
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
|
|
304
|
+
# Handle default branch (no ref specified)
|
|
305
|
+
if ref is None:
|
|
306
|
+
try:
|
|
307
|
+
return Repo.clone_from(url=repo_url, to_path=str(destination), depth=depth if depth > 0 else None)
|
|
308
|
+
except GitCommandError as e:
|
|
309
|
+
raise RepositoryOperationError(
|
|
310
|
+
f"Failed to clone repository: {e.stderr}", error_type="git_command_error", original_error=e
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Handle tags and branches (can use 'branch' parameter)
|
|
314
|
+
if ref_type in (RefType.TAG, RefType.BRANCH, None):
|
|
315
|
+
try:
|
|
316
|
+
return Repo.clone_from(
|
|
317
|
+
url=repo_url,
|
|
318
|
+
to_path=str(destination),
|
|
319
|
+
branch=ref,
|
|
320
|
+
single_branch=True,
|
|
321
|
+
depth=depth if depth > 0 else None,
|
|
322
|
+
)
|
|
323
|
+
except GitCommandError as e:
|
|
324
|
+
# If branch parameter fails and we haven't confirmed it's a commit, raise error
|
|
325
|
+
if ref_type != RefType.COMMIT:
|
|
326
|
+
stderr = e.stderr.lower() if e.stderr else ""
|
|
327
|
+
if "remote branch" in stderr and "not found" in stderr:
|
|
328
|
+
raise RepositoryOperationError(
|
|
329
|
+
f"Reference '{ref}' not found in repository",
|
|
330
|
+
error_type="invalid_reference",
|
|
331
|
+
original_error=e,
|
|
332
|
+
)
|
|
333
|
+
raise RepositoryOperationError(
|
|
334
|
+
f"Failed to clone at ref '{ref}': {e.stderr}",
|
|
335
|
+
error_type="git_command_error",
|
|
336
|
+
original_error=e,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Handle commits (requires two-step process)
|
|
340
|
+
if ref_type == RefType.COMMIT:
|
|
341
|
+
try:
|
|
342
|
+
# Step 1: Shallow clone default branch
|
|
343
|
+
repo = Repo.clone_from(url=repo_url, to_path=str(destination), depth=depth if depth > 0 else None)
|
|
344
|
+
|
|
345
|
+
# Step 2: Fetch and checkout specific commit
|
|
346
|
+
try:
|
|
347
|
+
# Try shallow fetch first (requires server support)
|
|
348
|
+
repo.git.fetch("origin", ref, depth=1)
|
|
349
|
+
repo.git.checkout(ref)
|
|
350
|
+
except GitCommandError:
|
|
351
|
+
# Fall back to full fetch if shallow fails
|
|
352
|
+
repo.git.fetch("origin", ref)
|
|
353
|
+
repo.git.checkout(ref)
|
|
354
|
+
|
|
355
|
+
return repo
|
|
356
|
+
|
|
357
|
+
except GitCommandError as e:
|
|
358
|
+
raise RepositoryOperationError(
|
|
359
|
+
f"Failed to clone at commit '{ref}': {e.stderr}",
|
|
360
|
+
error_type="git_command_error",
|
|
361
|
+
original_error=e,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
raise RepositoryOperationError(f"Unable to clone at ref '{ref}'", error_type="unknown_error")
|
|
365
|
+
|
|
366
|
+
@staticmethod
|
|
367
|
+
def get_repo_info(repo: Repo) -> dict[str, Any]:
|
|
368
|
+
"""
|
|
369
|
+
Extract useful repository information.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
repo: GitPython Repo object
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Dictionary with repository metadata
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
current_branch = repo.active_branch.name if not repo.head.is_detached else None
|
|
379
|
+
except Exception:
|
|
380
|
+
current_branch = None
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
"url": repo.remotes.origin.url if repo.remotes else None,
|
|
384
|
+
"current_branch": current_branch,
|
|
385
|
+
"current_commit": repo.head.commit.hexsha,
|
|
386
|
+
"is_dirty": repo.is_dirty(),
|
|
387
|
+
"is_shallow": repo.git.rev_parse("--is-shallow-repository") == "true",
|
|
388
|
+
"tags": [tag.name for tag in repo.tags],
|
|
389
|
+
"branches": [branch.name for branch in repo.heads],
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def check_for_updates(repo: Repo, branch: str = "main") -> bool:
|
|
394
|
+
"""
|
|
395
|
+
Check if remote branch has new commits without pulling.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
repo: GitPython Repo object
|
|
399
|
+
branch: Branch name to check
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
True if remote has updates, False otherwise
|
|
403
|
+
|
|
404
|
+
Raises:
|
|
405
|
+
RepositoryOperationError: If check fails
|
|
406
|
+
"""
|
|
407
|
+
try:
|
|
408
|
+
# Get current local commit
|
|
409
|
+
local_commit = repo.head.commit.hexsha
|
|
410
|
+
|
|
411
|
+
# Fetch remote refs (doesn't modify working tree)
|
|
412
|
+
origin = repo.remotes.origin
|
|
413
|
+
origin.fetch()
|
|
414
|
+
|
|
415
|
+
# Get remote commit
|
|
416
|
+
remote_commit = origin.refs[branch].commit.hexsha
|
|
417
|
+
|
|
418
|
+
# Compare
|
|
419
|
+
return local_commit != remote_commit
|
|
420
|
+
except Exception as e:
|
|
421
|
+
raise RepositoryOperationError(
|
|
422
|
+
f"Failed to check for updates: {e}", error_type="network_error", original_error=e
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def pull_repository_updates(repo: Repo, branch: str = "main") -> dict[str, Any]:
|
|
427
|
+
"""
|
|
428
|
+
Pull updates with conflict detection.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
repo: GitPython Repo object
|
|
432
|
+
branch: Branch to pull
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Dictionary with pull result details
|
|
436
|
+
|
|
437
|
+
Raises:
|
|
438
|
+
RepositoryOperationError: If pull fails
|
|
439
|
+
"""
|
|
440
|
+
try:
|
|
441
|
+
origin = repo.remotes.origin
|
|
442
|
+
|
|
443
|
+
# Check for local modifications
|
|
444
|
+
if repo.is_dirty():
|
|
445
|
+
return {
|
|
446
|
+
"success": False,
|
|
447
|
+
"error": "local_modifications",
|
|
448
|
+
"message": "Working directory has uncommitted changes",
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
# Pull
|
|
452
|
+
pull_info = origin.pull(branch)
|
|
453
|
+
|
|
454
|
+
# Check if any files were updated
|
|
455
|
+
updated_files = []
|
|
456
|
+
for info in pull_info:
|
|
457
|
+
if info.flags & info.HEAD_UPTODATE:
|
|
458
|
+
# Already up to date
|
|
459
|
+
continue
|
|
460
|
+
updated_files.append(str(info.ref))
|
|
461
|
+
|
|
462
|
+
return {"success": True, "updated": len(updated_files) > 0, "files": updated_files}
|
|
463
|
+
|
|
464
|
+
except GitCommandError as e:
|
|
465
|
+
# Pull failed - likely due to conflicts
|
|
466
|
+
stderr = e.stderr if e.stderr else ""
|
|
467
|
+
if "CONFLICT" in stderr:
|
|
468
|
+
return {"success": False, "error": "conflict", "message": str(e.stderr)}
|
|
469
|
+
else:
|
|
470
|
+
return {"success": False, "error": "unknown", "message": str(e)}
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def update_if_mutable(repo_path: Path, ref: str, ref_type: RefType) -> bool:
|
|
474
|
+
"""
|
|
475
|
+
Update repository only if reference is mutable (branch).
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
repo_path: Path to repository
|
|
479
|
+
ref: Reference name
|
|
480
|
+
ref_type: Type of reference
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
True if updated, False if skipped (immutable ref)
|
|
484
|
+
|
|
485
|
+
Raises:
|
|
486
|
+
RepositoryOperationError: If update fails
|
|
487
|
+
"""
|
|
488
|
+
# Only update branches (mutable refs)
|
|
489
|
+
if ref_type != RefType.BRANCH:
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
repo = Repo(repo_path)
|
|
494
|
+
|
|
495
|
+
# Check if updates available
|
|
496
|
+
if not GitOperations.check_for_updates(repo, ref):
|
|
497
|
+
return False
|
|
498
|
+
|
|
499
|
+
# Pull updates
|
|
500
|
+
result = GitOperations.pull_repository_updates(repo, ref)
|
|
501
|
+
|
|
502
|
+
return bool(result.get("success", False) and result.get("updated", False))
|
|
503
|
+
except Exception as e:
|
|
504
|
+
raise RepositoryOperationError(
|
|
505
|
+
f"Failed to update repository: {e}", error_type="git_error", original_error=e
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def with_temporary_clone(repo_url: str, branch: Optional[str] = None) -> AbstractContextManager[Path]:
|
|
510
|
+
"""
|
|
511
|
+
Context manager for temporary repository clones.
|
|
512
|
+
|
|
513
|
+
Usage:
|
|
514
|
+
with with_temporary_clone(repo_url) as repo_path:
|
|
515
|
+
# Use repo_path
|
|
516
|
+
pass
|
|
517
|
+
# Repository is automatically cleaned up
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
repo_url: URL of repository to clone
|
|
521
|
+
branch: Optional branch to clone
|
|
522
|
+
|
|
523
|
+
Yields:
|
|
524
|
+
Path to cloned repository
|
|
525
|
+
"""
|
|
526
|
+
from contextlib import contextmanager
|
|
527
|
+
|
|
528
|
+
@contextmanager
|
|
529
|
+
def _clone_context() -> Iterator[Path]:
|
|
530
|
+
repo_path = None
|
|
531
|
+
try:
|
|
532
|
+
git_ops = GitOperations()
|
|
533
|
+
repo_path = git_ops.clone_repository(repo_url, branch=branch)
|
|
534
|
+
yield repo_path
|
|
535
|
+
finally:
|
|
536
|
+
if repo_path and repo_path.exists():
|
|
537
|
+
GitOperations.cleanup_repository(repo_path)
|
|
538
|
+
|
|
539
|
+
return _clone_context()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP server configuration management."""
|