comfygit-core 0.2.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.
- comfygit_core/analyzers/custom_node_scanner.py +109 -0
- comfygit_core/analyzers/git_change_parser.py +156 -0
- comfygit_core/analyzers/model_scanner.py +318 -0
- comfygit_core/analyzers/node_classifier.py +58 -0
- comfygit_core/analyzers/node_git_analyzer.py +77 -0
- comfygit_core/analyzers/status_scanner.py +362 -0
- comfygit_core/analyzers/workflow_dependency_parser.py +143 -0
- comfygit_core/caching/__init__.py +16 -0
- comfygit_core/caching/api_cache.py +210 -0
- comfygit_core/caching/base.py +212 -0
- comfygit_core/caching/comfyui_cache.py +100 -0
- comfygit_core/caching/custom_node_cache.py +320 -0
- comfygit_core/caching/workflow_cache.py +797 -0
- comfygit_core/clients/__init__.py +4 -0
- comfygit_core/clients/civitai_client.py +412 -0
- comfygit_core/clients/github_client.py +349 -0
- comfygit_core/clients/registry_client.py +230 -0
- comfygit_core/configs/comfyui_builtin_nodes.py +1614 -0
- comfygit_core/configs/comfyui_models.py +62 -0
- comfygit_core/configs/model_config.py +151 -0
- comfygit_core/constants.py +82 -0
- comfygit_core/core/environment.py +1635 -0
- comfygit_core/core/workspace.py +898 -0
- comfygit_core/factories/environment_factory.py +419 -0
- comfygit_core/factories/uv_factory.py +61 -0
- comfygit_core/factories/workspace_factory.py +109 -0
- comfygit_core/infrastructure/sqlite_manager.py +156 -0
- comfygit_core/integrations/__init__.py +7 -0
- comfygit_core/integrations/uv_command.py +318 -0
- comfygit_core/logging/logging_config.py +15 -0
- comfygit_core/managers/environment_git_orchestrator.py +316 -0
- comfygit_core/managers/environment_model_manager.py +296 -0
- comfygit_core/managers/export_import_manager.py +116 -0
- comfygit_core/managers/git_manager.py +667 -0
- comfygit_core/managers/model_download_manager.py +252 -0
- comfygit_core/managers/model_symlink_manager.py +166 -0
- comfygit_core/managers/node_manager.py +1378 -0
- comfygit_core/managers/pyproject_manager.py +1321 -0
- comfygit_core/managers/user_content_symlink_manager.py +436 -0
- comfygit_core/managers/uv_project_manager.py +569 -0
- comfygit_core/managers/workflow_manager.py +1944 -0
- comfygit_core/models/civitai.py +432 -0
- comfygit_core/models/commit.py +18 -0
- comfygit_core/models/environment.py +293 -0
- comfygit_core/models/exceptions.py +378 -0
- comfygit_core/models/manifest.py +132 -0
- comfygit_core/models/node_mapping.py +201 -0
- comfygit_core/models/protocols.py +248 -0
- comfygit_core/models/registry.py +63 -0
- comfygit_core/models/shared.py +356 -0
- comfygit_core/models/sync.py +42 -0
- comfygit_core/models/system.py +204 -0
- comfygit_core/models/workflow.py +914 -0
- comfygit_core/models/workspace_config.py +71 -0
- comfygit_core/py.typed +0 -0
- comfygit_core/repositories/migrate_paths.py +49 -0
- comfygit_core/repositories/model_repository.py +958 -0
- comfygit_core/repositories/node_mappings_repository.py +246 -0
- comfygit_core/repositories/workflow_repository.py +57 -0
- comfygit_core/repositories/workspace_config_repository.py +121 -0
- comfygit_core/resolvers/global_node_resolver.py +459 -0
- comfygit_core/resolvers/model_resolver.py +250 -0
- comfygit_core/services/import_analyzer.py +218 -0
- comfygit_core/services/model_downloader.py +422 -0
- comfygit_core/services/node_lookup_service.py +251 -0
- comfygit_core/services/registry_data_manager.py +161 -0
- comfygit_core/strategies/__init__.py +4 -0
- comfygit_core/strategies/auto.py +72 -0
- comfygit_core/strategies/confirmation.py +69 -0
- comfygit_core/utils/comfyui_ops.py +125 -0
- comfygit_core/utils/common.py +164 -0
- comfygit_core/utils/conflict_parser.py +232 -0
- comfygit_core/utils/dependency_parser.py +231 -0
- comfygit_core/utils/download.py +216 -0
- comfygit_core/utils/environment_cleanup.py +111 -0
- comfygit_core/utils/filesystem.py +178 -0
- comfygit_core/utils/git.py +1184 -0
- comfygit_core/utils/input_signature.py +145 -0
- comfygit_core/utils/model_categories.py +52 -0
- comfygit_core/utils/pytorch.py +71 -0
- comfygit_core/utils/requirements.py +211 -0
- comfygit_core/utils/retry.py +242 -0
- comfygit_core/utils/symlink_utils.py +119 -0
- comfygit_core/utils/system_detector.py +258 -0
- comfygit_core/utils/uuid.py +28 -0
- comfygit_core/utils/uv_error_handler.py +158 -0
- comfygit_core/utils/version.py +73 -0
- comfygit_core/utils/workflow_hash.py +90 -0
- comfygit_core/validation/resolution_tester.py +297 -0
- comfygit_core-0.2.0.dist-info/METADATA +939 -0
- comfygit_core-0.2.0.dist-info/RECORD +93 -0
- comfygit_core-0.2.0.dist-info/WHEEL +4 -0
- comfygit_core-0.2.0.dist-info/licenses/LICENSE.txt +661 -0
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
"""Low-level git utilities for repository operations."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from comfygit_core.models.exceptions import CDProcessError
|
|
8
|
+
|
|
9
|
+
from ..logging.logging_config import get_logger
|
|
10
|
+
from .common import run_command
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# =============================================================================
|
|
16
|
+
# Error Handling Utilities
|
|
17
|
+
# =============================================================================
|
|
18
|
+
|
|
19
|
+
def _is_not_found_error(error: CDProcessError) -> bool:
|
|
20
|
+
"""Check if a git error indicates something doesn't exist.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
error: The CDProcessError from a git command
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True if this is a "not found" type error
|
|
27
|
+
"""
|
|
28
|
+
not_found_messages = [
|
|
29
|
+
"does not exist",
|
|
30
|
+
"invalid object",
|
|
31
|
+
"bad revision",
|
|
32
|
+
"path not in",
|
|
33
|
+
"unknown revision",
|
|
34
|
+
"not a valid object",
|
|
35
|
+
"pathspec"
|
|
36
|
+
]
|
|
37
|
+
error_text = ((error.stderr or "") + str(error)).lower()
|
|
38
|
+
return any(msg in error_text for msg in not_found_messages)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _git(cmd: list[str], repo_path: Path,
|
|
42
|
+
check: bool = True,
|
|
43
|
+
not_found_msg: str | None = None,
|
|
44
|
+
capture_output: bool = True,
|
|
45
|
+
text: bool = True) -> subprocess.CompletedProcess:
|
|
46
|
+
"""Run git command with consistent error handling.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
cmd: Git command arguments (without 'git' prefix)
|
|
50
|
+
repo_path: Path to git repository
|
|
51
|
+
check: Whether to raise exception on non-zero exit
|
|
52
|
+
not_found_msg: Custom message for "not found" errors
|
|
53
|
+
capture_output: Whether to capture stdout/stderr
|
|
54
|
+
text: Whether to return text output
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
CompletedProcess result
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: For "not found" type errors
|
|
61
|
+
OSError: For other git command failures
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
return run_command(
|
|
65
|
+
["git"] + cmd,
|
|
66
|
+
cwd=repo_path,
|
|
67
|
+
check=check,
|
|
68
|
+
capture_output=capture_output,
|
|
69
|
+
text=text
|
|
70
|
+
)
|
|
71
|
+
except CDProcessError as e:
|
|
72
|
+
if _is_not_found_error(e):
|
|
73
|
+
raise ValueError(not_found_msg or "Git object not found") from e
|
|
74
|
+
raise OSError(f"Git command failed: {e}") from e
|
|
75
|
+
|
|
76
|
+
# =============================================================================
|
|
77
|
+
# Configuration Operations
|
|
78
|
+
# =============================================================================
|
|
79
|
+
|
|
80
|
+
def git_config_get(repo_path: Path, key: str) -> str | None:
|
|
81
|
+
"""Get a git config value.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
repo_path: Path to git repository
|
|
85
|
+
key: Config key (e.g., "user.name")
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Config value or None if not set
|
|
89
|
+
"""
|
|
90
|
+
result = _git(["config", key], repo_path, check=False)
|
|
91
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
92
|
+
|
|
93
|
+
def git_config_set(repo_path: Path, key: str, value: str) -> None:
|
|
94
|
+
"""Set a git config value locally.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
repo_path: Path to git repository
|
|
98
|
+
key: Config key (e.g., "user.name")
|
|
99
|
+
value: Value to set
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
OSError: If git config command fails
|
|
103
|
+
"""
|
|
104
|
+
_git(["config", key, value], repo_path)
|
|
105
|
+
|
|
106
|
+
# =============================================================================
|
|
107
|
+
# URL Detection & Normalization
|
|
108
|
+
# =============================================================================
|
|
109
|
+
|
|
110
|
+
def is_git_url(url: str) -> bool:
|
|
111
|
+
"""Check if string is any git-style URL.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
url: String to check
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True if URL appears to be a git repository URL
|
|
118
|
+
"""
|
|
119
|
+
return url.startswith(('https://', 'http://', 'git@', 'ssh://'))
|
|
120
|
+
|
|
121
|
+
def is_github_url(url: str) -> bool:
|
|
122
|
+
"""Check if string is specifically a GitHub URL.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
url: String to check
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True if URL is a GitHub repository URL
|
|
129
|
+
"""
|
|
130
|
+
return url.startswith(('https://github.com/', 'git@github.com:', 'ssh://git@github.com/'))
|
|
131
|
+
|
|
132
|
+
def normalize_github_url(url: str) -> str:
|
|
133
|
+
"""Normalize GitHub URL to canonical https://github.com/owner/repo format.
|
|
134
|
+
|
|
135
|
+
Handles various GitHub URL formats:
|
|
136
|
+
- HTTPS: https://github.com/owner/repo.git
|
|
137
|
+
- SSH: git@github.com:owner/repo.git
|
|
138
|
+
- SSH URL: ssh://git@github.com/owner/repo.git
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
url: GitHub URL in any format
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Normalized URL in https://github.com/owner/repo format
|
|
145
|
+
"""
|
|
146
|
+
if not url:
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
# Remove .git suffix
|
|
150
|
+
url = re.sub(r"\.git$", "", url)
|
|
151
|
+
|
|
152
|
+
# Parse URL
|
|
153
|
+
from urllib.parse import urlparse
|
|
154
|
+
parsed = urlparse(url)
|
|
155
|
+
|
|
156
|
+
# Handle different GitHub URL formats
|
|
157
|
+
if parsed.hostname in ("github.com", "www.github.com"):
|
|
158
|
+
# Extract owner/repo from path
|
|
159
|
+
path_parts = parsed.path.strip("/").split("/")
|
|
160
|
+
if len(path_parts) >= 2:
|
|
161
|
+
owner, repo = path_parts[0], path_parts[1]
|
|
162
|
+
return f"https://github.com/{owner}/{repo}"
|
|
163
|
+
|
|
164
|
+
# For SSH URLs like git@github.com:owner/repo
|
|
165
|
+
if url.startswith("git@github.com:"):
|
|
166
|
+
repo_path = url.replace("git@github.com:", "")
|
|
167
|
+
repo_path = re.sub(r"\.git$", "", repo_path)
|
|
168
|
+
return f"https://github.com/{repo_path}"
|
|
169
|
+
|
|
170
|
+
# For SSH URLs like ssh://git@github.com/owner/repo
|
|
171
|
+
if url.startswith("ssh://git@github.com/"):
|
|
172
|
+
repo_path = url.replace("ssh://git@github.com/", "")
|
|
173
|
+
repo_path = re.sub(r"\.git$", "", repo_path)
|
|
174
|
+
return f"https://github.com/{repo_path}"
|
|
175
|
+
|
|
176
|
+
return url
|
|
177
|
+
|
|
178
|
+
# =============================================================================
|
|
179
|
+
# Repository Information
|
|
180
|
+
# =============================================================================
|
|
181
|
+
|
|
182
|
+
def parse_github_url(url: str) -> tuple[str, str, str | None] | None:
|
|
183
|
+
"""Parse GitHub URL to extract owner, repo name, and optional commit/ref.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
url: GitHub repository URL
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Tuple of (owner, repo, commit) or None if invalid.
|
|
190
|
+
commit will be None if no specific commit is specified.
|
|
191
|
+
"""
|
|
192
|
+
# Handle URLs with commit/tree/blob paths like:
|
|
193
|
+
# https://github.com/owner/repo/tree/commit-hash
|
|
194
|
+
# https://github.com/owner/repo/commit/commit-hash
|
|
195
|
+
github_match = re.match(
|
|
196
|
+
r"(?:https?://github\.com/|git@github\.com:)([^/]+)/([^/\.]+)(?:\.git)?(?:/(?:tree|commit|blob)/([^/]+))?",
|
|
197
|
+
url,
|
|
198
|
+
)
|
|
199
|
+
if github_match:
|
|
200
|
+
owner = github_match.group(1)
|
|
201
|
+
repo = github_match.group(2)
|
|
202
|
+
commit = github_match.group(3) # Will be None if not present
|
|
203
|
+
return (owner, repo, commit)
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def parse_git_url_with_subdir(url: str) -> tuple[str, str | None]:
|
|
207
|
+
"""Parse git URL with optional subdirectory specification.
|
|
208
|
+
|
|
209
|
+
Supports syntax: <git_url>#<subdirectory_path>
|
|
210
|
+
|
|
211
|
+
Examples:
|
|
212
|
+
"https://github.com/user/repo"
|
|
213
|
+
→ ("https://github.com/user/repo", None)
|
|
214
|
+
|
|
215
|
+
"https://github.com/user/repo#examples/example1"
|
|
216
|
+
→ ("https://github.com/user/repo", "examples/example1")
|
|
217
|
+
|
|
218
|
+
"git@github.com:user/repo.git#workflows/prod"
|
|
219
|
+
→ ("git@github.com:user/repo.git", "workflows/prod")
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
url: Git URL with optional #subdirectory suffix
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Tuple of (base_git_url, subdirectory_path or None)
|
|
226
|
+
"""
|
|
227
|
+
if '#' not in url:
|
|
228
|
+
return url, None
|
|
229
|
+
|
|
230
|
+
# Split on last # to handle edge cases
|
|
231
|
+
base_url, subdir = url.rsplit('#', 1)
|
|
232
|
+
|
|
233
|
+
# Normalize subdirectory path
|
|
234
|
+
subdir = subdir.strip('/')
|
|
235
|
+
|
|
236
|
+
if not subdir:
|
|
237
|
+
# URL ended with # but no path
|
|
238
|
+
return base_url, None
|
|
239
|
+
|
|
240
|
+
return base_url, subdir
|
|
241
|
+
|
|
242
|
+
def git_rev_parse(repo_path: Path, ref: str = "HEAD", abbrev_ref: bool = False) -> str | None:
|
|
243
|
+
"""Parse a git reference to get its value.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
repo_path: Path to git repository
|
|
247
|
+
ref: Reference to parse (default: HEAD)
|
|
248
|
+
abbrev_ref: If True, get abbreviated ref name
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Parsed reference value or None if command fails
|
|
252
|
+
"""
|
|
253
|
+
cmd = ["rev-parse"]
|
|
254
|
+
if abbrev_ref:
|
|
255
|
+
cmd.append("--abbrev-ref")
|
|
256
|
+
cmd.append(ref)
|
|
257
|
+
|
|
258
|
+
result = _git(cmd, repo_path, check=False)
|
|
259
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
260
|
+
|
|
261
|
+
def git_describe_tags(repo_path: Path, exact_match: bool = False, abbrev: int | None = None) -> str | None:
|
|
262
|
+
"""Describe HEAD using tags.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
repo_path: Path to git repository
|
|
266
|
+
exact_match: If True, only exact tag match
|
|
267
|
+
abbrev: If 0, only exact matches; if specified, abbreviate to N commits
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Tag description or None if no tags found
|
|
271
|
+
"""
|
|
272
|
+
cmd = ["describe", "--tags"]
|
|
273
|
+
if exact_match:
|
|
274
|
+
cmd.append("--exact-match")
|
|
275
|
+
if abbrev is not None:
|
|
276
|
+
cmd.append(f"--abbrev={abbrev}")
|
|
277
|
+
|
|
278
|
+
result = _git(cmd, repo_path, check=False)
|
|
279
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
280
|
+
|
|
281
|
+
def git_remote_get_url(repo_path: Path, remote: str = "origin") -> str | None:
|
|
282
|
+
"""Get URL of a git remote.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
repo_path: Path to git repository
|
|
286
|
+
remote: Remote name (default: origin)
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Remote URL or None if not found
|
|
290
|
+
"""
|
|
291
|
+
result = _git(["remote", "get-url", remote], repo_path, check=False)
|
|
292
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
293
|
+
|
|
294
|
+
# =============================================================================
|
|
295
|
+
# Basic Git Operations
|
|
296
|
+
# =============================================================================
|
|
297
|
+
|
|
298
|
+
def git_init(repo_path: Path) -> None:
|
|
299
|
+
"""Initialize a git repository.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
repo_path: Path to initialize as git repository
|
|
303
|
+
|
|
304
|
+
Raises:
|
|
305
|
+
OSError: If git initialization fails
|
|
306
|
+
"""
|
|
307
|
+
_git(["init"], repo_path)
|
|
308
|
+
|
|
309
|
+
def git_diff(repo_path: Path, file_path: Path) -> str:
|
|
310
|
+
"""Get git diff for a specific file.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
repo_path: Path to git repository
|
|
314
|
+
file_path: Path to file to diff
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Git diff output as string
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
OSError: If git diff command fails
|
|
321
|
+
"""
|
|
322
|
+
result = _git(["diff", str(file_path)], repo_path)
|
|
323
|
+
return result.stdout
|
|
324
|
+
|
|
325
|
+
def git_commit(repo_path: Path, message: str, add_all: bool = True) -> None:
|
|
326
|
+
"""Commit changes with optional staging.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
repo_path: Path to git repository
|
|
330
|
+
message: Commit message
|
|
331
|
+
add_all: Whether to stage all changes first
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
OSError: If git commands fail
|
|
335
|
+
"""
|
|
336
|
+
if add_all:
|
|
337
|
+
_git(["add", "."], repo_path)
|
|
338
|
+
|
|
339
|
+
# Check if there are any changes to commit
|
|
340
|
+
status = _git(["status", "--porcelain"], repo_path)
|
|
341
|
+
if not status.stdout.strip():
|
|
342
|
+
# No changes to commit - this is not an error
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
_git(["commit", "-m", message], repo_path)
|
|
346
|
+
|
|
347
|
+
# =============================================================================
|
|
348
|
+
# Advanced Git Operations
|
|
349
|
+
# =============================================================================
|
|
350
|
+
|
|
351
|
+
def git_show(repo_path: Path, ref: str, file_path: Path, is_text: bool = True) -> str:
|
|
352
|
+
"""Show file content from a specific git ref.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
repo_path: Path to git repository
|
|
356
|
+
ref: Git reference (commit, branch, tag)
|
|
357
|
+
file_path: Path to file to show
|
|
358
|
+
is_text: Whether to treat file as text
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
File content as string
|
|
362
|
+
|
|
363
|
+
Raises:
|
|
364
|
+
OSError: If git show command fails
|
|
365
|
+
ValueError: If ref or file doesn't exist
|
|
366
|
+
"""
|
|
367
|
+
cmd = ["show", f"{ref}:{file_path}"]
|
|
368
|
+
if is_text:
|
|
369
|
+
cmd.append("--text")
|
|
370
|
+
result = _git(cmd, repo_path, not_found_msg=f"Git ref '{ref}' or file '{file_path}' does not exist")
|
|
371
|
+
return result.stdout
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def git_history(
|
|
375
|
+
repo_path: Path,
|
|
376
|
+
file_path: Path | None = None,
|
|
377
|
+
pretty: str | None = None,
|
|
378
|
+
max_count: int | None = None,
|
|
379
|
+
follow: bool = False,
|
|
380
|
+
oneline: bool = False,
|
|
381
|
+
) -> str:
|
|
382
|
+
"""Get git history for a specific file.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
repo_path: Path to git repository
|
|
386
|
+
file_path: Path to file to get history for
|
|
387
|
+
oneline: Whether to show one-line format
|
|
388
|
+
follow: Whether to follow renames
|
|
389
|
+
max_count: Maximum number of commits to return
|
|
390
|
+
pretty: Git pretty format
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Git log output as string
|
|
394
|
+
|
|
395
|
+
Raises:
|
|
396
|
+
OSError: If git log command fails
|
|
397
|
+
"""
|
|
398
|
+
cmd = ["log"]
|
|
399
|
+
if follow:
|
|
400
|
+
cmd.append("--follow")
|
|
401
|
+
if oneline:
|
|
402
|
+
cmd.append("--oneline")
|
|
403
|
+
if max_count:
|
|
404
|
+
cmd.append(f"--max-count={max_count}")
|
|
405
|
+
if pretty:
|
|
406
|
+
cmd.append(f"--pretty={pretty}")
|
|
407
|
+
if file_path:
|
|
408
|
+
cmd.append("--")
|
|
409
|
+
cmd.append(str(file_path))
|
|
410
|
+
result = _git(cmd, repo_path)
|
|
411
|
+
return result.stdout
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def git_clone(
|
|
415
|
+
url: str,
|
|
416
|
+
target_path: Path,
|
|
417
|
+
depth: int = 1,
|
|
418
|
+
ref: str | None = None,
|
|
419
|
+
timeout: int = 30,
|
|
420
|
+
) -> None:
|
|
421
|
+
"""Clone a git repository to a target path.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
url: Git repository URL
|
|
425
|
+
target_path: Directory to clone to
|
|
426
|
+
depth: Clone depth (1 for shallow clone)
|
|
427
|
+
ref: Optional specific ref (branch/tag/commit) to checkout
|
|
428
|
+
timeout: Command timeout in seconds
|
|
429
|
+
|
|
430
|
+
Raises:
|
|
431
|
+
OSError: If git clone or checkout fails
|
|
432
|
+
ValueError: If URL is invalid or ref doesn't exist
|
|
433
|
+
"""
|
|
434
|
+
# Build clone command
|
|
435
|
+
cmd = ["clone"]
|
|
436
|
+
|
|
437
|
+
# For commit hashes, we need to clone without --depth and then checkout
|
|
438
|
+
# For branches/tags, we can use --branch with depth
|
|
439
|
+
is_commit_hash = ref and len(ref) == 40 and all(c in '0123456789abcdef' for c in ref.lower())
|
|
440
|
+
|
|
441
|
+
if depth > 0 and not is_commit_hash:
|
|
442
|
+
cmd.extend(["--depth", str(depth)])
|
|
443
|
+
|
|
444
|
+
if ref and not is_commit_hash and not ref.startswith("refs/"):
|
|
445
|
+
# If a specific branch/tag is requested, clone it directly
|
|
446
|
+
cmd.extend(["--branch", ref])
|
|
447
|
+
|
|
448
|
+
cmd.extend([url, str(target_path)])
|
|
449
|
+
|
|
450
|
+
# Execute clone
|
|
451
|
+
_git(cmd, Path.cwd(), not_found_msg=f"Git repository URL '{url}' does not exist")
|
|
452
|
+
|
|
453
|
+
# If a specific commit hash was requested, checkout to it
|
|
454
|
+
if is_commit_hash and ref:
|
|
455
|
+
_git(["checkout", ref], target_path, not_found_msg=f"Commit '{ref}' does not exist")
|
|
456
|
+
elif ref and ref.startswith("refs/"):
|
|
457
|
+
# Handle refs/ style references
|
|
458
|
+
_git(["checkout", ref], target_path, not_found_msg=f"Reference '{ref}' does not exist")
|
|
459
|
+
|
|
460
|
+
logger.info(f"Successfully cloned {url} to {target_path}")
|
|
461
|
+
|
|
462
|
+
def git_clone_subdirectory(
|
|
463
|
+
url: str,
|
|
464
|
+
target_path: Path,
|
|
465
|
+
subdir: str,
|
|
466
|
+
depth: int = 1,
|
|
467
|
+
ref: str | None = None,
|
|
468
|
+
timeout: int = 30,
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Clone a git repository and extract a specific subdirectory.
|
|
471
|
+
|
|
472
|
+
Clones the entire repository to a temporary location, validates
|
|
473
|
+
the subdirectory exists, then copies only that subdirectory to
|
|
474
|
+
the target path.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
url: Git repository URL (without #subdir)
|
|
478
|
+
target_path: Directory to extract subdirectory contents to
|
|
479
|
+
subdir: Subdirectory path within repository (e.g., "examples/example1")
|
|
480
|
+
depth: Clone depth (1 for shallow clone)
|
|
481
|
+
ref: Optional specific ref (branch/tag/commit) to checkout
|
|
482
|
+
timeout: Command timeout in seconds
|
|
483
|
+
|
|
484
|
+
Raises:
|
|
485
|
+
OSError: If git clone fails
|
|
486
|
+
ValueError: If subdirectory doesn't exist in repository
|
|
487
|
+
"""
|
|
488
|
+
import tempfile
|
|
489
|
+
import shutil
|
|
490
|
+
|
|
491
|
+
# Clone to temporary directory
|
|
492
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
493
|
+
temp_repo = Path(temp_dir) / "repo"
|
|
494
|
+
|
|
495
|
+
logger.info(f"Cloning {url} to temporary location for subdirectory extraction")
|
|
496
|
+
git_clone(url, temp_repo, depth=depth, ref=ref, timeout=timeout)
|
|
497
|
+
|
|
498
|
+
# Validate subdirectory exists
|
|
499
|
+
subdir_path = temp_repo / subdir
|
|
500
|
+
if not subdir_path.exists():
|
|
501
|
+
# List available top-level directories for helpful error message
|
|
502
|
+
available_dirs = [d.name for d in temp_repo.iterdir() if d.is_dir() and not d.name.startswith('.')]
|
|
503
|
+
raise ValueError(
|
|
504
|
+
f"Subdirectory '{subdir}' does not exist in repository. "
|
|
505
|
+
f"Available top-level directories: {', '.join(available_dirs)}"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
if not subdir_path.is_dir():
|
|
509
|
+
raise ValueError(f"Path '{subdir}' exists but is not a directory")
|
|
510
|
+
|
|
511
|
+
# Validate it's a ComfyDock environment
|
|
512
|
+
pyproject_path = subdir_path / "pyproject.toml"
|
|
513
|
+
if not pyproject_path.exists():
|
|
514
|
+
raise ValueError(
|
|
515
|
+
f"Subdirectory '{subdir}' does not contain pyproject.toml - "
|
|
516
|
+
f"not a valid ComfyDock environment"
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Copy subdirectory contents to target
|
|
520
|
+
logger.info(f"Extracting subdirectory '{subdir}' to {target_path}")
|
|
521
|
+
shutil.copytree(subdir_path, target_path, dirs_exist_ok=True)
|
|
522
|
+
|
|
523
|
+
logger.info(f"Successfully extracted {url}#{subdir} to {target_path}")
|
|
524
|
+
|
|
525
|
+
def git_checkout(repo_path: Path,
|
|
526
|
+
target: str = "HEAD",
|
|
527
|
+
files: list[str] | None = None,
|
|
528
|
+
unstage: bool = False) -> None:
|
|
529
|
+
"""Universal checkout function for commits, branches, or specific files.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
repo_path: Path to git repository
|
|
533
|
+
target: What to checkout (commit, branch, tag)
|
|
534
|
+
files: Specific files to checkout (None for all)
|
|
535
|
+
unstage: Whether to unstage files after checkout
|
|
536
|
+
|
|
537
|
+
Raises:
|
|
538
|
+
OSError: If git command fails
|
|
539
|
+
ValueError: If target doesn't exist
|
|
540
|
+
"""
|
|
541
|
+
cmd = ["checkout", target]
|
|
542
|
+
if files:
|
|
543
|
+
cmd.extend(["--"] + files)
|
|
544
|
+
|
|
545
|
+
_git(cmd, repo_path, not_found_msg=f"Git target '{target}' does not exist")
|
|
546
|
+
|
|
547
|
+
# Optionally unstage files to leave them as uncommitted changes
|
|
548
|
+
if unstage and files:
|
|
549
|
+
_git(["reset", "HEAD"] + files, repo_path)
|
|
550
|
+
elif unstage and not files:
|
|
551
|
+
_git(["reset", "HEAD", "."], repo_path)
|
|
552
|
+
|
|
553
|
+
# =============================================================================
|
|
554
|
+
# Status & Change Tracking
|
|
555
|
+
# =============================================================================
|
|
556
|
+
|
|
557
|
+
def git_status_porcelain(repo_path: Path) -> list[tuple[str, str, str]]:
|
|
558
|
+
"""Get git status in porcelain format, parsed.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
repo_path: Path to git repository
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
List of tuples: (index_status, working_status, filename)
|
|
565
|
+
Status characters follow git's convention:
|
|
566
|
+
- 'M' = modified, 'A' = added, 'D' = deleted
|
|
567
|
+
- '?' = untracked, ' ' = unmodified
|
|
568
|
+
"""
|
|
569
|
+
result = _git(["status", "--porcelain"], repo_path)
|
|
570
|
+
entries = []
|
|
571
|
+
|
|
572
|
+
if result.stdout:
|
|
573
|
+
for line in result.stdout.strip().split('\n'):
|
|
574
|
+
if line and len(line) >= 3:
|
|
575
|
+
index_status = line[0]
|
|
576
|
+
working_status = line[1]
|
|
577
|
+
filename = line[2:].lstrip()
|
|
578
|
+
|
|
579
|
+
# Handle quoted filenames (spaces/special chars)
|
|
580
|
+
if filename.startswith('"') and filename.endswith('"'):
|
|
581
|
+
filename = filename[1:-1].encode().decode('unicode_escape')
|
|
582
|
+
|
|
583
|
+
entries.append((index_status, working_status, filename))
|
|
584
|
+
|
|
585
|
+
return entries
|
|
586
|
+
|
|
587
|
+
def get_staged_changes(repo_path: Path) -> list[str]:
|
|
588
|
+
"""Get list of files that are staged (git added) but not committed.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
repo_path: Path to the git repository
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
List of file paths that are staged
|
|
595
|
+
|
|
596
|
+
Raises:
|
|
597
|
+
OSError: If git command fails
|
|
598
|
+
"""
|
|
599
|
+
result = _git(["diff", "--cached", "--name-only"], repo_path)
|
|
600
|
+
|
|
601
|
+
if result.stdout:
|
|
602
|
+
return result.stdout.strip().split('\n')
|
|
603
|
+
|
|
604
|
+
return []
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def get_uncommitted_changes(repo_path: Path) -> list[str]:
|
|
608
|
+
"""Get list of files that have uncommitted changes (staged or unstaged).
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
repo_path: Path to the git repository
|
|
612
|
+
|
|
613
|
+
Returns:
|
|
614
|
+
List of file paths with uncommitted changes
|
|
615
|
+
|
|
616
|
+
Raises:
|
|
617
|
+
OSError: If git command fails
|
|
618
|
+
"""
|
|
619
|
+
result = _git(["status", "--porcelain"], repo_path)
|
|
620
|
+
|
|
621
|
+
if result.stdout:
|
|
622
|
+
changes = []
|
|
623
|
+
for line in result.stdout.strip().split('\n'):
|
|
624
|
+
if line and len(line) >= 3:
|
|
625
|
+
# Git status --porcelain format: "XY filename"
|
|
626
|
+
# X = index status, Y = working tree status
|
|
627
|
+
# But the spacing varies based on content:
|
|
628
|
+
# "M filename" = staged (M + space + space + filename)
|
|
629
|
+
# " M filename" = unstaged (space + M + space + filename)
|
|
630
|
+
# "MM filename" = both staged and unstaged
|
|
631
|
+
|
|
632
|
+
# The first 2 characters are always status flags
|
|
633
|
+
# Everything after position 2 contains spaces + filename
|
|
634
|
+
remaining = line[2:] # Everything after status characters
|
|
635
|
+
|
|
636
|
+
# Skip any leading whitespace to get to filename
|
|
637
|
+
filename = remaining.lstrip()
|
|
638
|
+
if filename: # Make sure filename is not empty
|
|
639
|
+
changes.append(filename)
|
|
640
|
+
return changes
|
|
641
|
+
|
|
642
|
+
return []
|
|
643
|
+
|
|
644
|
+
def git_ls_tree(repo_path: Path, ref: str, recursive: bool = False) -> str:
|
|
645
|
+
"""List files in a git tree object.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
repo_path: Path to git repository
|
|
649
|
+
ref: Git reference (commit, branch, tag)
|
|
650
|
+
recursive: If True, recursively list all files
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
Output with file paths, one per line
|
|
654
|
+
|
|
655
|
+
Raises:
|
|
656
|
+
OSError: If git command fails
|
|
657
|
+
ValueError: If ref doesn't exist
|
|
658
|
+
"""
|
|
659
|
+
cmd = ["ls-tree"]
|
|
660
|
+
if recursive:
|
|
661
|
+
cmd.append("-r")
|
|
662
|
+
cmd.extend(["--name-only", ref])
|
|
663
|
+
|
|
664
|
+
result = _git(cmd, repo_path, not_found_msg=f"Git ref '{ref}' does not exist")
|
|
665
|
+
return result.stdout
|
|
666
|
+
|
|
667
|
+
def git_ls_files(repo_path: Path) -> str:
|
|
668
|
+
"""List all files tracked by git in the current working tree.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
repo_path: Path to git repository
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
Output with file paths, one per line
|
|
675
|
+
|
|
676
|
+
Raises:
|
|
677
|
+
OSError: If git command fails
|
|
678
|
+
"""
|
|
679
|
+
result = _git(["ls-files"], repo_path)
|
|
680
|
+
return result.stdout
|
|
681
|
+
|
|
682
|
+
# =============================================================================
|
|
683
|
+
# Pull/Push/Remote Operations
|
|
684
|
+
# =============================================================================
|
|
685
|
+
|
|
686
|
+
def git_fetch(
|
|
687
|
+
repo_path: Path,
|
|
688
|
+
remote: str = "origin",
|
|
689
|
+
timeout: int = 30,
|
|
690
|
+
) -> str:
|
|
691
|
+
"""Fetch from remote.
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
repo_path: Path to git repository
|
|
695
|
+
remote: Remote name (default: origin)
|
|
696
|
+
timeout: Command timeout in seconds
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
Fetch output
|
|
700
|
+
|
|
701
|
+
Raises:
|
|
702
|
+
ValueError: If remote doesn't exist
|
|
703
|
+
OSError: If fetch fails (network, auth, etc.)
|
|
704
|
+
"""
|
|
705
|
+
# Validate remote exists first
|
|
706
|
+
remote_url = git_remote_get_url(repo_path, remote)
|
|
707
|
+
if not remote_url:
|
|
708
|
+
raise ValueError(
|
|
709
|
+
f"Remote '{remote}' not configured. "
|
|
710
|
+
f"Add with: comfygit remote add {remote} <url>"
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
cmd = ["fetch", remote]
|
|
714
|
+
result = _git(cmd, repo_path)
|
|
715
|
+
return result.stdout
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def git_merge(
|
|
719
|
+
repo_path: Path,
|
|
720
|
+
ref: str,
|
|
721
|
+
ff_only: bool = False,
|
|
722
|
+
timeout: int = 30,
|
|
723
|
+
) -> str:
|
|
724
|
+
"""Merge a ref into current branch.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
repo_path: Path to git repository
|
|
728
|
+
ref: Ref to merge (e.g., "origin/main")
|
|
729
|
+
ff_only: Only allow fast-forward merges (default: False)
|
|
730
|
+
timeout: Command timeout in seconds
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
Merge output
|
|
734
|
+
|
|
735
|
+
Raises:
|
|
736
|
+
ValueError: If merge would conflict (when ff_only=True)
|
|
737
|
+
OSError: If merge fails (including merge conflicts)
|
|
738
|
+
"""
|
|
739
|
+
cmd = ["merge"]
|
|
740
|
+
if ff_only:
|
|
741
|
+
cmd.append("--ff-only")
|
|
742
|
+
cmd.append(ref)
|
|
743
|
+
|
|
744
|
+
try:
|
|
745
|
+
result = _git(cmd, repo_path)
|
|
746
|
+
return result.stdout
|
|
747
|
+
except OSError as e:
|
|
748
|
+
# _git() converts CDProcessError to OSError, but we can access the original via __cause__
|
|
749
|
+
original_error = e.__cause__ if isinstance(e.__cause__, CDProcessError) else None
|
|
750
|
+
|
|
751
|
+
# Check both error message and stderr for conflict indicators
|
|
752
|
+
error_str = str(e).lower()
|
|
753
|
+
stderr_str = ""
|
|
754
|
+
returncode = None
|
|
755
|
+
|
|
756
|
+
if original_error:
|
|
757
|
+
stderr_str = (original_error.stderr or "").lower()
|
|
758
|
+
returncode = original_error.returncode
|
|
759
|
+
|
|
760
|
+
combined_error = error_str + " " + stderr_str
|
|
761
|
+
|
|
762
|
+
if ff_only and "not possible to fast-forward" in combined_error:
|
|
763
|
+
raise ValueError(
|
|
764
|
+
f"Cannot fast-forward merge {ref}. "
|
|
765
|
+
"Remote has diverged - resolve manually."
|
|
766
|
+
) from e
|
|
767
|
+
|
|
768
|
+
# Check for merge conflict indicators (git uses "CONFLICT" in stderr)
|
|
769
|
+
# Exit code 1 from git merge typically indicates a conflict
|
|
770
|
+
if "conflict" in combined_error or returncode == 1:
|
|
771
|
+
raise OSError(
|
|
772
|
+
f"Merge conflict with {ref}. Resolve manually:\n"
|
|
773
|
+
" 1. cd <env>/.cec\n"
|
|
774
|
+
" 2. git status\n"
|
|
775
|
+
" 3. Resolve conflicts and commit"
|
|
776
|
+
) from e
|
|
777
|
+
raise
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def git_pull(
|
|
781
|
+
repo_path: Path,
|
|
782
|
+
remote: str = "origin",
|
|
783
|
+
branch: str | None = None,
|
|
784
|
+
ff_only: bool = False,
|
|
785
|
+
timeout: int = 30,
|
|
786
|
+
) -> dict:
|
|
787
|
+
"""Fetch and merge from remote (pull operation).
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
repo_path: Path to git repository
|
|
791
|
+
remote: Remote name (default: origin)
|
|
792
|
+
branch: Branch name (default: auto-detect current branch)
|
|
793
|
+
ff_only: Only allow fast-forward merges (default: False)
|
|
794
|
+
timeout: Command timeout in seconds
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
Dict with keys: 'fetch_output', 'merge_output', 'branch'
|
|
798
|
+
|
|
799
|
+
Raises:
|
|
800
|
+
ValueError: If remote doesn't exist, detached HEAD, or merge conflicts
|
|
801
|
+
OSError: If fetch/merge fails
|
|
802
|
+
"""
|
|
803
|
+
# Auto-detect current branch if not specified
|
|
804
|
+
if not branch:
|
|
805
|
+
branch = git_current_branch(repo_path)
|
|
806
|
+
|
|
807
|
+
# Fetch first
|
|
808
|
+
fetch_output = git_fetch(repo_path, remote, timeout)
|
|
809
|
+
|
|
810
|
+
# Then merge
|
|
811
|
+
merge_ref = f"{remote}/{branch}"
|
|
812
|
+
merge_output = git_merge(repo_path, merge_ref, ff_only, timeout)
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
'fetch_output': fetch_output,
|
|
816
|
+
'merge_output': merge_output,
|
|
817
|
+
'branch': branch,
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def git_push(
|
|
822
|
+
repo_path: Path,
|
|
823
|
+
remote: str = "origin",
|
|
824
|
+
branch: str | None = None,
|
|
825
|
+
force: bool = False,
|
|
826
|
+
timeout: int = 30,
|
|
827
|
+
) -> str:
|
|
828
|
+
"""Push commits to remote.
|
|
829
|
+
|
|
830
|
+
Args:
|
|
831
|
+
repo_path: Path to git repository
|
|
832
|
+
remote: Remote name (default: origin)
|
|
833
|
+
branch: Branch to push (default: current branch)
|
|
834
|
+
force: Use --force-with-lease (default: False)
|
|
835
|
+
timeout: Command timeout in seconds
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
Push output
|
|
839
|
+
|
|
840
|
+
Raises:
|
|
841
|
+
ValueError: If remote doesn't exist
|
|
842
|
+
OSError: If push fails (auth, conflicts, network)
|
|
843
|
+
"""
|
|
844
|
+
# Validate remote exists
|
|
845
|
+
remote_url = git_remote_get_url(repo_path, remote)
|
|
846
|
+
if not remote_url:
|
|
847
|
+
raise ValueError(
|
|
848
|
+
f"Remote '{remote}' not configured. "
|
|
849
|
+
f"Add with: comfygit remote add {remote} <url>"
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
# If force pushing, fetch first to update remote refs for --force-with-lease
|
|
853
|
+
if force:
|
|
854
|
+
try:
|
|
855
|
+
git_fetch(repo_path, remote, timeout)
|
|
856
|
+
except Exception:
|
|
857
|
+
# If fetch fails, continue anyway - user wants to force push
|
|
858
|
+
pass
|
|
859
|
+
|
|
860
|
+
cmd = ["push", remote]
|
|
861
|
+
|
|
862
|
+
if branch:
|
|
863
|
+
cmd.append(branch)
|
|
864
|
+
|
|
865
|
+
if force:
|
|
866
|
+
cmd.append("--force-with-lease")
|
|
867
|
+
|
|
868
|
+
try:
|
|
869
|
+
result = _git(cmd, repo_path)
|
|
870
|
+
return result.stdout
|
|
871
|
+
except CDProcessError as e:
|
|
872
|
+
error_msg = str(e).lower()
|
|
873
|
+
if "permission denied" in error_msg or "authentication" in error_msg:
|
|
874
|
+
raise OSError(
|
|
875
|
+
"Authentication failed. Check SSH key or HTTPS credentials."
|
|
876
|
+
) from e
|
|
877
|
+
elif "rejected" in error_msg:
|
|
878
|
+
raise OSError(
|
|
879
|
+
"Push rejected - remote has changes. Run: comfygit pull first"
|
|
880
|
+
) from e
|
|
881
|
+
raise OSError(f"Push failed: {e}") from e
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def git_current_branch(repo_path: Path) -> str:
|
|
885
|
+
"""Get current branch name.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
repo_path: Path to git repository
|
|
889
|
+
|
|
890
|
+
Returns:
|
|
891
|
+
Branch name (e.g., "main")
|
|
892
|
+
|
|
893
|
+
Raises:
|
|
894
|
+
ValueError: If in detached HEAD state
|
|
895
|
+
"""
|
|
896
|
+
result = _git(["rev-parse", "--abbrev-ref", "HEAD"], repo_path)
|
|
897
|
+
branch = result.stdout.strip()
|
|
898
|
+
|
|
899
|
+
if branch == "HEAD":
|
|
900
|
+
raise ValueError(
|
|
901
|
+
"Detached HEAD state - cannot pull/push. "
|
|
902
|
+
"Checkout a branch: git checkout main"
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
return branch
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def git_reset_hard(repo_path: Path, commit: str) -> None:
|
|
909
|
+
"""Reset repository to specific commit, discarding all changes.
|
|
910
|
+
|
|
911
|
+
Used for atomic rollback when pull+repair fails.
|
|
912
|
+
|
|
913
|
+
Args:
|
|
914
|
+
repo_path: Path to git repository
|
|
915
|
+
commit: Commit SHA to reset to
|
|
916
|
+
|
|
917
|
+
Raises:
|
|
918
|
+
OSError: If git reset fails
|
|
919
|
+
"""
|
|
920
|
+
_git(["reset", "--hard", commit], repo_path)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def git_remote_add(repo_path: Path, name: str, url: str) -> None:
|
|
924
|
+
"""Add a git remote.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
repo_path: Path to git repository
|
|
928
|
+
name: Remote name (e.g., "origin")
|
|
929
|
+
url: Remote URL
|
|
930
|
+
|
|
931
|
+
Raises:
|
|
932
|
+
OSError: If remote already exists or add fails
|
|
933
|
+
"""
|
|
934
|
+
# Check if remote already exists
|
|
935
|
+
existing_url = git_remote_get_url(repo_path, name)
|
|
936
|
+
if existing_url:
|
|
937
|
+
raise OSError(f"Remote '{name}' already exists: {existing_url}")
|
|
938
|
+
|
|
939
|
+
_git(["remote", "add", name, url], repo_path)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def git_remote_remove(repo_path: Path, name: str) -> None:
|
|
943
|
+
"""Remove a git remote.
|
|
944
|
+
|
|
945
|
+
Args:
|
|
946
|
+
repo_path: Path to git repository
|
|
947
|
+
name: Remote name (e.g., "origin")
|
|
948
|
+
|
|
949
|
+
Raises:
|
|
950
|
+
ValueError: If remote doesn't exist
|
|
951
|
+
OSError: If removal fails
|
|
952
|
+
"""
|
|
953
|
+
# Check if remote exists
|
|
954
|
+
existing_url = git_remote_get_url(repo_path, name)
|
|
955
|
+
if not existing_url:
|
|
956
|
+
raise ValueError(f"Remote '{name}' not found")
|
|
957
|
+
|
|
958
|
+
_git(["remote", "remove", name], repo_path)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def git_remote_list(repo_path: Path) -> list[tuple[str, str, str]]:
|
|
962
|
+
"""List all git remotes.
|
|
963
|
+
|
|
964
|
+
Args:
|
|
965
|
+
repo_path: Path to git repository
|
|
966
|
+
|
|
967
|
+
Returns:
|
|
968
|
+
List of tuples: [(name, url, type), ...]
|
|
969
|
+
Example: [("origin", "https://...", "fetch"), ("origin", "https://...", "push")]
|
|
970
|
+
"""
|
|
971
|
+
result = _git(["remote", "-v"], repo_path, check=False)
|
|
972
|
+
|
|
973
|
+
if result.returncode != 0:
|
|
974
|
+
return []
|
|
975
|
+
|
|
976
|
+
remotes = []
|
|
977
|
+
for line in result.stdout.strip().split('\n'):
|
|
978
|
+
if not line:
|
|
979
|
+
continue
|
|
980
|
+
parts = line.split()
|
|
981
|
+
if len(parts) >= 3:
|
|
982
|
+
name = parts[0]
|
|
983
|
+
url = parts[1]
|
|
984
|
+
remote_type = parts[2].strip('()')
|
|
985
|
+
remotes.append((name, url, remote_type))
|
|
986
|
+
|
|
987
|
+
return remotes
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
# =============================================================================
|
|
991
|
+
# Branch Management Operations
|
|
992
|
+
# =============================================================================
|
|
993
|
+
|
|
994
|
+
def git_branch_list(repo_path: Path) -> list[tuple[str, bool]]:
|
|
995
|
+
"""List all branches with current branch marked.
|
|
996
|
+
|
|
997
|
+
Args:
|
|
998
|
+
repo_path: Path to git repository
|
|
999
|
+
|
|
1000
|
+
Returns:
|
|
1001
|
+
List of (branch_name, is_current) tuples
|
|
1002
|
+
Example: [("main", True), ("feature", False)]
|
|
1003
|
+
|
|
1004
|
+
Raises:
|
|
1005
|
+
OSError: If git command fails
|
|
1006
|
+
"""
|
|
1007
|
+
result = _git(["branch", "--list"], repo_path)
|
|
1008
|
+
|
|
1009
|
+
branches = []
|
|
1010
|
+
for line in result.stdout.strip().split('\n'):
|
|
1011
|
+
if not line:
|
|
1012
|
+
continue
|
|
1013
|
+
|
|
1014
|
+
# Current branch starts with "* ", non-current starts with " "
|
|
1015
|
+
is_current = line.startswith('* ')
|
|
1016
|
+
# Strip leading "* " or " " and any trailing whitespace
|
|
1017
|
+
branch_name = line.lstrip('* ').strip()
|
|
1018
|
+
|
|
1019
|
+
branches.append((branch_name, is_current))
|
|
1020
|
+
|
|
1021
|
+
return branches
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def git_branch_create(repo_path: Path, name: str, start_point: str = "HEAD") -> None:
|
|
1025
|
+
"""Create new branch at start_point.
|
|
1026
|
+
|
|
1027
|
+
Args:
|
|
1028
|
+
repo_path: Path to git repository
|
|
1029
|
+
name: Branch name to create
|
|
1030
|
+
start_point: Commit/branch/tag to start from (default: HEAD)
|
|
1031
|
+
|
|
1032
|
+
Raises:
|
|
1033
|
+
OSError: If branch already exists or creation fails
|
|
1034
|
+
ValueError: If start_point doesn't exist
|
|
1035
|
+
"""
|
|
1036
|
+
_git(
|
|
1037
|
+
["branch", name, start_point],
|
|
1038
|
+
repo_path,
|
|
1039
|
+
not_found_msg=f"Git ref '{start_point}' does not exist"
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def git_branch_delete(repo_path: Path, name: str, force: bool = False) -> None:
|
|
1044
|
+
"""Delete branch.
|
|
1045
|
+
|
|
1046
|
+
Args:
|
|
1047
|
+
repo_path: Path to git repository
|
|
1048
|
+
name: Branch name to delete
|
|
1049
|
+
force: If True, force delete even if unmerged
|
|
1050
|
+
|
|
1051
|
+
Raises:
|
|
1052
|
+
OSError: If branch doesn't exist or deletion fails
|
|
1053
|
+
ValueError: If trying to delete current branch
|
|
1054
|
+
"""
|
|
1055
|
+
flag = "-D" if force else "-d"
|
|
1056
|
+
_git(
|
|
1057
|
+
["branch", flag, name],
|
|
1058
|
+
repo_path,
|
|
1059
|
+
not_found_msg=f"Branch '{name}' does not exist"
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def git_switch_branch(repo_path: Path, branch: str, create: bool = False) -> None:
|
|
1064
|
+
"""Switch to branch (optionally creating it).
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
repo_path: Path to git repository
|
|
1068
|
+
branch: Branch name to switch to
|
|
1069
|
+
create: If True, create branch if it doesn't exist
|
|
1070
|
+
|
|
1071
|
+
Raises:
|
|
1072
|
+
OSError: If branch doesn't exist (and create=False) or switch fails
|
|
1073
|
+
"""
|
|
1074
|
+
cmd = ["switch"]
|
|
1075
|
+
if create:
|
|
1076
|
+
cmd.append("-c")
|
|
1077
|
+
cmd.append(branch)
|
|
1078
|
+
|
|
1079
|
+
_git(
|
|
1080
|
+
cmd,
|
|
1081
|
+
repo_path,
|
|
1082
|
+
not_found_msg=f"Branch '{branch}' does not exist (use create=True to create it)"
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def git_get_current_branch(repo_path: Path) -> str | None:
|
|
1087
|
+
"""Get current branch name (None if detached HEAD).
|
|
1088
|
+
|
|
1089
|
+
Args:
|
|
1090
|
+
repo_path: Path to git repository
|
|
1091
|
+
|
|
1092
|
+
Returns:
|
|
1093
|
+
Branch name (e.g., "main") or None if in detached HEAD state
|
|
1094
|
+
|
|
1095
|
+
Raises:
|
|
1096
|
+
OSError: If git command fails
|
|
1097
|
+
"""
|
|
1098
|
+
result = _git(["rev-parse", "--abbrev-ref", "HEAD"], repo_path)
|
|
1099
|
+
branch = result.stdout.strip()
|
|
1100
|
+
|
|
1101
|
+
# "HEAD" means detached HEAD state
|
|
1102
|
+
if branch == "HEAD":
|
|
1103
|
+
return None
|
|
1104
|
+
|
|
1105
|
+
return branch
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def git_merge_branch(repo_path: Path, branch: str, message: str | None = None) -> None:
|
|
1109
|
+
"""Merge branch into current branch (wrapper around git_merge with message support).
|
|
1110
|
+
|
|
1111
|
+
Args:
|
|
1112
|
+
repo_path: Path to git repository
|
|
1113
|
+
branch: Branch name to merge
|
|
1114
|
+
message: Optional merge commit message
|
|
1115
|
+
|
|
1116
|
+
Raises:
|
|
1117
|
+
OSError: If branch doesn't exist or merge fails (conflicts, etc.)
|
|
1118
|
+
ValueError: If branch doesn't exist
|
|
1119
|
+
"""
|
|
1120
|
+
cmd = ["merge", branch]
|
|
1121
|
+
if message:
|
|
1122
|
+
cmd.extend(["-m", message])
|
|
1123
|
+
|
|
1124
|
+
_git(
|
|
1125
|
+
cmd,
|
|
1126
|
+
repo_path,
|
|
1127
|
+
not_found_msg=f"Branch '{branch}' does not exist"
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def git_reset(repo_path: Path, ref: str = "HEAD", mode: str = "hard") -> None:
|
|
1132
|
+
"""Reset current branch to ref.
|
|
1133
|
+
|
|
1134
|
+
Args:
|
|
1135
|
+
repo_path: Path to git repository
|
|
1136
|
+
ref: Commit/branch/tag to reset to (default: HEAD)
|
|
1137
|
+
mode: Reset mode - "soft", "mixed", or "hard" (default)
|
|
1138
|
+
|
|
1139
|
+
Raises:
|
|
1140
|
+
OSError: If reset fails
|
|
1141
|
+
ValueError: If ref doesn't exist or mode is invalid
|
|
1142
|
+
"""
|
|
1143
|
+
# Validate mode
|
|
1144
|
+
valid_modes = {"soft", "mixed", "hard"}
|
|
1145
|
+
if mode not in valid_modes:
|
|
1146
|
+
raise ValueError(f"Invalid reset mode '{mode}'. Must be one of: {valid_modes}")
|
|
1147
|
+
|
|
1148
|
+
_git(
|
|
1149
|
+
["reset", f"--{mode}", ref],
|
|
1150
|
+
repo_path,
|
|
1151
|
+
not_found_msg=f"Git ref '{ref}' does not exist"
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
# If hard reset, also clean untracked files
|
|
1155
|
+
if mode == "hard":
|
|
1156
|
+
_git(["clean", "-fd"], repo_path)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def git_revert(repo_path: Path, commit: str, no_commit: bool = False) -> None:
|
|
1160
|
+
"""Create new commit that undoes changes from commit.
|
|
1161
|
+
|
|
1162
|
+
Args:
|
|
1163
|
+
repo_path: Path to git repository
|
|
1164
|
+
commit: Commit hash/ref to revert
|
|
1165
|
+
no_commit: If True, apply changes but don't commit
|
|
1166
|
+
|
|
1167
|
+
Raises:
|
|
1168
|
+
OSError: If revert fails (conflicts, etc.)
|
|
1169
|
+
ValueError: If commit doesn't exist
|
|
1170
|
+
"""
|
|
1171
|
+
cmd = ["revert"]
|
|
1172
|
+
if no_commit:
|
|
1173
|
+
cmd.append("--no-commit")
|
|
1174
|
+
else:
|
|
1175
|
+
# Avoid opening editor for commit message
|
|
1176
|
+
cmd.append("--no-edit")
|
|
1177
|
+
|
|
1178
|
+
cmd.append(commit)
|
|
1179
|
+
|
|
1180
|
+
_git(
|
|
1181
|
+
cmd,
|
|
1182
|
+
repo_path,
|
|
1183
|
+
not_found_msg=f"Commit '{commit}' does not exist"
|
|
1184
|
+
)
|