griptape-nodes 0.64.11__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.
Files changed (55) hide show
  1. griptape_nodes/app/app.py +25 -5
  2. griptape_nodes/cli/commands/init.py +65 -54
  3. griptape_nodes/cli/commands/libraries.py +92 -85
  4. griptape_nodes/cli/commands/self.py +121 -0
  5. griptape_nodes/common/node_executor.py +2142 -101
  6. griptape_nodes/exe_types/base_iterative_nodes.py +1004 -0
  7. griptape_nodes/exe_types/connections.py +114 -19
  8. griptape_nodes/exe_types/core_types.py +225 -7
  9. griptape_nodes/exe_types/flow.py +3 -3
  10. griptape_nodes/exe_types/node_types.py +681 -225
  11. griptape_nodes/exe_types/param_components/README.md +414 -0
  12. griptape_nodes/exe_types/param_components/api_key_provider_parameter.py +200 -0
  13. griptape_nodes/exe_types/param_components/huggingface/huggingface_model_parameter.py +2 -0
  14. griptape_nodes/exe_types/param_components/huggingface/huggingface_repo_file_parameter.py +79 -5
  15. griptape_nodes/exe_types/param_types/parameter_button.py +443 -0
  16. griptape_nodes/machines/control_flow.py +77 -38
  17. griptape_nodes/machines/dag_builder.py +148 -70
  18. griptape_nodes/machines/parallel_resolution.py +61 -35
  19. griptape_nodes/machines/sequential_resolution.py +11 -113
  20. griptape_nodes/retained_mode/events/app_events.py +1 -0
  21. griptape_nodes/retained_mode/events/base_events.py +16 -13
  22. griptape_nodes/retained_mode/events/connection_events.py +3 -0
  23. griptape_nodes/retained_mode/events/execution_events.py +35 -0
  24. griptape_nodes/retained_mode/events/flow_events.py +15 -2
  25. griptape_nodes/retained_mode/events/library_events.py +347 -0
  26. griptape_nodes/retained_mode/events/node_events.py +48 -0
  27. griptape_nodes/retained_mode/events/os_events.py +86 -3
  28. griptape_nodes/retained_mode/events/project_events.py +15 -1
  29. griptape_nodes/retained_mode/events/workflow_events.py +48 -1
  30. griptape_nodes/retained_mode/griptape_nodes.py +6 -2
  31. griptape_nodes/retained_mode/managers/config_manager.py +10 -8
  32. griptape_nodes/retained_mode/managers/event_manager.py +168 -0
  33. griptape_nodes/retained_mode/managers/fitness_problems/libraries/__init__.py +2 -0
  34. griptape_nodes/retained_mode/managers/fitness_problems/libraries/old_xdg_location_warning_problem.py +43 -0
  35. griptape_nodes/retained_mode/managers/flow_manager.py +664 -123
  36. griptape_nodes/retained_mode/managers/library_manager.py +1134 -138
  37. griptape_nodes/retained_mode/managers/model_manager.py +2 -3
  38. griptape_nodes/retained_mode/managers/node_manager.py +148 -25
  39. griptape_nodes/retained_mode/managers/object_manager.py +3 -1
  40. griptape_nodes/retained_mode/managers/operation_manager.py +3 -1
  41. griptape_nodes/retained_mode/managers/os_manager.py +1158 -122
  42. griptape_nodes/retained_mode/managers/secrets_manager.py +2 -3
  43. griptape_nodes/retained_mode/managers/settings.py +21 -1
  44. griptape_nodes/retained_mode/managers/sync_manager.py +2 -3
  45. griptape_nodes/retained_mode/managers/workflow_manager.py +358 -104
  46. griptape_nodes/retained_mode/retained_mode.py +3 -3
  47. griptape_nodes/traits/button.py +44 -2
  48. griptape_nodes/traits/file_system_picker.py +2 -2
  49. griptape_nodes/utils/file_utils.py +101 -0
  50. griptape_nodes/utils/git_utils.py +1226 -0
  51. griptape_nodes/utils/library_utils.py +122 -0
  52. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.0.dist-info}/METADATA +2 -1
  53. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.0.dist-info}/RECORD +55 -47
  54. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.0.dist-info}/WHEEL +1 -1
  55. {griptape_nodes-0.64.11.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)