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,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)
|