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,667 @@
1
+ """High-level Git workflow manager for ComfyDock environments.
2
+
3
+ This module provides higher-level git workflows that combine multiple git operations
4
+ with business logic. It builds on top of the low-level git utilities in git.py.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import socket
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from ..logging.logging_config import get_logger
14
+ from ..models.environment import GitStatus
15
+
16
+ if TYPE_CHECKING:
17
+ from .pyproject_manager import PyprojectManager
18
+
19
+ from ..utils.git import (
20
+ get_uncommitted_changes,
21
+ git_checkout,
22
+ git_commit,
23
+ git_config_get,
24
+ git_config_set,
25
+ git_diff,
26
+ git_history,
27
+ git_init,
28
+ git_ls_files,
29
+ git_ls_tree,
30
+ git_show,
31
+ git_status_porcelain,
32
+ )
33
+
34
+ logger = get_logger(__name__)
35
+
36
+
37
+ class GitManager:
38
+ """Manages high-level git workflows for environment tracking."""
39
+
40
+ def __init__(self, repo_path: Path):
41
+ """Initialize GitManager for a specific repository.
42
+
43
+ Args:
44
+ repo_path: Path to the git repository (usually .cec directory)
45
+ """
46
+ self.repo_path = repo_path
47
+ self.gitignore_content = """# Staging area
48
+ staging/
49
+
50
+ # Staging metadata
51
+ metadata/
52
+
53
+ # logs
54
+ logs/
55
+
56
+ # Python cache
57
+ __pycache__/
58
+ *.pyc
59
+
60
+ # Temporary files
61
+ *.tmp
62
+ *.bak
63
+
64
+ # Runtime marker (created after successful environment initialization)
65
+ .complete
66
+ """
67
+
68
+ def ensure_git_identity(self) -> None:
69
+ """Ensure git has a user identity configured for commits.
70
+
71
+ Sets up local git config (not global) with sensible defaults.
72
+ """
73
+ # Check if identity is already configured
74
+ existing_name = git_config_get(self.repo_path, "user.name")
75
+ existing_email = git_config_get(self.repo_path, "user.email")
76
+
77
+ # If both are set, we're good
78
+ if existing_name and existing_email:
79
+ return
80
+
81
+ # Determine git identity using fallback chain
82
+ git_name = self._get_git_identity()
83
+ git_email = self._get_git_email()
84
+
85
+ # Set identity locally for this repository only
86
+ git_config_set(self.repo_path, "user.name", git_name)
87
+ git_config_set(self.repo_path, "user.email", git_email)
88
+
89
+ logger.info(f"Set local git identity: {git_name} <{git_email}>")
90
+
91
+ def _get_git_identity(self) -> str:
92
+ """Get a suitable git user name with smart fallbacks."""
93
+ # Try environment variables first
94
+ git_name = os.environ.get("GIT_AUTHOR_NAME")
95
+ if git_name:
96
+ return git_name
97
+
98
+ # Try to get system username as fallback for name
99
+ try:
100
+ import pwd
101
+ git_name = (
102
+ pwd.getpwuid(os.getuid()).pw_gecos or pwd.getpwuid(os.getuid()).pw_name
103
+ )
104
+ if git_name:
105
+ return git_name
106
+ except Exception:
107
+ pass
108
+
109
+ try:
110
+ git_name = os.getlogin()
111
+ if git_name:
112
+ return git_name
113
+ except Exception:
114
+ pass
115
+
116
+ return "ComfyDock User"
117
+
118
+ def _get_git_email(self) -> str:
119
+ """Get a suitable git email with smart fallbacks."""
120
+ # Try environment variables first
121
+ git_email = os.environ.get("GIT_AUTHOR_EMAIL")
122
+ if git_email:
123
+ return git_email
124
+
125
+ # Try to construct from username and hostname
126
+ try:
127
+ hostname = socket.gethostname()
128
+ username = os.getlogin()
129
+ return f"{username}@{hostname}"
130
+ except Exception:
131
+ pass
132
+
133
+ return "user@comfygit.local"
134
+
135
+ def initialize_environment_repo(
136
+ self, initial_message: str = "Initial environment setup"
137
+ ) -> None:
138
+ """Initialize a new environment repository with proper setup.
139
+
140
+ This combines:
141
+ - Git init
142
+ - Identity setup
143
+ - Gitignore creation
144
+ - Initial commit
145
+
146
+ Args:
147
+ initial_message: Message for the initial commit
148
+ """
149
+ # Initialize git repository
150
+ git_init(self.repo_path)
151
+
152
+ # Ensure git identity is configured
153
+ self.ensure_git_identity()
154
+
155
+ # Create standard .gitignore
156
+ self._create_gitignore()
157
+
158
+ # Initial commit (if there are files to commit)
159
+ if any(self.repo_path.iterdir()):
160
+ git_commit(self.repo_path, initial_message)
161
+ logger.info(f"Created initial commit: {initial_message}")
162
+
163
+ def commit_with_identity(self, message: str, add_all: bool = True) -> None:
164
+ """Commit changes ensuring identity is set up.
165
+
166
+ Args:
167
+ message: Commit message
168
+ add_all: Whether to stage all changes first
169
+ """
170
+ # Ensure identity before committing
171
+ self.ensure_git_identity()
172
+
173
+ # Perform the commit
174
+ git_commit(self.repo_path, message, add_all)
175
+
176
+ def _get_files_in_commit(self, commit_hash: str) -> set[str]:
177
+ """Get all tracked file paths in a specific commit.
178
+
179
+ Args:
180
+ commit_hash: Git commit hash
181
+
182
+ Returns:
183
+ Set of file paths that exist in the commit
184
+ """
185
+ result = git_ls_tree(self.repo_path, commit_hash, recursive=True)
186
+ if not result.strip():
187
+ return set()
188
+
189
+ return {line for line in result.splitlines() if line}
190
+
191
+ def _get_tracked_files(self) -> set[str]:
192
+ """Get all currently tracked file paths in working tree.
193
+
194
+ Returns:
195
+ Set of file paths currently tracked by git
196
+ """
197
+ result = git_ls_files(self.repo_path)
198
+ if not result.strip():
199
+ return set()
200
+
201
+ return {line for line in result.splitlines() if line}
202
+
203
+ def apply_commit(self, commit_ref: str, leave_unstaged: bool = True) -> None:
204
+ """Apply files from a specific commit to working directory.
205
+
206
+ Args:
207
+ commit_ref: Any valid git ref (hash, branch, tag, HEAD~N)
208
+ leave_unstaged: If True, files are left as uncommitted changes
209
+
210
+ Raises:
211
+ OSError: If git commands fail (invalid ref, etc.)
212
+ """
213
+ # Git will validate the ref - no manual resolution needed
214
+ logger.info(f"Applying files from commit {commit_ref}")
215
+
216
+ # Phase 1: Get file lists
217
+ target_files = self._get_files_in_commit(commit_ref)
218
+ current_files = self._get_tracked_files()
219
+ files_to_delete = current_files - target_files
220
+
221
+ # Phase 2: Restore files from target commit
222
+ git_checkout(self.repo_path, commit_ref, files=["."], unstage=leave_unstaged)
223
+
224
+ # Phase 3: Delete files that don't exist in target commit
225
+ if files_to_delete:
226
+ from ..utils.common import run_command
227
+
228
+ for file_path in files_to_delete:
229
+ full_path = self.repo_path / file_path
230
+ if full_path.exists():
231
+ full_path.unlink()
232
+ logger.info(f"Deleted {file_path} (not in target commit)")
233
+
234
+ # Stage only the specific deletions (not all modifications)
235
+ # git add <file> will stage the deletion when file doesn't exist
236
+ for file_path in files_to_delete:
237
+ run_command(["git", "add", file_path], cwd=self.repo_path, check=True)
238
+
239
+ # If leave_unstaged, unstage the deletions again
240
+ if leave_unstaged:
241
+ run_command(["git", "reset", "HEAD"] + list(files_to_delete),
242
+ cwd=self.repo_path, check=True)
243
+
244
+ def discard_uncommitted(self) -> None:
245
+ """Discard all uncommitted changes in the repository."""
246
+ logger.info("Discarding uncommitted changes")
247
+ git_checkout(self.repo_path, "HEAD", files=["."])
248
+
249
+ def get_version_history(self, limit: int = 10) -> list[dict]:
250
+ """Get commit history with short hashes and branch references.
251
+
252
+ Args:
253
+ limit: Maximum number of commits to return
254
+
255
+ Returns:
256
+ List of commit dicts with keys: hash, refs, message, date, date_relative
257
+ (newest first)
258
+ """
259
+ # Use %h for short hash, %D for refs (branch names without parens), %cr for relative date
260
+ result = git_history(
261
+ self.repo_path,
262
+ max_count=limit,
263
+ pretty="format:%h|%D|%s|%ai|%cr"
264
+ )
265
+
266
+ commits = []
267
+ for line in result.strip().split('\n'):
268
+ if line:
269
+ hash_short, refs, message, date, date_relative = line.split('|', 4)
270
+ commits.append({
271
+ 'hash': hash_short, # 7-char short hash
272
+ 'refs': refs.strip(), # Branch/tag refs: "HEAD -> main, origin/main" or ""
273
+ 'message': message,
274
+ 'date': date, # Absolute: 2025-11-15 14:23:45
275
+ 'date_relative': date_relative # Relative: "2 days ago"
276
+ })
277
+
278
+ # git log returns newest first by default
279
+ return commits
280
+
281
+
282
+ def get_pyproject_diff(self) -> str:
283
+ """Get the git diff specifically for pyproject.toml.
284
+
285
+ Returns:
286
+ Diff output or empty string
287
+ """
288
+ pyproject_path = Path("pyproject.toml")
289
+ return git_diff(self.repo_path, pyproject_path) or ""
290
+
291
+ def get_pyproject_from_commit(self, commit_ref: str) -> str:
292
+ """Get pyproject.toml content from a specific commit.
293
+
294
+ Args:
295
+ commit_ref: Any valid git ref (hash, branch, tag, HEAD~N)
296
+
297
+ Returns:
298
+ File content as string
299
+
300
+ Raises:
301
+ OSError: If commit or file doesn't exist
302
+ """
303
+ return git_show(self.repo_path, commit_ref, Path("pyproject.toml"))
304
+
305
+ def commit_all(self, message: str | None = None) -> None:
306
+ """Commit all changes in the repository.
307
+
308
+ Args:
309
+ message: Commit message
310
+
311
+ Raises:
312
+ OSError: If git commands fail
313
+
314
+ """
315
+ if message is None:
316
+ message = "Committing all changes"
317
+ return git_commit(self.repo_path, message, add_all=True)
318
+
319
+ def get_workflow_git_changes(self) -> dict[str, str]:
320
+ """Get git status for workflow files specifically.
321
+
322
+ Returns:
323
+ Dict mapping workflow names to their git status:
324
+ - 'modified' for modified files
325
+ - 'added' for new/untracked files
326
+ - 'deleted' for deleted files
327
+ """
328
+ status_entries = git_status_porcelain(self.repo_path)
329
+ workflow_changes = {}
330
+
331
+ for index_status, working_status, filename in status_entries:
332
+ logger.debug(f"index status: {index_status}, working status: {working_status}, filename: {filename}")
333
+
334
+ # Only process workflow files
335
+ if filename.startswith('workflows/') and filename.endswith('.json'):
336
+ # Extract workflow name from path (keep spaces as-is)
337
+ workflow_name = Path(filename).stem
338
+ logger.debug(f"Workflow name: {workflow_name}")
339
+
340
+ # Determine status (prioritize working tree status)
341
+ if working_status == 'M' or index_status == 'M':
342
+ workflow_changes[workflow_name] = 'modified'
343
+ elif working_status == 'D' or index_status == 'D':
344
+ workflow_changes[workflow_name] = 'deleted'
345
+ elif working_status == '?' or index_status == 'A':
346
+ workflow_changes[workflow_name] = 'added'
347
+
348
+ logger.debug(f"Workflow changes: {str(workflow_changes)}")
349
+ return workflow_changes
350
+
351
+ def has_uncommitted_changes(self) -> bool:
352
+ """Check if there are any uncommitted changes.
353
+
354
+ Returns:
355
+ True if there are uncommitted changes
356
+ """
357
+ return bool(get_uncommitted_changes(self.repo_path))
358
+
359
+ def _create_gitignore(self) -> None:
360
+ """Create standard .gitignore for environment tracking."""
361
+ gitignore_path = self.repo_path / ".gitignore"
362
+ gitignore_path.write_text(self.gitignore_content)
363
+
364
+
365
+ def get_status(self, pyproject_manager: PyprojectManager | None = None) -> GitStatus:
366
+ """Get complete git status with optional change parsing.
367
+
368
+ Args:
369
+ pyproject_manager: Optional PyprojectManager for parsing changes
370
+
371
+ Returns:
372
+ GitStatus with all git information encapsulated
373
+ """
374
+ # Get basic git information
375
+ workflow_changes = self.get_workflow_git_changes()
376
+ pyproject_has_changes = bool(self.get_pyproject_diff().strip())
377
+ has_changes = pyproject_has_changes or bool(workflow_changes)
378
+ current_branch = self.get_current_branch()
379
+
380
+ # Check for other uncommitted changes beyond workflows/pyproject
381
+ all_uncommitted = self.has_uncommitted_changes()
382
+ has_other_changes = all_uncommitted and not has_changes
383
+
384
+ # Create status object
385
+ status = GitStatus(
386
+ has_changes=has_changes or has_other_changes,
387
+ current_branch=current_branch,
388
+ has_other_changes=has_other_changes,
389
+ # diff=diff,
390
+ workflow_changes=workflow_changes
391
+ )
392
+
393
+ # Parse changes if we have them and a pyproject manager
394
+ if has_changes and pyproject_manager:
395
+ from ..analyzers.git_change_parser import GitChangeParser
396
+ parser = GitChangeParser(self.repo_path)
397
+ current_config = pyproject_manager.load()
398
+
399
+ # The parser updates the status object directly
400
+ parser.update_git_status(status, current_config)
401
+
402
+ return status
403
+
404
+ def create_checkpoint(self, description: str | None = None) -> str:
405
+ """Create a checkpoint of the current state.
406
+
407
+ Args:
408
+ description: Optional description for the checkpoint
409
+
410
+ Returns:
411
+ Commit hash of the checkpoint
412
+ """
413
+ # Generate automatic message if not provided
414
+ if not description:
415
+ from datetime import datetime
416
+
417
+ description = f"Checkpoint created at {datetime.now().isoformat()}"
418
+
419
+ # Commit current state
420
+ self.commit_with_identity(description)
421
+
422
+ # Get the new commit hash
423
+ history = self.get_version_history(limit=1)
424
+ if history:
425
+ return history[0]["hash"] # Newest first
426
+ return ""
427
+
428
+ def get_commit_summary(self) -> dict:
429
+ """Get a summary of the commit state.
430
+
431
+ Returns:
432
+ Dict with current_commit, has_uncommitted_changes, total_commits, latest_message
433
+ """
434
+ history = self.get_version_history(limit=100)
435
+ has_changes = self.has_uncommitted_changes()
436
+
437
+ current_commit = history[0]["hash"] if history else None # Newest first
438
+
439
+ return {
440
+ "current_commit": current_commit,
441
+ "has_uncommitted_changes": has_changes,
442
+ "total_commits": len(history),
443
+ "latest_message": history[0]["message"] if history else None,
444
+ }
445
+
446
+ # =============================================================================
447
+ # Pull/Push/Remote Operations
448
+ # =============================================================================
449
+
450
+ def pull(self, remote: str = "origin", branch: str | None = None, ff_only: bool = False) -> dict:
451
+ """Pull from remote (fetch + merge).
452
+
453
+ Args:
454
+ remote: Remote name (default: origin)
455
+ branch: Branch to pull (default: current branch)
456
+ ff_only: Only allow fast-forward merges (default: False)
457
+
458
+ Returns:
459
+ Dict with keys: 'fetch_output', 'merge_output', 'branch'
460
+
461
+ Raises:
462
+ ValueError: If no remote, detached HEAD, or merge conflicts
463
+ OSError: If fetch/merge fails
464
+ """
465
+ from ..utils.git import git_pull
466
+
467
+ logger.info(f"Pulling {remote}/{branch or 'current branch'}")
468
+
469
+ result = git_pull(self.repo_path, remote, branch, ff_only=ff_only)
470
+
471
+ return result
472
+
473
+ def push(self, remote: str = "origin", branch: str | None = None, force: bool = False) -> str:
474
+ """Push commits to remote.
475
+
476
+ Args:
477
+ remote: Remote name (default: origin)
478
+ branch: Branch to push (default: current branch)
479
+ force: Use --force-with-lease (default: False)
480
+
481
+ Returns:
482
+ Push output
483
+
484
+ Raises:
485
+ ValueError: If no remote or detached HEAD
486
+ OSError: If push fails
487
+ """
488
+ from ..utils.git import git_push, git_current_branch
489
+
490
+ # Get current branch if not specified
491
+ if not branch:
492
+ branch = git_current_branch(self.repo_path)
493
+
494
+ logger.info(f"Pushing to {remote}/{branch}" + (" (force)" if force else ""))
495
+
496
+ return git_push(self.repo_path, remote, branch, force=force)
497
+
498
+ def add_remote(self, name: str, url: str) -> None:
499
+ """Add a git remote.
500
+
501
+ Args:
502
+ name: Remote name (e.g., "origin")
503
+ url: Remote URL
504
+
505
+ Raises:
506
+ OSError: If remote already exists
507
+ """
508
+ from ..utils.git import git_remote_add
509
+
510
+ logger.info(f"Adding remote '{name}': {url}")
511
+ git_remote_add(self.repo_path, name, url)
512
+
513
+ def remove_remote(self, name: str) -> None:
514
+ """Remove a git remote.
515
+
516
+ Args:
517
+ name: Remote name (e.g., "origin")
518
+
519
+ Raises:
520
+ ValueError: If remote doesn't exist
521
+ """
522
+ from ..utils.git import git_remote_remove
523
+
524
+ logger.info(f"Removing remote '{name}'")
525
+ git_remote_remove(self.repo_path, name)
526
+
527
+ def list_remotes(self) -> list[tuple[str, str, str]]:
528
+ """List all git remotes.
529
+
530
+ Returns:
531
+ List of tuples: [(name, url, type), ...]
532
+ """
533
+ from ..utils.git import git_remote_list
534
+
535
+ return git_remote_list(self.repo_path)
536
+
537
+ def has_remote(self, name: str = "origin") -> bool:
538
+ """Check if a remote exists.
539
+
540
+ Args:
541
+ name: Remote name (default: origin)
542
+
543
+ Returns:
544
+ True if remote exists
545
+ """
546
+ from ..utils.git import git_remote_get_url
547
+
548
+ url = git_remote_get_url(self.repo_path, name)
549
+ return bool(url)
550
+
551
+ # =============================================================================
552
+ # Branch Management
553
+ # =============================================================================
554
+
555
+ def list_branches(self) -> list[tuple[str, bool]]:
556
+ """List all branches with current branch marked.
557
+
558
+ Returns:
559
+ List of (branch_name, is_current) tuples
560
+ """
561
+ from ..utils.git import git_branch_list
562
+
563
+ return git_branch_list(self.repo_path)
564
+
565
+ def create_branch(self, name: str, start_point: str = "HEAD") -> None:
566
+ """Create new branch at start_point.
567
+
568
+ Args:
569
+ name: Branch name to create
570
+ start_point: Commit/branch/tag to start from (default: HEAD)
571
+
572
+ Raises:
573
+ OSError: If branch already exists or creation fails
574
+ ValueError: If start_point doesn't exist
575
+ """
576
+ from ..utils.git import git_branch_create
577
+
578
+ logger.info(f"Creating branch '{name}' at {start_point}")
579
+ git_branch_create(self.repo_path, name, start_point)
580
+
581
+ def delete_branch(self, name: str, force: bool = False) -> None:
582
+ """Delete branch.
583
+
584
+ Args:
585
+ name: Branch name to delete
586
+ force: If True, force delete even if unmerged
587
+
588
+ Raises:
589
+ OSError: If branch doesn't exist or deletion fails
590
+ ValueError: If trying to delete current branch
591
+ """
592
+ from ..utils.git import git_branch_delete
593
+
594
+ logger.info(f"Deleting branch '{name}'" + (" (force)" if force else ""))
595
+ git_branch_delete(self.repo_path, name, force)
596
+
597
+ def switch_branch(self, branch: str, create: bool = False) -> None:
598
+ """Switch to branch (optionally creating it).
599
+
600
+ Args:
601
+ branch: Branch name to switch to
602
+ create: If True, create branch if it doesn't exist
603
+
604
+ Raises:
605
+ OSError: If branch doesn't exist (and create=False) or switch fails
606
+ """
607
+ from ..utils.git import git_switch_branch
608
+
609
+ logger.info(f"Switching to branch '{branch}'" + (" (create)" if create else ""))
610
+ git_switch_branch(self.repo_path, branch, create)
611
+
612
+ def get_current_branch(self) -> str | None:
613
+ """Get current branch name (None if detached HEAD).
614
+
615
+ Returns:
616
+ Branch name or None if in detached HEAD state
617
+ """
618
+ from ..utils.git import git_get_current_branch
619
+
620
+ return git_get_current_branch(self.repo_path)
621
+
622
+ def merge_branch(self, branch: str, message: str | None = None) -> None:
623
+ """Merge branch into current branch.
624
+
625
+ Args:
626
+ branch: Branch name to merge
627
+ message: Optional merge commit message
628
+
629
+ Raises:
630
+ OSError: If branch doesn't exist or merge fails (conflicts, etc.)
631
+ ValueError: If branch doesn't exist
632
+ """
633
+ from ..utils.git import git_merge_branch
634
+
635
+ logger.info(f"Merging branch '{branch}' into current branch")
636
+ git_merge_branch(self.repo_path, branch, message)
637
+
638
+ def reset_to(self, ref: str = "HEAD", mode: str = "hard") -> None:
639
+ """Reset current branch to ref.
640
+
641
+ Args:
642
+ ref: Commit/branch/tag to reset to (default: HEAD)
643
+ mode: Reset mode - "soft", "mixed", or "hard" (default)
644
+
645
+ Raises:
646
+ OSError: If reset fails
647
+ ValueError: If ref doesn't exist or mode is invalid
648
+ """
649
+ from ..utils.git import git_reset
650
+
651
+ logger.info(f"Resetting to {ref} (mode: {mode})")
652
+ git_reset(self.repo_path, ref, mode)
653
+
654
+ def revert_commit(self, commit: str) -> None:
655
+ """Create new commit that undoes changes from commit.
656
+
657
+ Args:
658
+ commit: Commit hash/ref to revert
659
+
660
+ Raises:
661
+ OSError: If revert fails (conflicts, etc.)
662
+ ValueError: If commit doesn't exist
663
+ """
664
+ from ..utils.git import git_revert
665
+
666
+ logger.info(f"Reverting commit {commit}")
667
+ git_revert(self.repo_path, commit)