griptape-nodes 0.64.10__py3-none-any.whl → 0.65.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.
- griptape_nodes/app/app.py +25 -5
- griptape_nodes/cli/commands/init.py +65 -54
- griptape_nodes/cli/commands/libraries.py +92 -85
- griptape_nodes/cli/commands/self.py +121 -0
- griptape_nodes/common/node_executor.py +2142 -101
- griptape_nodes/exe_types/base_iterative_nodes.py +1004 -0
- griptape_nodes/exe_types/connections.py +114 -19
- griptape_nodes/exe_types/core_types.py +225 -7
- griptape_nodes/exe_types/flow.py +3 -3
- griptape_nodes/exe_types/node_types.py +681 -225
- griptape_nodes/exe_types/param_components/README.md +414 -0
- griptape_nodes/exe_types/param_components/api_key_provider_parameter.py +200 -0
- griptape_nodes/exe_types/param_components/huggingface/huggingface_model_parameter.py +2 -0
- griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_file_parameter.py +79 -5
- griptape_nodes/exe_types/param_types/parameter_button.py +443 -0
- griptape_nodes/machines/control_flow.py +77 -38
- griptape_nodes/machines/dag_builder.py +148 -70
- griptape_nodes/machines/parallel_resolution.py +61 -35
- griptape_nodes/machines/sequential_resolution.py +11 -113
- griptape_nodes/retained_mode/events/app_events.py +1 -0
- griptape_nodes/retained_mode/events/base_events.py +16 -13
- griptape_nodes/retained_mode/events/connection_events.py +3 -0
- griptape_nodes/retained_mode/events/execution_events.py +35 -0
- griptape_nodes/retained_mode/events/flow_events.py +15 -2
- griptape_nodes/retained_mode/events/library_events.py +347 -0
- griptape_nodes/retained_mode/events/node_events.py +48 -0
- griptape_nodes/retained_mode/events/os_events.py +86 -3
- griptape_nodes/retained_mode/events/project_events.py +15 -1
- griptape_nodes/retained_mode/events/workflow_events.py +48 -1
- griptape_nodes/retained_mode/griptape_nodes.py +6 -2
- griptape_nodes/retained_mode/managers/config_manager.py +10 -8
- griptape_nodes/retained_mode/managers/event_manager.py +168 -0
- griptape_nodes/retained_mode/managers/fitness_problems/libraries/__init__.py +2 -0
- griptape_nodes/retained_mode/managers/fitness_problems/libraries/old_xdg_location_warning_problem.py +43 -0
- griptape_nodes/retained_mode/managers/flow_manager.py +664 -123
- griptape_nodes/retained_mode/managers/library_manager.py +1143 -139
- griptape_nodes/retained_mode/managers/model_manager.py +2 -3
- griptape_nodes/retained_mode/managers/node_manager.py +148 -25
- griptape_nodes/retained_mode/managers/object_manager.py +3 -1
- griptape_nodes/retained_mode/managers/operation_manager.py +3 -1
- griptape_nodes/retained_mode/managers/os_manager.py +1158 -122
- griptape_nodes/retained_mode/managers/secrets_manager.py +2 -3
- griptape_nodes/retained_mode/managers/settings.py +21 -1
- griptape_nodes/retained_mode/managers/sync_manager.py +2 -3
- griptape_nodes/retained_mode/managers/workflow_manager.py +358 -104
- griptape_nodes/retained_mode/retained_mode.py +3 -3
- griptape_nodes/traits/button.py +44 -2
- griptape_nodes/traits/file_system_picker.py +2 -2
- griptape_nodes/utils/file_utils.py +101 -0
- griptape_nodes/utils/git_utils.py +1226 -0
- griptape_nodes/utils/library_utils.py +122 -0
- {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/METADATA +2 -1
- {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/RECORD +55 -47
- {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/WHEEL +1 -1
- {griptape_nodes-0.64.10.dist-info → griptape_nodes-0.65.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,1226 @@
|
|
|
1
|
+
"""Git utilities for library updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NamedTuple
|
|
11
|
+
|
|
12
|
+
import pygit2
|
|
13
|
+
|
|
14
|
+
from griptape_nodes.utils.file_utils import find_file_in_directory
|
|
15
|
+
|
|
16
|
+
# Common SSH key paths to try when SSH agent doesn't have keys loaded
|
|
17
|
+
_SSH_KEY_PATHS = [
|
|
18
|
+
Path.home() / ".ssh" / "id_ed25519",
|
|
19
|
+
Path.home() / ".ssh" / "id_rsa",
|
|
20
|
+
Path.home() / ".ssh" / "id_ecdsa",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("griptape_nodes")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GitError(Exception):
|
|
27
|
+
"""Base exception for git operations."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GitRepositoryError(GitError):
|
|
31
|
+
"""Raised when a path is not a valid git repository."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GitRemoteError(GitError):
|
|
35
|
+
"""Raised when git remote operations fail."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GitRefError(GitError):
|
|
39
|
+
"""Raised when git ref operations fail."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class GitCloneError(GitError):
|
|
43
|
+
"""Raised when git clone operations fail."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GitPullError(GitError):
|
|
47
|
+
"""Raised when git pull operations fail."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GitUrlWithRef(NamedTuple):
|
|
51
|
+
"""Parsed git URL with optional ref (branch/tag/commit)."""
|
|
52
|
+
|
|
53
|
+
url: str
|
|
54
|
+
ref: str | None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_git_url(url: str) -> bool:
|
|
58
|
+
"""Check if a string is a git URL.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
url: The URL to check.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
bool: True if the string is a git URL, False otherwise.
|
|
65
|
+
"""
|
|
66
|
+
git_url_patterns = (
|
|
67
|
+
"http://",
|
|
68
|
+
"https://",
|
|
69
|
+
"git://",
|
|
70
|
+
"ssh://",
|
|
71
|
+
"git@",
|
|
72
|
+
)
|
|
73
|
+
return url.startswith(git_url_patterns)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_git_url_with_ref(url_with_ref: str) -> GitUrlWithRef:
|
|
77
|
+
"""Parse a git URL that may contain a ref specification using @ delimiter.
|
|
78
|
+
|
|
79
|
+
Supports format: url@ref where ref can be a branch, tag, or commit SHA.
|
|
80
|
+
If no @ delimiter is present, returns the URL with None as the ref.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
url_with_ref: A git URL optionally followed by @ref
|
|
84
|
+
(e.g., "https://github.com/user/repo@stable" or "user/repo@v1.0.0")
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
GitUrlWithRef: Parsed URL with optional ref (branch/tag/commit).
|
|
88
|
+
|
|
89
|
+
Examples:
|
|
90
|
+
"https://github.com/user/repo@stable" -> GitUrlWithRef("https://github.com/user/repo", "stable")
|
|
91
|
+
"user/repo@main" -> GitUrlWithRef("user/repo", "main")
|
|
92
|
+
"https://github.com/user/repo" -> GitUrlWithRef("https://github.com/user/repo", None)
|
|
93
|
+
"user/repo" -> GitUrlWithRef("user/repo", None)
|
|
94
|
+
"""
|
|
95
|
+
url_with_ref = url_with_ref.strip()
|
|
96
|
+
|
|
97
|
+
# Check for @ delimiter (but not in SSH URLs like git@github.com)
|
|
98
|
+
# We need to be careful not to split on the @ in git@github.com
|
|
99
|
+
if url_with_ref.startswith("git@"):
|
|
100
|
+
# SSH URL format - look for @ after the domain
|
|
101
|
+
# Format: git@github.com:user/repo@ref
|
|
102
|
+
parts = url_with_ref.split(":", 1)
|
|
103
|
+
if len(parts) == 2 and "@" in parts[1]: # noqa: PLR2004
|
|
104
|
+
# Split the path part only
|
|
105
|
+
path_parts = parts[1].rsplit("@", 1)
|
|
106
|
+
if len(path_parts) == 2: # noqa: PLR2004
|
|
107
|
+
return GitUrlWithRef(url=f"{parts[0]}:{path_parts[0]}", ref=path_parts[1])
|
|
108
|
+
return GitUrlWithRef(url=url_with_ref, ref=None)
|
|
109
|
+
|
|
110
|
+
# For HTTPS/HTTP URLs and shorthand, split on last @
|
|
111
|
+
if "@" in url_with_ref:
|
|
112
|
+
# Use rsplit to split from the right, so we get the last @ (in case of user:pass@host format)
|
|
113
|
+
parts = url_with_ref.rsplit("@", 1)
|
|
114
|
+
if len(parts) == 2: # noqa: PLR2004
|
|
115
|
+
return GitUrlWithRef(url=parts[0], ref=parts[1])
|
|
116
|
+
|
|
117
|
+
return GitUrlWithRef(url=url_with_ref, ref=None)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def normalize_github_url(url_or_shorthand: str) -> str:
|
|
121
|
+
"""Normalize a GitHub URL or shorthand to a full HTTPS git URL.
|
|
122
|
+
|
|
123
|
+
Converts GitHub shorthand (e.g., "owner/repo") to full HTTPS URLs.
|
|
124
|
+
Ensures .git suffix on GitHub URLs. Passes through non-GitHub URLs unchanged.
|
|
125
|
+
Preserves @ref suffix if present.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
url_or_shorthand: Either a full git URL or GitHub shorthand (e.g., "user/repo"),
|
|
129
|
+
optionally with @ref suffix (e.g., "user/repo@stable").
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
A normalized HTTPS git URL, preserving any @ref suffix.
|
|
133
|
+
|
|
134
|
+
Examples:
|
|
135
|
+
"griptape-ai/griptape-nodes-library-topazlabs" -> "https://github.com/griptape-ai/griptape-nodes-library-topazlabs.git"
|
|
136
|
+
"griptape-ai/repo@stable" -> "https://github.com/griptape-ai/repo.git@stable"
|
|
137
|
+
"https://github.com/user/repo" -> "https://github.com/user/repo.git"
|
|
138
|
+
"https://github.com/user/repo@main" -> "https://github.com/user/repo.git@main"
|
|
139
|
+
"git@github.com:user/repo.git" -> "git@github.com:user/repo.git"
|
|
140
|
+
"https://gitlab.com/user/repo" -> "https://gitlab.com/user/repo"
|
|
141
|
+
"""
|
|
142
|
+
url_or_shorthand = url_or_shorthand.strip().rstrip("/")
|
|
143
|
+
|
|
144
|
+
# Parse out @ref suffix if present
|
|
145
|
+
url, ref = parse_git_url_with_ref(url_or_shorthand)
|
|
146
|
+
|
|
147
|
+
# Check if it's GitHub shorthand: owner/repo (no protocol, single slash, no domain)
|
|
148
|
+
if not is_git_url(url) and "/" in url and url.count("/") == 1:
|
|
149
|
+
# Assume GitHub shorthand
|
|
150
|
+
normalized = f"https://github.com/{url}.git"
|
|
151
|
+
elif "github.com" in url and not url.endswith(".git"):
|
|
152
|
+
# If it's a GitHub URL, ensure .git suffix
|
|
153
|
+
normalized = f"{url}.git"
|
|
154
|
+
else:
|
|
155
|
+
# Pass through all other URLs unchanged
|
|
156
|
+
normalized = url
|
|
157
|
+
|
|
158
|
+
# Re-append @ref suffix if it was present
|
|
159
|
+
if ref is not None:
|
|
160
|
+
return f"{normalized}@{ref}"
|
|
161
|
+
|
|
162
|
+
return normalized
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def extract_repo_name_from_url(url: str) -> str:
|
|
166
|
+
"""Extract the repository name from a git URL.
|
|
167
|
+
|
|
168
|
+
Handles URLs with @ref suffix by stripping the ref before extraction.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
url: A git URL (HTTPS, SSH, or GitHub shorthand), optionally with @ref suffix.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The repository name without the .git suffix or @ref.
|
|
175
|
+
|
|
176
|
+
Examples:
|
|
177
|
+
"https://github.com/griptape-ai/griptape-nodes-library-advanced" -> "griptape-nodes-library-advanced"
|
|
178
|
+
"https://github.com/griptape-ai/griptape-nodes-library-advanced.git" -> "griptape-nodes-library-advanced"
|
|
179
|
+
"https://github.com/griptape-ai/griptape-nodes-library-advanced@stable" -> "griptape-nodes-library-advanced"
|
|
180
|
+
"git@github.com:user/repo.git" -> "repo"
|
|
181
|
+
"griptape-ai/repo" -> "repo"
|
|
182
|
+
"griptape-ai/repo@main" -> "repo"
|
|
183
|
+
"""
|
|
184
|
+
url = url.strip().rstrip("/")
|
|
185
|
+
|
|
186
|
+
# Strip @ref suffix if present
|
|
187
|
+
url, _ = parse_git_url_with_ref(url)
|
|
188
|
+
|
|
189
|
+
# Remove .git suffix if present
|
|
190
|
+
url = url.removesuffix(".git")
|
|
191
|
+
|
|
192
|
+
# Extract the last part of the path
|
|
193
|
+
# Handle both https://domain/owner/repo and git@domain:owner/repo formats
|
|
194
|
+
if ":" in url and not url.startswith(("http://", "https://", "ssh://")):
|
|
195
|
+
# SSH format: git@github.com:owner/repo
|
|
196
|
+
repo_name = url.split(":")[-1].split("/")[-1]
|
|
197
|
+
else:
|
|
198
|
+
# HTTPS format or shorthand: https://github.com/owner/repo or owner/repo
|
|
199
|
+
repo_name = url.split("/")[-1]
|
|
200
|
+
|
|
201
|
+
return repo_name
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def is_git_repository(path: Path) -> bool:
|
|
205
|
+
"""Check if a directory or its parent is a git repository.
|
|
206
|
+
|
|
207
|
+
This checks both the given path and its parent directory for a .git folder.
|
|
208
|
+
This handles cases where library JSON files are in subdirectories of a git
|
|
209
|
+
repository (e.g., monorepo structures).
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
path: The directory path to check.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
bool: True if the directory or its parent is a git repository, False otherwise.
|
|
216
|
+
"""
|
|
217
|
+
if not path.exists():
|
|
218
|
+
return False
|
|
219
|
+
if not path.is_dir():
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
# Check for .git directory or file in the given path (for git worktrees/submodules)
|
|
223
|
+
git_path = path / ".git"
|
|
224
|
+
if git_path.exists():
|
|
225
|
+
return True
|
|
226
|
+
|
|
227
|
+
# Check parent directory for .git
|
|
228
|
+
parent_path = path.parent
|
|
229
|
+
if parent_path != path and parent_path.exists():
|
|
230
|
+
parent_git_path = parent_path / ".git"
|
|
231
|
+
if parent_git_path.exists():
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def get_git_remote(library_path: Path) -> str | None:
|
|
238
|
+
"""Get the git remote URL for a library directory.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
library_path: The path to the library directory.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
str | None: The remote URL if found, None if not a git repository or no remote configured.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
GitRemoteError: If an error occurs while accessing the git remote.
|
|
248
|
+
"""
|
|
249
|
+
if not is_git_repository(library_path):
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
254
|
+
if repo_path is None:
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
repo = pygit2.Repository(repo_path)
|
|
258
|
+
|
|
259
|
+
# Access remote by indexing (raises KeyError if not found)
|
|
260
|
+
try:
|
|
261
|
+
remote = repo.remotes["origin"]
|
|
262
|
+
except (KeyError, IndexError):
|
|
263
|
+
return None
|
|
264
|
+
else:
|
|
265
|
+
return remote.url
|
|
266
|
+
|
|
267
|
+
except pygit2.GitError as e:
|
|
268
|
+
msg = f"Error getting git remote for {library_path}: {e}"
|
|
269
|
+
raise GitRemoteError(msg) from e
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_current_ref(library_path: Path) -> str | None:
|
|
273
|
+
"""Get the current git reference (branch, tag, or commit) for a library directory.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
library_path: The path to the library directory.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
str | None: The current git reference (branch name, tag name, or commit SHA) if found, None if not a git repository.
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
GitRefError: If an error occurs while getting the current git reference.
|
|
283
|
+
"""
|
|
284
|
+
if not is_git_repository(library_path):
|
|
285
|
+
logger.debug("Path %s is not a git repository", library_path)
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
290
|
+
if repo_path is None:
|
|
291
|
+
logger.debug("Could not discover git repository at %s", library_path)
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
repo = pygit2.Repository(repo_path)
|
|
295
|
+
|
|
296
|
+
# Check if HEAD is detached
|
|
297
|
+
if repo.head_is_detached:
|
|
298
|
+
# HEAD is detached - check if it's pointing to a tag
|
|
299
|
+
tag_name = get_current_tag(library_path)
|
|
300
|
+
if tag_name:
|
|
301
|
+
logger.debug("Repository at %s has detached HEAD on tag %s", library_path, tag_name)
|
|
302
|
+
return tag_name
|
|
303
|
+
|
|
304
|
+
# No tag found, return the commit SHA as fallback
|
|
305
|
+
head_commit = repo.head.target
|
|
306
|
+
logger.debug("Repository at %s has detached HEAD at commit %s", library_path, head_commit)
|
|
307
|
+
return str(head_commit)
|
|
308
|
+
|
|
309
|
+
except pygit2.GitError as e:
|
|
310
|
+
msg = f"Error getting current git reference for {library_path}: {e}"
|
|
311
|
+
raise GitRefError(msg) from e
|
|
312
|
+
else:
|
|
313
|
+
# Get the current git reference name (branch)
|
|
314
|
+
return repo.head.shorthand
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def get_current_tag(library_path: Path) -> str | None:
|
|
318
|
+
"""Get the current tag name if HEAD is pointing to a tag.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
library_path: The path to the library directory.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
str | None: The current tag name if found, None if not on a tag or not a git repository.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
GitError: If an error occurs while getting the current tag.
|
|
328
|
+
"""
|
|
329
|
+
if not is_git_repository(library_path):
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
334
|
+
if repo_path is None:
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
repo = pygit2.Repository(repo_path)
|
|
338
|
+
|
|
339
|
+
# Get the current HEAD commit
|
|
340
|
+
if repo.head_is_unborn:
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
head_commit = repo.head.target
|
|
344
|
+
|
|
345
|
+
# Check all tags to see if any point to HEAD
|
|
346
|
+
for tag_name in repo.references:
|
|
347
|
+
if not tag_name.startswith("refs/tags/"):
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
tag_ref = repo.references[tag_name]
|
|
351
|
+
# Handle both lightweight and annotated tags
|
|
352
|
+
if hasattr(tag_ref, "peel"):
|
|
353
|
+
tag_target = tag_ref.peel(pygit2.Commit).id
|
|
354
|
+
else:
|
|
355
|
+
tag_target = tag_ref.target
|
|
356
|
+
|
|
357
|
+
if tag_target == head_commit:
|
|
358
|
+
# Return tag name without refs/tags/ prefix
|
|
359
|
+
return tag_name.replace("refs/tags/", "")
|
|
360
|
+
except pygit2.GitError as e:
|
|
361
|
+
msg = f"Error getting current tag for {library_path}: {e}"
|
|
362
|
+
raise GitError(msg) from e
|
|
363
|
+
else:
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def is_on_tag(library_path: Path) -> bool:
|
|
368
|
+
"""Check if HEAD is currently pointing to a tag.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
library_path: The path to the library directory.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
bool: True if HEAD is on a tag, False otherwise.
|
|
375
|
+
"""
|
|
376
|
+
return get_current_tag(library_path) is not None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def get_local_commit_sha(library_path: Path) -> str | None:
|
|
380
|
+
"""Get the current HEAD commit SHA for a library directory.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
library_path: The path to the library directory.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
str | None: The full commit SHA if found, None if not a git repository or error occurs.
|
|
387
|
+
|
|
388
|
+
Raises:
|
|
389
|
+
GitError: If an error occurs while getting the commit SHA.
|
|
390
|
+
"""
|
|
391
|
+
if not is_git_repository(library_path):
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
396
|
+
if repo_path is None:
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
repo = pygit2.Repository(repo_path)
|
|
400
|
+
|
|
401
|
+
if repo.head_is_unborn:
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
return str(repo.head.target)
|
|
405
|
+
|
|
406
|
+
except pygit2.GitError as e:
|
|
407
|
+
msg = f"Error getting commit SHA for {library_path}: {e}"
|
|
408
|
+
raise GitError(msg) from e
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def get_git_repository_root(library_path: Path) -> Path | None:
|
|
412
|
+
"""Get the root directory of the git repository containing the given path.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
library_path: A path within a git repository.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Path | None: The root directory of the git repository, or None if not in a git repository.
|
|
419
|
+
|
|
420
|
+
Raises:
|
|
421
|
+
GitRepositoryError: If an error occurs while accessing the git repository.
|
|
422
|
+
"""
|
|
423
|
+
if not is_git_repository(library_path):
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
428
|
+
if repo_path is None:
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
# discover_repository returns path to .git directory
|
|
432
|
+
# For a normal repo: /path/to/repo/.git
|
|
433
|
+
# For a bare repo: /path/to/repo.git
|
|
434
|
+
git_dir = Path(repo_path)
|
|
435
|
+
|
|
436
|
+
# Check if it's a bare repository
|
|
437
|
+
if git_dir.name.endswith(".git") and git_dir.is_dir():
|
|
438
|
+
repo = pygit2.Repository(repo_path)
|
|
439
|
+
if repo.is_bare:
|
|
440
|
+
return git_dir
|
|
441
|
+
|
|
442
|
+
except pygit2.GitError as e:
|
|
443
|
+
msg = f"Error getting git repository root for {library_path}: {e}"
|
|
444
|
+
raise GitRepositoryError(msg) from e
|
|
445
|
+
else:
|
|
446
|
+
# Normal repository - return parent of .git directory
|
|
447
|
+
return git_dir.parent
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def has_uncommitted_changes(library_path: Path) -> bool:
|
|
451
|
+
"""Check if a repository has uncommitted changes (including untracked files).
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
library_path: The path to the library directory.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
True if there are uncommitted changes or untracked files, False otherwise.
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
GitRepositoryError: If the path is not a valid git repository.
|
|
461
|
+
"""
|
|
462
|
+
if not is_git_repository(library_path):
|
|
463
|
+
msg = f"Cannot check status: {library_path} is not a git repository"
|
|
464
|
+
raise GitRepositoryError(msg)
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
468
|
+
if repo_path is None:
|
|
469
|
+
msg = f"Cannot check status: {library_path} is not a git repository"
|
|
470
|
+
raise GitRepositoryError(msg)
|
|
471
|
+
|
|
472
|
+
repo = pygit2.Repository(repo_path)
|
|
473
|
+
status = repo.status()
|
|
474
|
+
return len(status) > 0
|
|
475
|
+
|
|
476
|
+
except pygit2.GitError as e:
|
|
477
|
+
msg = f"Failed to check git status at {library_path}: {e}"
|
|
478
|
+
raise GitRepositoryError(msg) from e
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _validate_branch_update_preconditions(library_path: Path) -> None:
|
|
482
|
+
"""Validate preconditions for branch-based update.
|
|
483
|
+
|
|
484
|
+
Raises:
|
|
485
|
+
GitRepositoryError: If validation fails.
|
|
486
|
+
GitPullError: If repository state is invalid for update.
|
|
487
|
+
"""
|
|
488
|
+
if not is_git_repository(library_path):
|
|
489
|
+
msg = f"Cannot update: {library_path} is not a git repository"
|
|
490
|
+
raise GitRepositoryError(msg)
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
494
|
+
if repo_path is None:
|
|
495
|
+
msg = f"Cannot discover repository at {library_path}"
|
|
496
|
+
raise GitRepositoryError(msg)
|
|
497
|
+
|
|
498
|
+
repo = pygit2.Repository(repo_path)
|
|
499
|
+
|
|
500
|
+
if repo.head_is_detached:
|
|
501
|
+
msg = f"Repository at {library_path} has detached HEAD"
|
|
502
|
+
raise GitPullError(msg)
|
|
503
|
+
|
|
504
|
+
current_branch = repo.branches.get(repo.head.shorthand)
|
|
505
|
+
if current_branch is None:
|
|
506
|
+
msg = f"Cannot get current branch for repository at {library_path}"
|
|
507
|
+
raise GitPullError(msg)
|
|
508
|
+
|
|
509
|
+
if current_branch.upstream is None:
|
|
510
|
+
msg = f"No upstream branch set for {current_branch.branch_name} at {library_path}"
|
|
511
|
+
raise GitPullError(msg)
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
_ = repo.remotes["origin"]
|
|
515
|
+
except (KeyError, IndexError) as e:
|
|
516
|
+
msg = f"No origin remote found for repository at {library_path}"
|
|
517
|
+
raise GitPullError(msg) from e
|
|
518
|
+
|
|
519
|
+
except pygit2.GitError as e:
|
|
520
|
+
msg = f"Git error during update at {library_path}: {e}"
|
|
521
|
+
raise GitPullError(msg) from e
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def git_update_from_remote(library_path: Path, *, overwrite_existing: bool = False) -> None:
|
|
525
|
+
"""Update a library from remote by resetting to match upstream exactly.
|
|
526
|
+
|
|
527
|
+
This function uses git fetch + git reset --hard to force the local repository
|
|
528
|
+
to match the remote state. This is appropriate for library consumption where
|
|
529
|
+
local modifications should not be preserved.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
library_path: The path to the library directory.
|
|
533
|
+
overwrite_existing: If True, discard any uncommitted local changes.
|
|
534
|
+
If False, fail if uncommitted changes exist.
|
|
535
|
+
|
|
536
|
+
Raises:
|
|
537
|
+
GitRepositoryError: If the path is not a valid git repository.
|
|
538
|
+
GitPullError: If the update operation fails or uncommitted changes exist
|
|
539
|
+
when overwrite_existing=False.
|
|
540
|
+
"""
|
|
541
|
+
_validate_branch_update_preconditions(library_path)
|
|
542
|
+
|
|
543
|
+
if has_uncommitted_changes(library_path):
|
|
544
|
+
if not overwrite_existing:
|
|
545
|
+
msg = f"Cannot update library at {library_path}: You have uncommitted changes. Use overwrite_existing=True to discard them."
|
|
546
|
+
raise GitPullError(msg)
|
|
547
|
+
|
|
548
|
+
logger.warning("Discarding uncommitted changes at %s", library_path)
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
552
|
+
if repo_path is None:
|
|
553
|
+
msg = f"Cannot update: {library_path} is not a git repository"
|
|
554
|
+
raise GitPullError(msg)
|
|
555
|
+
|
|
556
|
+
repo = pygit2.Repository(repo_path)
|
|
557
|
+
|
|
558
|
+
# Get remote and fetch
|
|
559
|
+
remote = repo.remotes["origin"]
|
|
560
|
+
remote.fetch()
|
|
561
|
+
|
|
562
|
+
# Get upstream branch reference
|
|
563
|
+
try:
|
|
564
|
+
upstream_name = repo.branches.get(repo.head.shorthand).upstream.branch_name
|
|
565
|
+
upstream_ref = repo.references.get(f"refs/remotes/{upstream_name}")
|
|
566
|
+
if upstream_ref is None:
|
|
567
|
+
msg = f"Failed to find upstream reference {upstream_name} at {library_path}"
|
|
568
|
+
raise GitPullError(msg)
|
|
569
|
+
upstream_oid = upstream_ref.target
|
|
570
|
+
except (pygit2.GitError, AttributeError) as e:
|
|
571
|
+
msg = f"Failed to determine upstream branch at {library_path}: {e}"
|
|
572
|
+
raise GitPullError(msg) from e
|
|
573
|
+
|
|
574
|
+
# Hard reset to upstream
|
|
575
|
+
repo.reset(upstream_oid, pygit2.enums.ResetMode.HARD)
|
|
576
|
+
|
|
577
|
+
logger.debug("Successfully updated library at %s to match remote %s", library_path, upstream_name)
|
|
578
|
+
|
|
579
|
+
except pygit2.GitError as e:
|
|
580
|
+
msg = f"Git error during update at {library_path}: {e}"
|
|
581
|
+
raise GitPullError(msg) from e
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def update_to_moving_tag(library_path: Path, tag_name: str, *, overwrite_existing: bool = False) -> None:
|
|
585
|
+
"""Update library to the latest version of a moving tag.
|
|
586
|
+
|
|
587
|
+
This function is designed for tags that are force-pushed to point to new commits
|
|
588
|
+
(e.g., a 'latest' tag that always points to the newest release).
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
library_path: The path to the library directory.
|
|
592
|
+
tag_name: The name of the tag to update to (e.g., "latest").
|
|
593
|
+
overwrite_existing: If True, discard any uncommitted local changes.
|
|
594
|
+
If False, fail if uncommitted changes exist.
|
|
595
|
+
|
|
596
|
+
Raises:
|
|
597
|
+
GitRepositoryError: If the path is not a valid git repository.
|
|
598
|
+
GitPullError: If the tag update operation fails or uncommitted changes exist
|
|
599
|
+
when overwrite_existing=False.
|
|
600
|
+
"""
|
|
601
|
+
if not is_git_repository(library_path):
|
|
602
|
+
msg = f"Cannot update tag: {library_path} is not a git repository"
|
|
603
|
+
raise GitRepositoryError(msg)
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
607
|
+
if repo_path is None:
|
|
608
|
+
msg = f"Cannot discover repository at {library_path}"
|
|
609
|
+
raise GitRepositoryError(msg)
|
|
610
|
+
|
|
611
|
+
repo = pygit2.Repository(repo_path)
|
|
612
|
+
|
|
613
|
+
# Check for origin remote
|
|
614
|
+
try:
|
|
615
|
+
_ = repo.remotes["origin"]
|
|
616
|
+
except (KeyError, IndexError) as e:
|
|
617
|
+
msg = f"No origin remote found for repository at {library_path}"
|
|
618
|
+
raise GitPullError(msg) from e
|
|
619
|
+
|
|
620
|
+
except pygit2.GitError as e:
|
|
621
|
+
msg = f"Git error during tag update at {library_path}: {e}"
|
|
622
|
+
raise GitPullError(msg) from e
|
|
623
|
+
|
|
624
|
+
# Check for uncommitted changes
|
|
625
|
+
if has_uncommitted_changes(library_path):
|
|
626
|
+
if not overwrite_existing:
|
|
627
|
+
msg = f"Cannot update library at {library_path}: You have uncommitted changes. Use overwrite_existing=True to discard them."
|
|
628
|
+
raise GitPullError(msg)
|
|
629
|
+
|
|
630
|
+
logger.warning("Discarding uncommitted changes at %s", library_path)
|
|
631
|
+
|
|
632
|
+
# Use pygit2 to fetch tags and checkout
|
|
633
|
+
try:
|
|
634
|
+
# Step 1: Fetch all tags, force update existing ones
|
|
635
|
+
remote = repo.remotes["origin"]
|
|
636
|
+
remote.fetch(refspecs=["+refs/tags/*:refs/tags/*"])
|
|
637
|
+
|
|
638
|
+
# Step 2: Checkout the tag with force to discard local changes
|
|
639
|
+
tag_ref = f"refs/tags/{tag_name}"
|
|
640
|
+
if tag_ref not in repo.references:
|
|
641
|
+
msg = f"Tag {tag_name} not found at {library_path}"
|
|
642
|
+
raise GitPullError(msg)
|
|
643
|
+
|
|
644
|
+
strategy = pygit2.enums.CheckoutStrategy.FORCE if overwrite_existing else pygit2.enums.CheckoutStrategy.SAFE
|
|
645
|
+
repo.checkout(tag_ref, strategy=strategy)
|
|
646
|
+
|
|
647
|
+
logger.debug("Successfully updated library at %s to tag %s", library_path, tag_name)
|
|
648
|
+
|
|
649
|
+
except pygit2.GitError as e:
|
|
650
|
+
msg = f"Git error during tag update at {library_path}: {e}"
|
|
651
|
+
raise GitPullError(msg) from e
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def update_library_git(library_path: Path, *, overwrite_existing: bool = False) -> None:
|
|
655
|
+
"""Update a library to the latest version using the appropriate git strategy.
|
|
656
|
+
|
|
657
|
+
This function automatically detects whether the library uses a branch-based or
|
|
658
|
+
tag-based workflow and applies the correct update mechanism:
|
|
659
|
+
- Branch-based: Uses git fetch + git reset --hard
|
|
660
|
+
- Tag-based: Uses git fetch --tags --force + git checkout
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
library_path: The path to the library directory.
|
|
664
|
+
overwrite_existing: If True, discard any uncommitted local changes.
|
|
665
|
+
If False, fail if uncommitted changes exist.
|
|
666
|
+
|
|
667
|
+
Raises:
|
|
668
|
+
GitRepositoryError: If the path is not a valid git repository.
|
|
669
|
+
GitPullError: If the update operation fails or uncommitted changes exist
|
|
670
|
+
when overwrite_existing=False.
|
|
671
|
+
"""
|
|
672
|
+
if not is_git_repository(library_path):
|
|
673
|
+
msg = f"Cannot update: {library_path} is not a git repository"
|
|
674
|
+
raise GitRepositoryError(msg)
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
678
|
+
if repo_path is None:
|
|
679
|
+
msg = f"Cannot discover repository at {library_path}"
|
|
680
|
+
raise GitRepositoryError(msg)
|
|
681
|
+
|
|
682
|
+
repo = pygit2.Repository(repo_path)
|
|
683
|
+
|
|
684
|
+
# Detect workflow type
|
|
685
|
+
if repo.head_is_detached:
|
|
686
|
+
# Detached HEAD - likely on a tag
|
|
687
|
+
tag_name = get_current_tag(library_path)
|
|
688
|
+
if tag_name is None:
|
|
689
|
+
msg = f"Repository at {library_path} is in detached HEAD state but not on a known tag. Cannot auto-update."
|
|
690
|
+
raise GitPullError(msg)
|
|
691
|
+
|
|
692
|
+
logger.debug("Detected tag-based workflow for %s (tag: %s)", library_path, tag_name)
|
|
693
|
+
update_to_moving_tag(library_path, tag_name, overwrite_existing=overwrite_existing)
|
|
694
|
+
else:
|
|
695
|
+
# On a branch - use fetch + reset to match remote
|
|
696
|
+
logger.debug("Detected branch-based workflow for %s", library_path)
|
|
697
|
+
git_update_from_remote(library_path, overwrite_existing=overwrite_existing)
|
|
698
|
+
|
|
699
|
+
except pygit2.GitError as e:
|
|
700
|
+
msg = f"Git error during library update at {library_path}: {e}"
|
|
701
|
+
raise GitPullError(msg) from e
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def switch_branch(library_path: Path, branch_name: str) -> None:
|
|
705
|
+
"""Switch to a different branch in a library directory.
|
|
706
|
+
|
|
707
|
+
Fetches from remote first, then checks out the specified branch.
|
|
708
|
+
If the branch doesn't exist locally, creates a tracking branch from remote.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
library_path: The path to the library directory.
|
|
712
|
+
branch_name: The name of the branch to switch to.
|
|
713
|
+
|
|
714
|
+
Raises:
|
|
715
|
+
GitRepositoryError: If the path is not a valid git repository.
|
|
716
|
+
GitRefError: If the branch switch operation fails.
|
|
717
|
+
"""
|
|
718
|
+
if not is_git_repository(library_path):
|
|
719
|
+
msg = f"Cannot switch branch: {library_path} is not a git repository"
|
|
720
|
+
raise GitRepositoryError(msg)
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
724
|
+
if repo_path is None:
|
|
725
|
+
msg = f"Cannot discover repository at {library_path}"
|
|
726
|
+
raise GitRepositoryError(msg)
|
|
727
|
+
|
|
728
|
+
repo = pygit2.Repository(repo_path)
|
|
729
|
+
|
|
730
|
+
# Get origin remote
|
|
731
|
+
try:
|
|
732
|
+
remote = repo.remotes["origin"]
|
|
733
|
+
except (KeyError, IndexError) as e:
|
|
734
|
+
msg = f"No origin remote found for repository at {library_path}"
|
|
735
|
+
raise GitRefError(msg) from e
|
|
736
|
+
|
|
737
|
+
# Fetch from remote first
|
|
738
|
+
remote.fetch()
|
|
739
|
+
|
|
740
|
+
# Try to find the branch locally first
|
|
741
|
+
local_branch = repo.branches.get(branch_name)
|
|
742
|
+
|
|
743
|
+
if local_branch is not None:
|
|
744
|
+
# Branch exists locally, just check it out
|
|
745
|
+
repo.checkout(local_branch)
|
|
746
|
+
logger.debug("Checked out existing local branch %s at %s", branch_name, library_path)
|
|
747
|
+
return
|
|
748
|
+
|
|
749
|
+
# Branch doesn't exist locally, try to find it on remote
|
|
750
|
+
remote_branch_name = f"origin/{branch_name}"
|
|
751
|
+
remote_branch = repo.branches.get(remote_branch_name)
|
|
752
|
+
|
|
753
|
+
if remote_branch is None:
|
|
754
|
+
msg = f"Branch {branch_name} not found locally or on remote at {library_path}"
|
|
755
|
+
raise GitRefError(msg)
|
|
756
|
+
|
|
757
|
+
# Create local tracking branch from remote
|
|
758
|
+
commit = repo.get(remote_branch.target)
|
|
759
|
+
if commit is None:
|
|
760
|
+
msg = f"Failed to get commit for remote branch {remote_branch_name} at {library_path}"
|
|
761
|
+
raise GitRefError(msg)
|
|
762
|
+
|
|
763
|
+
new_branch = repo.branches.local.create(branch_name, commit) # type: ignore[arg-type]
|
|
764
|
+
new_branch.upstream = remote_branch
|
|
765
|
+
|
|
766
|
+
# Checkout the new branch
|
|
767
|
+
repo.checkout(new_branch)
|
|
768
|
+
logger.debug(
|
|
769
|
+
"Created and checked out tracking branch %s from %s at %s", branch_name, remote_branch_name, library_path
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
except pygit2.GitError as e:
|
|
773
|
+
msg = f"Git error during branch switch at {library_path}: {e}"
|
|
774
|
+
raise GitRefError(msg) from e
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def switch_branch_or_tag(library_path: Path, ref_name: str) -> None:
|
|
778
|
+
"""Switch to a different branch or tag in a library directory.
|
|
779
|
+
|
|
780
|
+
Fetches from remote first, then checks out the specified branch or tag.
|
|
781
|
+
Automatically detects whether the ref is a branch or tag.
|
|
782
|
+
|
|
783
|
+
Args:
|
|
784
|
+
library_path: The path to the library directory.
|
|
785
|
+
ref_name: The name of the branch or tag to switch to.
|
|
786
|
+
|
|
787
|
+
Raises:
|
|
788
|
+
GitRepositoryError: If the path is not a valid git repository.
|
|
789
|
+
GitRefError: If the switch operation fails.
|
|
790
|
+
"""
|
|
791
|
+
if not is_git_repository(library_path):
|
|
792
|
+
msg = f"Cannot switch ref: {library_path} is not a git repository"
|
|
793
|
+
raise GitRepositoryError(msg)
|
|
794
|
+
|
|
795
|
+
try:
|
|
796
|
+
repo_path = pygit2.discover_repository(str(library_path))
|
|
797
|
+
if repo_path is None:
|
|
798
|
+
msg = f"Cannot switch ref: {library_path} is not a git repository"
|
|
799
|
+
raise GitRefError(msg)
|
|
800
|
+
|
|
801
|
+
repo = pygit2.Repository(repo_path)
|
|
802
|
+
|
|
803
|
+
# Fetch both branches and tags from remote
|
|
804
|
+
remote = repo.remotes["origin"]
|
|
805
|
+
remote.fetch(refspecs=["+refs/tags/*:refs/tags/*"])
|
|
806
|
+
remote.fetch()
|
|
807
|
+
|
|
808
|
+
# Try to checkout the ref (works for both branches and tags)
|
|
809
|
+
# First check if it's a tag
|
|
810
|
+
tag_ref = f"refs/tags/{ref_name}"
|
|
811
|
+
branch_ref = f"refs/remotes/origin/{ref_name}"
|
|
812
|
+
|
|
813
|
+
if tag_ref in repo.references:
|
|
814
|
+
repo.checkout(tag_ref)
|
|
815
|
+
elif branch_ref in repo.references:
|
|
816
|
+
# For remote branches, create/update local tracking branch and checkout
|
|
817
|
+
remote_branch_name = f"origin/{ref_name}"
|
|
818
|
+
remote_branch = repo.branches.get(remote_branch_name)
|
|
819
|
+
|
|
820
|
+
if remote_branch is None:
|
|
821
|
+
msg = f"Remote branch {remote_branch_name} not found at {library_path}"
|
|
822
|
+
raise GitRefError(msg)
|
|
823
|
+
|
|
824
|
+
commit = repo.get(remote_branch.target)
|
|
825
|
+
if commit is None:
|
|
826
|
+
msg = f"Failed to get commit for remote branch {remote_branch_name} at {library_path}"
|
|
827
|
+
raise GitRefError(msg)
|
|
828
|
+
|
|
829
|
+
# Create or update local branch
|
|
830
|
+
if ref_name in repo.branches.local:
|
|
831
|
+
local_branch = repo.branches.local[ref_name]
|
|
832
|
+
local_branch.set_target(commit.id)
|
|
833
|
+
else:
|
|
834
|
+
local_branch = repo.branches.local.create(ref_name, commit) # type: ignore[arg-type]
|
|
835
|
+
|
|
836
|
+
local_branch.upstream = remote_branch
|
|
837
|
+
repo.checkout(local_branch)
|
|
838
|
+
elif ref_name in repo.branches:
|
|
839
|
+
# Local branch
|
|
840
|
+
repo.checkout(repo.branches[ref_name])
|
|
841
|
+
else:
|
|
842
|
+
msg = f"Ref {ref_name} not found at {library_path}"
|
|
843
|
+
raise GitRefError(msg)
|
|
844
|
+
|
|
845
|
+
logger.debug("Checked out %s at %s", ref_name, library_path)
|
|
846
|
+
|
|
847
|
+
except pygit2.GitError as e:
|
|
848
|
+
msg = f"Git error during ref switch at {library_path}: {e}"
|
|
849
|
+
raise GitRefError(msg) from e
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _get_ssh_callbacks() -> pygit2.RemoteCallbacks | None:
|
|
853
|
+
"""Get SSH callbacks for pygit2 operations.
|
|
854
|
+
|
|
855
|
+
Tries multiple SSH authentication methods:
|
|
856
|
+
1. SSH agent (KeypairFromAgent) - works if ssh-agent has keys loaded
|
|
857
|
+
2. SSH key files (Keypair) - reads keys directly from common paths
|
|
858
|
+
|
|
859
|
+
Returns:
|
|
860
|
+
pygit2.RemoteCallbacks configured with SSH credentials, or None if no keys found.
|
|
861
|
+
"""
|
|
862
|
+
# First, try to find an SSH key file
|
|
863
|
+
for key_path in _SSH_KEY_PATHS:
|
|
864
|
+
if key_path.exists():
|
|
865
|
+
pub_key_path = key_path.with_suffix(key_path.suffix + ".pub")
|
|
866
|
+
if pub_key_path.exists():
|
|
867
|
+
logger.debug("Using SSH key from %s", key_path)
|
|
868
|
+
credentials = pygit2.Keypair("git", str(pub_key_path), str(key_path), "")
|
|
869
|
+
return pygit2.RemoteCallbacks(credentials=credentials)
|
|
870
|
+
|
|
871
|
+
# Fall back to SSH agent (may work if user has ssh-agent configured)
|
|
872
|
+
logger.debug("No SSH key files found, falling back to SSH agent")
|
|
873
|
+
return pygit2.RemoteCallbacks(credentials=pygit2.KeypairFromAgent("git"))
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _is_git_available() -> bool:
|
|
877
|
+
"""Check if git CLI is available on PATH.
|
|
878
|
+
|
|
879
|
+
Returns:
|
|
880
|
+
bool: True if git CLI is available, False otherwise.
|
|
881
|
+
"""
|
|
882
|
+
try:
|
|
883
|
+
subprocess.run(
|
|
884
|
+
["git", "--version"], # noqa: S607
|
|
885
|
+
capture_output=True,
|
|
886
|
+
check=True,
|
|
887
|
+
)
|
|
888
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
889
|
+
return False
|
|
890
|
+
else:
|
|
891
|
+
return True
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _run_git_command(args: list[str], cwd: str, error_msg: str) -> subprocess.CompletedProcess[str]:
|
|
895
|
+
"""Run a git command and raise GitCloneError on failure.
|
|
896
|
+
|
|
897
|
+
Args:
|
|
898
|
+
args: Git command arguments (e.g., ["git", "init"]).
|
|
899
|
+
cwd: Working directory for the command.
|
|
900
|
+
error_msg: Error message prefix to use if command fails.
|
|
901
|
+
|
|
902
|
+
Returns:
|
|
903
|
+
subprocess.CompletedProcess: The result of the command.
|
|
904
|
+
|
|
905
|
+
Raises:
|
|
906
|
+
GitCloneError: If the command returns a non-zero exit code.
|
|
907
|
+
"""
|
|
908
|
+
result = subprocess.run( # noqa: S603
|
|
909
|
+
args,
|
|
910
|
+
cwd=cwd,
|
|
911
|
+
capture_output=True,
|
|
912
|
+
text=True,
|
|
913
|
+
check=False,
|
|
914
|
+
)
|
|
915
|
+
if result.returncode != 0:
|
|
916
|
+
msg = f"{error_msg}: {result.stderr}"
|
|
917
|
+
raise GitCloneError(msg)
|
|
918
|
+
|
|
919
|
+
return result
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _checkout_branch_tag_or_commit(repo: pygit2.Repository, ref: str) -> None:
|
|
923
|
+
"""Check out a branch, tag, or commit in a repository.
|
|
924
|
+
|
|
925
|
+
For branches, creates a local tracking branch from the remote.
|
|
926
|
+
For tags and commits, checks out in detached HEAD state.
|
|
927
|
+
|
|
928
|
+
Args:
|
|
929
|
+
repo: The pygit2 Repository object.
|
|
930
|
+
ref: The branch, tag, or commit reference to checkout.
|
|
931
|
+
|
|
932
|
+
Raises:
|
|
933
|
+
GitCloneError: If checkout fails.
|
|
934
|
+
"""
|
|
935
|
+
# Try to resolve as a local branch first
|
|
936
|
+
try:
|
|
937
|
+
branch = repo.branches[ref]
|
|
938
|
+
repo.checkout(branch)
|
|
939
|
+
except (pygit2.GitError, KeyError, IndexError):
|
|
940
|
+
pass
|
|
941
|
+
else:
|
|
942
|
+
logger.debug("Checked out local branch %s", ref)
|
|
943
|
+
return
|
|
944
|
+
|
|
945
|
+
# Try to resolve as a remote branch and create local tracking branch
|
|
946
|
+
remote_ref = f"refs/remotes/origin/{ref}"
|
|
947
|
+
remote_branch_exists = remote_ref in repo.references
|
|
948
|
+
|
|
949
|
+
if remote_branch_exists:
|
|
950
|
+
remote_branch_name = f"origin/{ref}"
|
|
951
|
+
remote_branch = repo.branches.get(remote_branch_name)
|
|
952
|
+
|
|
953
|
+
if remote_branch is not None:
|
|
954
|
+
commit = repo.get(remote_branch.target)
|
|
955
|
+
if commit is None:
|
|
956
|
+
msg = f"Failed to get commit for remote branch {remote_branch_name}"
|
|
957
|
+
raise GitCloneError(msg)
|
|
958
|
+
|
|
959
|
+
# Create local tracking branch
|
|
960
|
+
local_branch = repo.branches.local.create(ref, commit) # type: ignore[arg-type]
|
|
961
|
+
local_branch.upstream = remote_branch
|
|
962
|
+
repo.checkout(local_branch)
|
|
963
|
+
logger.debug("Checked out remote branch %s as local tracking branch", ref)
|
|
964
|
+
return
|
|
965
|
+
|
|
966
|
+
# Not a local or remote branch, try as tag or commit
|
|
967
|
+
try:
|
|
968
|
+
commit_obj = repo.revparse_single(ref)
|
|
969
|
+
repo.checkout_tree(commit_obj)
|
|
970
|
+
repo.set_head(commit_obj.id)
|
|
971
|
+
logger.debug("Checked out %s as tag or commit", ref)
|
|
972
|
+
except pygit2.GitError as e:
|
|
973
|
+
msg = f"Failed to checkout {ref}: {e}"
|
|
974
|
+
raise GitCloneError(msg) from e
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def clone_repository(git_url: str, target_path: Path, branch_tag_commit: str | None = None) -> None:
|
|
978
|
+
"""Clone a git repository to a target directory.
|
|
979
|
+
|
|
980
|
+
Args:
|
|
981
|
+
git_url: The git repository URL to clone (HTTPS or SSH).
|
|
982
|
+
target_path: The target directory path to clone into.
|
|
983
|
+
branch_tag_commit: Optional branch, tag, or commit to checkout after cloning.
|
|
984
|
+
|
|
985
|
+
Raises:
|
|
986
|
+
GitCloneError: If cloning fails or target path already exists.
|
|
987
|
+
"""
|
|
988
|
+
if target_path.exists():
|
|
989
|
+
msg = f"Cannot clone: target path {target_path} already exists"
|
|
990
|
+
raise GitCloneError(msg)
|
|
991
|
+
|
|
992
|
+
# Use SSH callbacks for SSH URLs
|
|
993
|
+
callbacks = None
|
|
994
|
+
if git_url.startswith(("git@", "ssh://")):
|
|
995
|
+
callbacks = _get_ssh_callbacks()
|
|
996
|
+
|
|
997
|
+
try:
|
|
998
|
+
# Clone the repository
|
|
999
|
+
repo = pygit2.clone_repository(git_url, str(target_path), callbacks=callbacks)
|
|
1000
|
+
if repo is None:
|
|
1001
|
+
msg = f"Failed to clone repository from {git_url}"
|
|
1002
|
+
raise GitCloneError(msg)
|
|
1003
|
+
|
|
1004
|
+
# Checkout specific branch/tag/commit if provided
|
|
1005
|
+
if branch_tag_commit:
|
|
1006
|
+
_checkout_branch_tag_or_commit(repo, branch_tag_commit)
|
|
1007
|
+
|
|
1008
|
+
except pygit2.GitError as e:
|
|
1009
|
+
msg = f"Git error while cloning {git_url} to {target_path}: {e}"
|
|
1010
|
+
raise GitCloneError(msg) from e
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _extract_library_version_from_json(json_path: Path, remote_url: str) -> str:
|
|
1014
|
+
"""Extract library version from a griptape_nodes_library.json file.
|
|
1015
|
+
|
|
1016
|
+
Args:
|
|
1017
|
+
json_path: Path to the library JSON file.
|
|
1018
|
+
remote_url: Git remote URL (for error messages).
|
|
1019
|
+
|
|
1020
|
+
Returns:
|
|
1021
|
+
str: The library version string.
|
|
1022
|
+
|
|
1023
|
+
Raises:
|
|
1024
|
+
GitCloneError: If JSON is invalid or version is missing.
|
|
1025
|
+
"""
|
|
1026
|
+
import json
|
|
1027
|
+
|
|
1028
|
+
try:
|
|
1029
|
+
with json_path.open() as f:
|
|
1030
|
+
library_data = json.load(f)
|
|
1031
|
+
except json.JSONDecodeError as e:
|
|
1032
|
+
msg = f"JSON decode error reading library metadata from {remote_url}: {e}"
|
|
1033
|
+
raise GitCloneError(msg) from e
|
|
1034
|
+
|
|
1035
|
+
if "metadata" not in library_data:
|
|
1036
|
+
msg = f"No metadata found in griptape_nodes_library.json from {remote_url}"
|
|
1037
|
+
raise GitCloneError(msg)
|
|
1038
|
+
|
|
1039
|
+
if "library_version" not in library_data["metadata"]:
|
|
1040
|
+
msg = f"No library_version found in metadata from {remote_url}"
|
|
1041
|
+
raise GitCloneError(msg)
|
|
1042
|
+
|
|
1043
|
+
return library_data["metadata"]["library_version"]
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _sparse_checkout_with_git_cli(remote_url: str, ref: str) -> tuple[str, str, dict]:
|
|
1047
|
+
"""Perform sparse checkout using git CLI to fetch only library JSON file.
|
|
1048
|
+
|
|
1049
|
+
This is the most efficient method as it only downloads files matching the sparse
|
|
1050
|
+
checkout patterns, not the entire repository.
|
|
1051
|
+
|
|
1052
|
+
Args:
|
|
1053
|
+
remote_url: The git repository URL (HTTPS or SSH).
|
|
1054
|
+
ref: The git reference (branch, tag, or commit) to checkout.
|
|
1055
|
+
|
|
1056
|
+
Returns:
|
|
1057
|
+
tuple[str, str, dict]: A tuple of (library_version, commit_sha, library_data).
|
|
1058
|
+
|
|
1059
|
+
Raises:
|
|
1060
|
+
GitCloneError: If sparse checkout fails or library metadata is invalid.
|
|
1061
|
+
"""
|
|
1062
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
1063
|
+
temp_path = Path(temp_dir)
|
|
1064
|
+
|
|
1065
|
+
try:
|
|
1066
|
+
# Initialize empty git repository
|
|
1067
|
+
_run_git_command(["git", "init"], temp_dir, "Git init failed")
|
|
1068
|
+
|
|
1069
|
+
# Add remote
|
|
1070
|
+
_run_git_command(
|
|
1071
|
+
["git", "remote", "add", "origin", remote_url],
|
|
1072
|
+
temp_dir,
|
|
1073
|
+
"Git remote add failed",
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
# Enable sparse checkout
|
|
1077
|
+
_run_git_command(
|
|
1078
|
+
["git", "config", "core.sparseCheckout", "true"],
|
|
1079
|
+
temp_dir,
|
|
1080
|
+
"Git sparse checkout config failed",
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
# Configure sparse-checkout patterns
|
|
1084
|
+
sparse_checkout_file = temp_path / ".git" / "info" / "sparse-checkout"
|
|
1085
|
+
sparse_checkout_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1086
|
+
patterns = [
|
|
1087
|
+
"griptape_nodes_library.json",
|
|
1088
|
+
"*/griptape_nodes_library.json",
|
|
1089
|
+
"*/*/griptape_nodes_library.json",
|
|
1090
|
+
"griptape-nodes-library.json",
|
|
1091
|
+
"*/griptape-nodes-library.json",
|
|
1092
|
+
"*/*/griptape-nodes-library.json",
|
|
1093
|
+
]
|
|
1094
|
+
sparse_checkout_file.write_text("\n".join(patterns))
|
|
1095
|
+
|
|
1096
|
+
# Fetch with depth 1 (shallow clone)
|
|
1097
|
+
_run_git_command(
|
|
1098
|
+
["git", "fetch", "--depth=1", "origin", ref],
|
|
1099
|
+
temp_dir,
|
|
1100
|
+
f"Git fetch failed for {ref}",
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
# Checkout the ref
|
|
1104
|
+
_run_git_command(["git", "checkout", "FETCH_HEAD"], temp_dir, "Git checkout failed")
|
|
1105
|
+
|
|
1106
|
+
# Find the library JSON file
|
|
1107
|
+
library_json_path = find_file_in_directory(temp_path, "griptape[-_]nodes[-_]library.json")
|
|
1108
|
+
if library_json_path is None:
|
|
1109
|
+
msg = f"No library JSON file found in sparse checkout from {remote_url}"
|
|
1110
|
+
raise GitCloneError(msg)
|
|
1111
|
+
|
|
1112
|
+
# Extract version from JSON
|
|
1113
|
+
library_version = _extract_library_version_from_json(library_json_path, remote_url)
|
|
1114
|
+
|
|
1115
|
+
# Get commit SHA
|
|
1116
|
+
rev_parse_result = _run_git_command(
|
|
1117
|
+
["git", "rev-parse", "HEAD"],
|
|
1118
|
+
temp_dir,
|
|
1119
|
+
"Git rev-parse failed",
|
|
1120
|
+
)
|
|
1121
|
+
commit_sha = rev_parse_result.stdout.strip()
|
|
1122
|
+
|
|
1123
|
+
# Read the JSON data before temp directory is deleted
|
|
1124
|
+
try:
|
|
1125
|
+
with library_json_path.open() as f:
|
|
1126
|
+
library_data = json.load(f)
|
|
1127
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
1128
|
+
msg = f"Failed to read library file from {remote_url}: {e}"
|
|
1129
|
+
raise GitCloneError(msg) from e
|
|
1130
|
+
|
|
1131
|
+
except subprocess.SubprocessError as e:
|
|
1132
|
+
msg = f"Subprocess error during sparse checkout from {remote_url}: {e}"
|
|
1133
|
+
raise GitCloneError(msg) from e
|
|
1134
|
+
|
|
1135
|
+
return (library_version, commit_sha, library_data)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _shallow_clone_with_pygit2(remote_url: str, ref: str) -> tuple[str, str, dict]:
|
|
1139
|
+
"""Perform shallow clone using pygit2 to fetch library JSON file.
|
|
1140
|
+
|
|
1141
|
+
This is a fallback method when git CLI is not available. It downloads all files
|
|
1142
|
+
but with limited history (depth=1).
|
|
1143
|
+
|
|
1144
|
+
Args:
|
|
1145
|
+
remote_url: The git repository URL (HTTPS or SSH).
|
|
1146
|
+
ref: The git reference (branch, tag, or commit) to checkout.
|
|
1147
|
+
|
|
1148
|
+
Returns:
|
|
1149
|
+
tuple[str, str, dict]: A tuple of (library_version, commit_sha, library_data).
|
|
1150
|
+
|
|
1151
|
+
Raises:
|
|
1152
|
+
GitCloneError: If clone fails or library metadata is invalid.
|
|
1153
|
+
"""
|
|
1154
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
1155
|
+
temp_path = Path(temp_dir)
|
|
1156
|
+
|
|
1157
|
+
try:
|
|
1158
|
+
# Use SSH callbacks for SSH URLs
|
|
1159
|
+
callbacks = None
|
|
1160
|
+
if remote_url.startswith(("git@", "ssh://")):
|
|
1161
|
+
callbacks = _get_ssh_callbacks()
|
|
1162
|
+
|
|
1163
|
+
# Shallow clone with depth=1
|
|
1164
|
+
checkout_branch = ref if ref != "HEAD" else None
|
|
1165
|
+
repo = pygit2.clone_repository(
|
|
1166
|
+
remote_url,
|
|
1167
|
+
str(temp_path),
|
|
1168
|
+
callbacks=callbacks,
|
|
1169
|
+
depth=1,
|
|
1170
|
+
checkout_branch=checkout_branch,
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
if repo is None:
|
|
1174
|
+
msg = f"Failed to clone repository from {remote_url}"
|
|
1175
|
+
raise GitCloneError(msg)
|
|
1176
|
+
|
|
1177
|
+
# Find the library JSON file
|
|
1178
|
+
library_json_path = find_file_in_directory(temp_path, "griptape[-_]nodes[-_]library.json")
|
|
1179
|
+
if library_json_path is None:
|
|
1180
|
+
msg = f"No library JSON file found in clone from {remote_url}"
|
|
1181
|
+
raise GitCloneError(msg)
|
|
1182
|
+
|
|
1183
|
+
# Extract version from JSON
|
|
1184
|
+
library_version = _extract_library_version_from_json(library_json_path, remote_url)
|
|
1185
|
+
|
|
1186
|
+
# Get commit SHA
|
|
1187
|
+
commit_sha = str(repo.head.target)
|
|
1188
|
+
|
|
1189
|
+
# Read the JSON data before temp directory is deleted
|
|
1190
|
+
try:
|
|
1191
|
+
with library_json_path.open() as f:
|
|
1192
|
+
library_data = json.load(f)
|
|
1193
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
1194
|
+
msg = f"Failed to read library file from {remote_url}: {e}"
|
|
1195
|
+
raise GitCloneError(msg) from e
|
|
1196
|
+
|
|
1197
|
+
except pygit2.GitError as e:
|
|
1198
|
+
msg = f"Git error during clone from {remote_url}: {e}"
|
|
1199
|
+
raise GitCloneError(msg) from e
|
|
1200
|
+
|
|
1201
|
+
return (library_version, commit_sha, library_data)
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def sparse_checkout_library_json(remote_url: str, ref: str = "HEAD") -> tuple[str, str, dict]:
|
|
1205
|
+
"""Fetch library JSON file from a git repository.
|
|
1206
|
+
|
|
1207
|
+
This function uses the most efficient method available:
|
|
1208
|
+
- If git CLI is available: uses sparse checkout (only downloads needed files)
|
|
1209
|
+
- Otherwise: falls back to pygit2 shallow clone (downloads all files with depth=1)
|
|
1210
|
+
|
|
1211
|
+
Args:
|
|
1212
|
+
remote_url: The git repository URL (HTTPS or SSH).
|
|
1213
|
+
ref: The git reference (branch, tag, or commit) to checkout. Defaults to HEAD.
|
|
1214
|
+
|
|
1215
|
+
Returns:
|
|
1216
|
+
tuple[str, str, dict]: A tuple of (library_version, commit_sha, library_data).
|
|
1217
|
+
|
|
1218
|
+
Raises:
|
|
1219
|
+
GitCloneError: If the operation fails or library metadata is invalid.
|
|
1220
|
+
"""
|
|
1221
|
+
if _is_git_available():
|
|
1222
|
+
logger.debug("Using git CLI for sparse checkout from %s", remote_url)
|
|
1223
|
+
return _sparse_checkout_with_git_cli(remote_url, ref)
|
|
1224
|
+
|
|
1225
|
+
logger.debug("Git CLI not available, using pygit2 shallow clone from %s", remote_url)
|
|
1226
|
+
return _shallow_clone_with_pygit2(remote_url, ref)
|