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.
Files changed (93) hide show
  1. comfygit_core/analyzers/custom_node_scanner.py +109 -0
  2. comfygit_core/analyzers/git_change_parser.py +156 -0
  3. comfygit_core/analyzers/model_scanner.py +318 -0
  4. comfygit_core/analyzers/node_classifier.py +58 -0
  5. comfygit_core/analyzers/node_git_analyzer.py +77 -0
  6. comfygit_core/analyzers/status_scanner.py +362 -0
  7. comfygit_core/analyzers/workflow_dependency_parser.py +143 -0
  8. comfygit_core/caching/__init__.py +16 -0
  9. comfygit_core/caching/api_cache.py +210 -0
  10. comfygit_core/caching/base.py +212 -0
  11. comfygit_core/caching/comfyui_cache.py +100 -0
  12. comfygit_core/caching/custom_node_cache.py +320 -0
  13. comfygit_core/caching/workflow_cache.py +797 -0
  14. comfygit_core/clients/__init__.py +4 -0
  15. comfygit_core/clients/civitai_client.py +412 -0
  16. comfygit_core/clients/github_client.py +349 -0
  17. comfygit_core/clients/registry_client.py +230 -0
  18. comfygit_core/configs/comfyui_builtin_nodes.py +1614 -0
  19. comfygit_core/configs/comfyui_models.py +62 -0
  20. comfygit_core/configs/model_config.py +151 -0
  21. comfygit_core/constants.py +82 -0
  22. comfygit_core/core/environment.py +1635 -0
  23. comfygit_core/core/workspace.py +898 -0
  24. comfygit_core/factories/environment_factory.py +419 -0
  25. comfygit_core/factories/uv_factory.py +61 -0
  26. comfygit_core/factories/workspace_factory.py +109 -0
  27. comfygit_core/infrastructure/sqlite_manager.py +156 -0
  28. comfygit_core/integrations/__init__.py +7 -0
  29. comfygit_core/integrations/uv_command.py +318 -0
  30. comfygit_core/logging/logging_config.py +15 -0
  31. comfygit_core/managers/environment_git_orchestrator.py +316 -0
  32. comfygit_core/managers/environment_model_manager.py +296 -0
  33. comfygit_core/managers/export_import_manager.py +116 -0
  34. comfygit_core/managers/git_manager.py +667 -0
  35. comfygit_core/managers/model_download_manager.py +252 -0
  36. comfygit_core/managers/model_symlink_manager.py +166 -0
  37. comfygit_core/managers/node_manager.py +1378 -0
  38. comfygit_core/managers/pyproject_manager.py +1321 -0
  39. comfygit_core/managers/user_content_symlink_manager.py +436 -0
  40. comfygit_core/managers/uv_project_manager.py +569 -0
  41. comfygit_core/managers/workflow_manager.py +1944 -0
  42. comfygit_core/models/civitai.py +432 -0
  43. comfygit_core/models/commit.py +18 -0
  44. comfygit_core/models/environment.py +293 -0
  45. comfygit_core/models/exceptions.py +378 -0
  46. comfygit_core/models/manifest.py +132 -0
  47. comfygit_core/models/node_mapping.py +201 -0
  48. comfygit_core/models/protocols.py +248 -0
  49. comfygit_core/models/registry.py +63 -0
  50. comfygit_core/models/shared.py +356 -0
  51. comfygit_core/models/sync.py +42 -0
  52. comfygit_core/models/system.py +204 -0
  53. comfygit_core/models/workflow.py +914 -0
  54. comfygit_core/models/workspace_config.py +71 -0
  55. comfygit_core/py.typed +0 -0
  56. comfygit_core/repositories/migrate_paths.py +49 -0
  57. comfygit_core/repositories/model_repository.py +958 -0
  58. comfygit_core/repositories/node_mappings_repository.py +246 -0
  59. comfygit_core/repositories/workflow_repository.py +57 -0
  60. comfygit_core/repositories/workspace_config_repository.py +121 -0
  61. comfygit_core/resolvers/global_node_resolver.py +459 -0
  62. comfygit_core/resolvers/model_resolver.py +250 -0
  63. comfygit_core/services/import_analyzer.py +218 -0
  64. comfygit_core/services/model_downloader.py +422 -0
  65. comfygit_core/services/node_lookup_service.py +251 -0
  66. comfygit_core/services/registry_data_manager.py +161 -0
  67. comfygit_core/strategies/__init__.py +4 -0
  68. comfygit_core/strategies/auto.py +72 -0
  69. comfygit_core/strategies/confirmation.py +69 -0
  70. comfygit_core/utils/comfyui_ops.py +125 -0
  71. comfygit_core/utils/common.py +164 -0
  72. comfygit_core/utils/conflict_parser.py +232 -0
  73. comfygit_core/utils/dependency_parser.py +231 -0
  74. comfygit_core/utils/download.py +216 -0
  75. comfygit_core/utils/environment_cleanup.py +111 -0
  76. comfygit_core/utils/filesystem.py +178 -0
  77. comfygit_core/utils/git.py +1184 -0
  78. comfygit_core/utils/input_signature.py +145 -0
  79. comfygit_core/utils/model_categories.py +52 -0
  80. comfygit_core/utils/pytorch.py +71 -0
  81. comfygit_core/utils/requirements.py +211 -0
  82. comfygit_core/utils/retry.py +242 -0
  83. comfygit_core/utils/symlink_utils.py +119 -0
  84. comfygit_core/utils/system_detector.py +258 -0
  85. comfygit_core/utils/uuid.py +28 -0
  86. comfygit_core/utils/uv_error_handler.py +158 -0
  87. comfygit_core/utils/version.py +73 -0
  88. comfygit_core/utils/workflow_hash.py +90 -0
  89. comfygit_core/validation/resolution_tester.py +297 -0
  90. comfygit_core-0.2.0.dist-info/METADATA +939 -0
  91. comfygit_core-0.2.0.dist-info/RECORD +93 -0
  92. comfygit_core-0.2.0.dist-info/WHEEL +4 -0
  93. 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
+ )