griptape-nodes 0.64.11__py3-none-any.whl → 0.65.1__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 +84 -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 +1142 -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 +1236 -0
  51. griptape_nodes/utils/library_utils.py +122 -0
  52. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.1.dist-info}/METADATA +2 -1
  53. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.1.dist-info}/RECORD +55 -47
  54. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.1.dist-info}/WHEEL +1 -1
  55. {griptape_nodes-0.64.11.dist-info → griptape_nodes-0.65.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1236 @@
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 unborn (no commits yet)
297
+ if repo.head_is_unborn:
298
+ logger.debug("Repository at %s has unborn HEAD (no commits)", library_path)
299
+ return None
300
+
301
+ # Check if HEAD is detached
302
+ if repo.head_is_detached:
303
+ # HEAD is detached - check if it's pointing to a tag
304
+ tag_name = get_current_tag(library_path)
305
+ if tag_name:
306
+ logger.debug("Repository at %s has detached HEAD on tag %s", library_path, tag_name)
307
+ return tag_name
308
+
309
+ # No tag found, return the commit SHA as fallback
310
+ head_commit = repo.head.target
311
+ logger.debug("Repository at %s has detached HEAD at commit %s", library_path, head_commit)
312
+ return str(head_commit)
313
+
314
+ except pygit2.GitError as e:
315
+ msg = f"Error getting current git reference for {library_path}: {e}"
316
+ raise GitRefError(msg) from e
317
+ else:
318
+ # Get the current git reference name (branch)
319
+ return repo.head.shorthand
320
+
321
+
322
+ def get_current_tag(library_path: Path) -> str | None:
323
+ """Get the current tag name if HEAD is pointing to a tag.
324
+
325
+ Args:
326
+ library_path: The path to the library directory.
327
+
328
+ Returns:
329
+ str | None: The current tag name if found, None if not on a tag or not a git repository.
330
+
331
+ Raises:
332
+ GitError: If an error occurs while getting the current tag.
333
+ """
334
+ if not is_git_repository(library_path):
335
+ return None
336
+
337
+ try:
338
+ repo_path = pygit2.discover_repository(str(library_path))
339
+ if repo_path is None:
340
+ return None
341
+
342
+ repo = pygit2.Repository(repo_path)
343
+
344
+ # Get the current HEAD commit
345
+ if repo.head_is_unborn:
346
+ return None
347
+
348
+ head_commit = repo.head.target
349
+
350
+ # Check all tags to see if any point to HEAD
351
+ for tag_name in repo.references:
352
+ if not tag_name.startswith("refs/tags/"):
353
+ continue
354
+
355
+ tag_ref = repo.references[tag_name]
356
+ # Handle both lightweight and annotated tags
357
+ if hasattr(tag_ref, "peel"):
358
+ tag_target = tag_ref.peel(pygit2.Commit).id
359
+ else:
360
+ tag_target = tag_ref.target
361
+
362
+ if tag_target == head_commit:
363
+ # Return tag name without refs/tags/ prefix
364
+ return tag_name.replace("refs/tags/", "")
365
+ except pygit2.GitError as e:
366
+ msg = f"Error getting current tag for {library_path}: {e}"
367
+ raise GitError(msg) from e
368
+ else:
369
+ return None
370
+
371
+
372
+ def is_on_tag(library_path: Path) -> bool:
373
+ """Check if HEAD is currently pointing to a tag.
374
+
375
+ Args:
376
+ library_path: The path to the library directory.
377
+
378
+ Returns:
379
+ bool: True if HEAD is on a tag, False otherwise.
380
+ """
381
+ return get_current_tag(library_path) is not None
382
+
383
+
384
+ def get_local_commit_sha(library_path: Path) -> str | None:
385
+ """Get the current HEAD commit SHA for a library directory.
386
+
387
+ Args:
388
+ library_path: The path to the library directory.
389
+
390
+ Returns:
391
+ str | None: The full commit SHA if found, None if not a git repository or error occurs.
392
+
393
+ Raises:
394
+ GitError: If an error occurs while getting the commit SHA.
395
+ """
396
+ if not is_git_repository(library_path):
397
+ return None
398
+
399
+ try:
400
+ repo_path = pygit2.discover_repository(str(library_path))
401
+ if repo_path is None:
402
+ return None
403
+
404
+ repo = pygit2.Repository(repo_path)
405
+
406
+ if repo.head_is_unborn:
407
+ return None
408
+
409
+ return str(repo.head.target)
410
+
411
+ except pygit2.GitError as e:
412
+ msg = f"Error getting commit SHA for {library_path}: {e}"
413
+ raise GitError(msg) from e
414
+
415
+
416
+ def get_git_repository_root(library_path: Path) -> Path | None:
417
+ """Get the root directory of the git repository containing the given path.
418
+
419
+ Args:
420
+ library_path: A path within a git repository.
421
+
422
+ Returns:
423
+ Path | None: The root directory of the git repository, or None if not in a git repository.
424
+
425
+ Raises:
426
+ GitRepositoryError: If an error occurs while accessing the git repository.
427
+ """
428
+ if not is_git_repository(library_path):
429
+ return None
430
+
431
+ try:
432
+ repo_path = pygit2.discover_repository(str(library_path))
433
+ if repo_path is None:
434
+ return None
435
+
436
+ # discover_repository returns path to .git directory
437
+ # For a normal repo: /path/to/repo/.git
438
+ # For a bare repo: /path/to/repo.git
439
+ git_dir = Path(repo_path)
440
+
441
+ # Check if it's a bare repository
442
+ if git_dir.name.endswith(".git") and git_dir.is_dir():
443
+ repo = pygit2.Repository(repo_path)
444
+ if repo.is_bare:
445
+ return git_dir
446
+
447
+ except pygit2.GitError as e:
448
+ msg = f"Error getting git repository root for {library_path}: {e}"
449
+ raise GitRepositoryError(msg) from e
450
+ else:
451
+ # Normal repository - return parent of .git directory
452
+ return git_dir.parent
453
+
454
+
455
+ def has_uncommitted_changes(library_path: Path) -> bool:
456
+ """Check if a repository has uncommitted changes (including untracked files).
457
+
458
+ Args:
459
+ library_path: The path to the library directory.
460
+
461
+ Returns:
462
+ True if there are uncommitted changes or untracked files, False otherwise.
463
+
464
+ Raises:
465
+ GitRepositoryError: If the path is not a valid git repository.
466
+ """
467
+ if not is_git_repository(library_path):
468
+ msg = f"Cannot check status: {library_path} is not a git repository"
469
+ raise GitRepositoryError(msg)
470
+
471
+ try:
472
+ repo_path = pygit2.discover_repository(str(library_path))
473
+ if repo_path is None:
474
+ msg = f"Cannot check status: {library_path} is not a git repository"
475
+ raise GitRepositoryError(msg)
476
+
477
+ repo = pygit2.Repository(repo_path)
478
+ status = repo.status()
479
+ return len(status) > 0
480
+
481
+ except pygit2.GitError as e:
482
+ msg = f"Failed to check git status at {library_path}: {e}"
483
+ raise GitRepositoryError(msg) from e
484
+
485
+
486
+ def _validate_branch_update_preconditions(library_path: Path) -> None:
487
+ """Validate preconditions for branch-based update.
488
+
489
+ Raises:
490
+ GitRepositoryError: If validation fails.
491
+ GitPullError: If repository state is invalid for update.
492
+ """
493
+ if not is_git_repository(library_path):
494
+ msg = f"Cannot update: {library_path} is not a git repository"
495
+ raise GitRepositoryError(msg)
496
+
497
+ try:
498
+ repo_path = pygit2.discover_repository(str(library_path))
499
+ if repo_path is None:
500
+ msg = f"Cannot discover repository at {library_path}"
501
+ raise GitRepositoryError(msg)
502
+
503
+ repo = pygit2.Repository(repo_path)
504
+
505
+ if repo.head_is_detached:
506
+ msg = f"Repository at {library_path} has detached HEAD"
507
+ raise GitPullError(msg)
508
+
509
+ current_branch = repo.branches.get(repo.head.shorthand)
510
+ if current_branch is None:
511
+ msg = f"Cannot get current branch for repository at {library_path}"
512
+ raise GitPullError(msg)
513
+
514
+ if current_branch.upstream is None:
515
+ msg = f"No upstream branch set for {current_branch.branch_name} at {library_path}"
516
+ raise GitPullError(msg)
517
+
518
+ try:
519
+ _ = repo.remotes["origin"]
520
+ except (KeyError, IndexError) as e:
521
+ msg = f"No origin remote found for repository at {library_path}"
522
+ raise GitPullError(msg) from e
523
+
524
+ except pygit2.GitError as e:
525
+ msg = f"Git error during update at {library_path}: {e}"
526
+ raise GitPullError(msg) from e
527
+
528
+
529
+ def git_update_from_remote(library_path: Path, *, overwrite_existing: bool = False) -> None:
530
+ """Update a library from remote by resetting to match upstream exactly.
531
+
532
+ This function uses git fetch + git reset --hard to force the local repository
533
+ to match the remote state. This is appropriate for library consumption where
534
+ local modifications should not be preserved.
535
+
536
+ Args:
537
+ library_path: The path to the library directory.
538
+ overwrite_existing: If True, discard any uncommitted local changes.
539
+ If False, fail if uncommitted changes exist.
540
+
541
+ Raises:
542
+ GitRepositoryError: If the path is not a valid git repository.
543
+ GitPullError: If the update operation fails or uncommitted changes exist
544
+ when overwrite_existing=False.
545
+ """
546
+ _validate_branch_update_preconditions(library_path)
547
+
548
+ if has_uncommitted_changes(library_path):
549
+ if not overwrite_existing:
550
+ msg = f"Cannot update library at {library_path}: You have uncommitted changes. Use overwrite_existing=True to discard them."
551
+ raise GitPullError(msg)
552
+
553
+ logger.warning("Discarding uncommitted changes at %s", library_path)
554
+
555
+ try:
556
+ repo_path = pygit2.discover_repository(str(library_path))
557
+ if repo_path is None:
558
+ msg = f"Cannot update: {library_path} is not a git repository"
559
+ raise GitPullError(msg)
560
+
561
+ repo = pygit2.Repository(repo_path)
562
+
563
+ # Get remote and fetch
564
+ remote = repo.remotes["origin"]
565
+ remote.fetch()
566
+
567
+ # Get upstream branch reference
568
+ try:
569
+ upstream_name = repo.branches.get(repo.head.shorthand).upstream.branch_name
570
+ upstream_ref = repo.references.get(f"refs/remotes/{upstream_name}")
571
+ if upstream_ref is None:
572
+ msg = f"Failed to find upstream reference {upstream_name} at {library_path}"
573
+ raise GitPullError(msg)
574
+ upstream_oid = upstream_ref.target
575
+ except (pygit2.GitError, AttributeError) as e:
576
+ msg = f"Failed to determine upstream branch at {library_path}: {e}"
577
+ raise GitPullError(msg) from e
578
+
579
+ # Hard reset to upstream
580
+ repo.reset(upstream_oid, pygit2.enums.ResetMode.HARD)
581
+
582
+ logger.debug("Successfully updated library at %s to match remote %s", library_path, upstream_name)
583
+
584
+ except pygit2.GitError as e:
585
+ msg = f"Git error during update at {library_path}: {e}"
586
+ raise GitPullError(msg) from e
587
+
588
+
589
+ def update_to_moving_tag(library_path: Path, tag_name: str, *, overwrite_existing: bool = False) -> None:
590
+ """Update library to the latest version of a moving tag.
591
+
592
+ This function is designed for tags that are force-pushed to point to new commits
593
+ (e.g., a 'latest' tag that always points to the newest release).
594
+
595
+ Args:
596
+ library_path: The path to the library directory.
597
+ tag_name: The name of the tag to update to (e.g., "latest").
598
+ overwrite_existing: If True, discard any uncommitted local changes.
599
+ If False, fail if uncommitted changes exist.
600
+
601
+ Raises:
602
+ GitRepositoryError: If the path is not a valid git repository.
603
+ GitPullError: If the tag update operation fails or uncommitted changes exist
604
+ when overwrite_existing=False.
605
+ """
606
+ if not is_git_repository(library_path):
607
+ msg = f"Cannot update tag: {library_path} is not a git repository"
608
+ raise GitRepositoryError(msg)
609
+
610
+ try:
611
+ repo_path = pygit2.discover_repository(str(library_path))
612
+ if repo_path is None:
613
+ msg = f"Cannot discover repository at {library_path}"
614
+ raise GitRepositoryError(msg)
615
+
616
+ repo = pygit2.Repository(repo_path)
617
+
618
+ # Check for origin remote
619
+ try:
620
+ _ = repo.remotes["origin"]
621
+ except (KeyError, IndexError) as e:
622
+ msg = f"No origin remote found for repository at {library_path}"
623
+ raise GitPullError(msg) from e
624
+
625
+ except pygit2.GitError as e:
626
+ msg = f"Git error during tag update at {library_path}: {e}"
627
+ raise GitPullError(msg) from e
628
+
629
+ # Check for uncommitted changes
630
+ if has_uncommitted_changes(library_path):
631
+ if not overwrite_existing:
632
+ msg = f"Cannot update library at {library_path}: You have uncommitted changes. Use overwrite_existing=True to discard them."
633
+ raise GitPullError(msg)
634
+
635
+ logger.warning("Discarding uncommitted changes at %s", library_path)
636
+
637
+ # Use pygit2 to fetch tags and checkout
638
+ try:
639
+ # Step 1: Delete local tag to allow fetch to update it (pygit2 doesn't honor +force)
640
+ tag_ref = f"refs/tags/{tag_name}"
641
+ if tag_ref in repo.references:
642
+ repo.references.delete(tag_ref)
643
+ logger.debug("Deleted local tag %s to allow force-update", tag_name)
644
+
645
+ # Step 2: Fetch all tags (will create the deleted tag with new commit)
646
+ remote = repo.remotes["origin"]
647
+ remote.fetch(refspecs=["+refs/tags/*:refs/tags/*"])
648
+
649
+ # Step 3: Checkout the tag with force to discard local changes
650
+ if tag_ref not in repo.references:
651
+ msg = f"Tag {tag_name} not found at {library_path}"
652
+ raise GitPullError(msg)
653
+
654
+ strategy = pygit2.enums.CheckoutStrategy.FORCE if overwrite_existing else pygit2.enums.CheckoutStrategy.SAFE
655
+ repo.checkout(tag_ref, strategy=strategy)
656
+
657
+ logger.debug("Successfully updated library at %s to tag %s", library_path, tag_name)
658
+
659
+ except pygit2.GitError as e:
660
+ msg = f"Git error during tag update at {library_path}: {e}"
661
+ raise GitPullError(msg) from e
662
+
663
+
664
+ def update_library_git(library_path: Path, *, overwrite_existing: bool = False) -> None:
665
+ """Update a library to the latest version using the appropriate git strategy.
666
+
667
+ This function automatically detects whether the library uses a branch-based or
668
+ tag-based workflow and applies the correct update mechanism:
669
+ - Branch-based: Uses git fetch + git reset --hard
670
+ - Tag-based: Uses git fetch --tags --force + git checkout
671
+
672
+ Args:
673
+ library_path: The path to the library directory.
674
+ overwrite_existing: If True, discard any uncommitted local changes.
675
+ If False, fail if uncommitted changes exist.
676
+
677
+ Raises:
678
+ GitRepositoryError: If the path is not a valid git repository.
679
+ GitPullError: If the update operation fails or uncommitted changes exist
680
+ when overwrite_existing=False.
681
+ """
682
+ if not is_git_repository(library_path):
683
+ msg = f"Cannot update: {library_path} is not a git repository"
684
+ raise GitRepositoryError(msg)
685
+
686
+ try:
687
+ repo_path = pygit2.discover_repository(str(library_path))
688
+ if repo_path is None:
689
+ msg = f"Cannot discover repository at {library_path}"
690
+ raise GitRepositoryError(msg)
691
+
692
+ repo = pygit2.Repository(repo_path)
693
+
694
+ # Detect workflow type
695
+ if repo.head_is_detached:
696
+ # Detached HEAD - likely on a tag
697
+ tag_name = get_current_tag(library_path)
698
+ if tag_name is None:
699
+ msg = f"Repository at {library_path} is in detached HEAD state but not on a known tag. Cannot auto-update."
700
+ raise GitPullError(msg)
701
+
702
+ logger.debug("Detected tag-based workflow for %s (tag: %s)", library_path, tag_name)
703
+ update_to_moving_tag(library_path, tag_name, overwrite_existing=overwrite_existing)
704
+ else:
705
+ # On a branch - use fetch + reset to match remote
706
+ logger.debug("Detected branch-based workflow for %s", library_path)
707
+ git_update_from_remote(library_path, overwrite_existing=overwrite_existing)
708
+
709
+ except pygit2.GitError as e:
710
+ msg = f"Git error during library update at {library_path}: {e}"
711
+ raise GitPullError(msg) from e
712
+
713
+
714
+ def switch_branch(library_path: Path, branch_name: str) -> None:
715
+ """Switch to a different branch in a library directory.
716
+
717
+ Fetches from remote first, then checks out the specified branch.
718
+ If the branch doesn't exist locally, creates a tracking branch from remote.
719
+
720
+ Args:
721
+ library_path: The path to the library directory.
722
+ branch_name: The name of the branch to switch to.
723
+
724
+ Raises:
725
+ GitRepositoryError: If the path is not a valid git repository.
726
+ GitRefError: If the branch switch operation fails.
727
+ """
728
+ if not is_git_repository(library_path):
729
+ msg = f"Cannot switch branch: {library_path} is not a git repository"
730
+ raise GitRepositoryError(msg)
731
+
732
+ try:
733
+ repo_path = pygit2.discover_repository(str(library_path))
734
+ if repo_path is None:
735
+ msg = f"Cannot discover repository at {library_path}"
736
+ raise GitRepositoryError(msg)
737
+
738
+ repo = pygit2.Repository(repo_path)
739
+
740
+ # Get origin remote
741
+ try:
742
+ remote = repo.remotes["origin"]
743
+ except (KeyError, IndexError) as e:
744
+ msg = f"No origin remote found for repository at {library_path}"
745
+ raise GitRefError(msg) from e
746
+
747
+ # Fetch from remote first
748
+ remote.fetch()
749
+
750
+ # Try to find the branch locally first
751
+ local_branch = repo.branches.get(branch_name)
752
+
753
+ if local_branch is not None:
754
+ # Branch exists locally, just check it out
755
+ repo.checkout(local_branch)
756
+ logger.debug("Checked out existing local branch %s at %s", branch_name, library_path)
757
+ return
758
+
759
+ # Branch doesn't exist locally, try to find it on remote
760
+ remote_branch_name = f"origin/{branch_name}"
761
+ remote_branch = repo.branches.get(remote_branch_name)
762
+
763
+ if remote_branch is None:
764
+ msg = f"Branch {branch_name} not found locally or on remote at {library_path}"
765
+ raise GitRefError(msg)
766
+
767
+ # Create local tracking branch from remote
768
+ commit = repo.get(remote_branch.target)
769
+ if commit is None:
770
+ msg = f"Failed to get commit for remote branch {remote_branch_name} at {library_path}"
771
+ raise GitRefError(msg)
772
+
773
+ new_branch = repo.branches.local.create(branch_name, commit) # type: ignore[arg-type]
774
+ new_branch.upstream = remote_branch
775
+
776
+ # Checkout the new branch
777
+ repo.checkout(new_branch)
778
+ logger.debug(
779
+ "Created and checked out tracking branch %s from %s at %s", branch_name, remote_branch_name, library_path
780
+ )
781
+
782
+ except pygit2.GitError as e:
783
+ msg = f"Git error during branch switch at {library_path}: {e}"
784
+ raise GitRefError(msg) from e
785
+
786
+
787
+ def switch_branch_or_tag(library_path: Path, ref_name: str) -> None:
788
+ """Switch to a different branch or tag in a library directory.
789
+
790
+ Fetches from remote first, then checks out the specified branch or tag.
791
+ Automatically detects whether the ref is a branch or tag.
792
+
793
+ Args:
794
+ library_path: The path to the library directory.
795
+ ref_name: The name of the branch or tag to switch to.
796
+
797
+ Raises:
798
+ GitRepositoryError: If the path is not a valid git repository.
799
+ GitRefError: If the switch operation fails.
800
+ """
801
+ if not is_git_repository(library_path):
802
+ msg = f"Cannot switch ref: {library_path} is not a git repository"
803
+ raise GitRepositoryError(msg)
804
+
805
+ try:
806
+ repo_path = pygit2.discover_repository(str(library_path))
807
+ if repo_path is None:
808
+ msg = f"Cannot switch ref: {library_path} is not a git repository"
809
+ raise GitRefError(msg)
810
+
811
+ repo = pygit2.Repository(repo_path)
812
+
813
+ # Fetch both branches and tags from remote
814
+ remote = repo.remotes["origin"]
815
+ remote.fetch(refspecs=["+refs/tags/*:refs/tags/*"])
816
+ remote.fetch()
817
+
818
+ # Try to checkout the ref (works for both branches and tags)
819
+ # First check if it's a tag
820
+ tag_ref = f"refs/tags/{ref_name}"
821
+ branch_ref = f"refs/remotes/origin/{ref_name}"
822
+
823
+ if tag_ref in repo.references:
824
+ repo.checkout(tag_ref)
825
+ elif branch_ref in repo.references:
826
+ # For remote branches, create/update local tracking branch and checkout
827
+ remote_branch_name = f"origin/{ref_name}"
828
+ remote_branch = repo.branches.get(remote_branch_name)
829
+
830
+ if remote_branch is None:
831
+ msg = f"Remote branch {remote_branch_name} not found at {library_path}"
832
+ raise GitRefError(msg)
833
+
834
+ commit = repo.get(remote_branch.target)
835
+ if commit is None:
836
+ msg = f"Failed to get commit for remote branch {remote_branch_name} at {library_path}"
837
+ raise GitRefError(msg)
838
+
839
+ # Create or update local branch
840
+ if ref_name in repo.branches.local:
841
+ local_branch = repo.branches.local[ref_name]
842
+ local_branch.set_target(commit.id)
843
+ else:
844
+ local_branch = repo.branches.local.create(ref_name, commit) # type: ignore[arg-type]
845
+
846
+ local_branch.upstream = remote_branch
847
+ repo.checkout(local_branch)
848
+ elif ref_name in repo.branches:
849
+ # Local branch
850
+ repo.checkout(repo.branches[ref_name])
851
+ else:
852
+ msg = f"Ref {ref_name} not found at {library_path}"
853
+ raise GitRefError(msg)
854
+
855
+ logger.debug("Checked out %s at %s", ref_name, library_path)
856
+
857
+ except pygit2.GitError as e:
858
+ msg = f"Git error during ref switch at {library_path}: {e}"
859
+ raise GitRefError(msg) from e
860
+
861
+
862
+ def _get_ssh_callbacks() -> pygit2.RemoteCallbacks | None:
863
+ """Get SSH callbacks for pygit2 operations.
864
+
865
+ Tries multiple SSH authentication methods:
866
+ 1. SSH agent (KeypairFromAgent) - works if ssh-agent has keys loaded
867
+ 2. SSH key files (Keypair) - reads keys directly from common paths
868
+
869
+ Returns:
870
+ pygit2.RemoteCallbacks configured with SSH credentials, or None if no keys found.
871
+ """
872
+ # First, try to find an SSH key file
873
+ for key_path in _SSH_KEY_PATHS:
874
+ if key_path.exists():
875
+ pub_key_path = key_path.with_suffix(key_path.suffix + ".pub")
876
+ if pub_key_path.exists():
877
+ logger.debug("Using SSH key from %s", key_path)
878
+ credentials = pygit2.Keypair("git", str(pub_key_path), str(key_path), "")
879
+ return pygit2.RemoteCallbacks(credentials=credentials)
880
+
881
+ # Fall back to SSH agent (may work if user has ssh-agent configured)
882
+ logger.debug("No SSH key files found, falling back to SSH agent")
883
+ return pygit2.RemoteCallbacks(credentials=pygit2.KeypairFromAgent("git"))
884
+
885
+
886
+ def _is_git_available() -> bool:
887
+ """Check if git CLI is available on PATH.
888
+
889
+ Returns:
890
+ bool: True if git CLI is available, False otherwise.
891
+ """
892
+ try:
893
+ subprocess.run(
894
+ ["git", "--version"], # noqa: S607
895
+ capture_output=True,
896
+ check=True,
897
+ )
898
+ except (subprocess.SubprocessError, FileNotFoundError):
899
+ return False
900
+ else:
901
+ return True
902
+
903
+
904
+ def _run_git_command(args: list[str], cwd: str, error_msg: str) -> subprocess.CompletedProcess[str]:
905
+ """Run a git command and raise GitCloneError on failure.
906
+
907
+ Args:
908
+ args: Git command arguments (e.g., ["git", "init"]).
909
+ cwd: Working directory for the command.
910
+ error_msg: Error message prefix to use if command fails.
911
+
912
+ Returns:
913
+ subprocess.CompletedProcess: The result of the command.
914
+
915
+ Raises:
916
+ GitCloneError: If the command returns a non-zero exit code.
917
+ """
918
+ result = subprocess.run( # noqa: S603
919
+ args,
920
+ cwd=cwd,
921
+ capture_output=True,
922
+ text=True,
923
+ check=False,
924
+ )
925
+ if result.returncode != 0:
926
+ msg = f"{error_msg}: {result.stderr}"
927
+ raise GitCloneError(msg)
928
+
929
+ return result
930
+
931
+
932
+ def _checkout_branch_tag_or_commit(repo: pygit2.Repository, ref: str) -> None:
933
+ """Check out a branch, tag, or commit in a repository.
934
+
935
+ For branches, creates a local tracking branch from the remote.
936
+ For tags and commits, checks out in detached HEAD state.
937
+
938
+ Args:
939
+ repo: The pygit2 Repository object.
940
+ ref: The branch, tag, or commit reference to checkout.
941
+
942
+ Raises:
943
+ GitCloneError: If checkout fails.
944
+ """
945
+ # Try to resolve as a local branch first
946
+ try:
947
+ branch = repo.branches[ref]
948
+ repo.checkout(branch)
949
+ except (pygit2.GitError, KeyError, IndexError):
950
+ pass
951
+ else:
952
+ logger.debug("Checked out local branch %s", ref)
953
+ return
954
+
955
+ # Try to resolve as a remote branch and create local tracking branch
956
+ remote_ref = f"refs/remotes/origin/{ref}"
957
+ remote_branch_exists = remote_ref in repo.references
958
+
959
+ if remote_branch_exists:
960
+ remote_branch_name = f"origin/{ref}"
961
+ remote_branch = repo.branches.get(remote_branch_name)
962
+
963
+ if remote_branch is not None:
964
+ commit = repo.get(remote_branch.target)
965
+ if commit is None:
966
+ msg = f"Failed to get commit for remote branch {remote_branch_name}"
967
+ raise GitCloneError(msg)
968
+
969
+ # Create local tracking branch
970
+ local_branch = repo.branches.local.create(ref, commit) # type: ignore[arg-type]
971
+ local_branch.upstream = remote_branch
972
+ repo.checkout(local_branch)
973
+ logger.debug("Checked out remote branch %s as local tracking branch", ref)
974
+ return
975
+
976
+ # Not a local or remote branch, try as tag or commit
977
+ try:
978
+ commit_obj = repo.revparse_single(ref)
979
+ repo.checkout_tree(commit_obj)
980
+ repo.set_head(commit_obj.id)
981
+ logger.debug("Checked out %s as tag or commit", ref)
982
+ except pygit2.GitError as e:
983
+ msg = f"Failed to checkout {ref}: {e}"
984
+ raise GitCloneError(msg) from e
985
+
986
+
987
+ def clone_repository(git_url: str, target_path: Path, branch_tag_commit: str | None = None) -> None:
988
+ """Clone a git repository to a target directory.
989
+
990
+ Args:
991
+ git_url: The git repository URL to clone (HTTPS or SSH).
992
+ target_path: The target directory path to clone into.
993
+ branch_tag_commit: Optional branch, tag, or commit to checkout after cloning.
994
+
995
+ Raises:
996
+ GitCloneError: If cloning fails or target path already exists.
997
+ """
998
+ if target_path.exists():
999
+ msg = f"Cannot clone: target path {target_path} already exists"
1000
+ raise GitCloneError(msg)
1001
+
1002
+ # Use SSH callbacks for SSH URLs
1003
+ callbacks = None
1004
+ if git_url.startswith(("git@", "ssh://")):
1005
+ callbacks = _get_ssh_callbacks()
1006
+
1007
+ try:
1008
+ # Clone the repository
1009
+ repo = pygit2.clone_repository(git_url, str(target_path), callbacks=callbacks)
1010
+ if repo is None:
1011
+ msg = f"Failed to clone repository from {git_url}"
1012
+ raise GitCloneError(msg)
1013
+
1014
+ # Checkout specific branch/tag/commit if provided
1015
+ if branch_tag_commit:
1016
+ _checkout_branch_tag_or_commit(repo, branch_tag_commit)
1017
+
1018
+ except pygit2.GitError as e:
1019
+ msg = f"Git error while cloning {git_url} to {target_path}: {e}"
1020
+ raise GitCloneError(msg) from e
1021
+
1022
+
1023
+ def _extract_library_version_from_json(json_path: Path, remote_url: str) -> str:
1024
+ """Extract library version from a griptape_nodes_library.json file.
1025
+
1026
+ Args:
1027
+ json_path: Path to the library JSON file.
1028
+ remote_url: Git remote URL (for error messages).
1029
+
1030
+ Returns:
1031
+ str: The library version string.
1032
+
1033
+ Raises:
1034
+ GitCloneError: If JSON is invalid or version is missing.
1035
+ """
1036
+ import json
1037
+
1038
+ try:
1039
+ with json_path.open() as f:
1040
+ library_data = json.load(f)
1041
+ except json.JSONDecodeError as e:
1042
+ msg = f"JSON decode error reading library metadata from {remote_url}: {e}"
1043
+ raise GitCloneError(msg) from e
1044
+
1045
+ if "metadata" not in library_data:
1046
+ msg = f"No metadata found in griptape_nodes_library.json from {remote_url}"
1047
+ raise GitCloneError(msg)
1048
+
1049
+ if "library_version" not in library_data["metadata"]:
1050
+ msg = f"No library_version found in metadata from {remote_url}"
1051
+ raise GitCloneError(msg)
1052
+
1053
+ return library_data["metadata"]["library_version"]
1054
+
1055
+
1056
+ def _sparse_checkout_with_git_cli(remote_url: str, ref: str) -> tuple[str, str, dict]:
1057
+ """Perform sparse checkout using git CLI to fetch only library JSON file.
1058
+
1059
+ This is the most efficient method as it only downloads files matching the sparse
1060
+ checkout patterns, not the entire repository.
1061
+
1062
+ Args:
1063
+ remote_url: The git repository URL (HTTPS or SSH).
1064
+ ref: The git reference (branch, tag, or commit) to checkout.
1065
+
1066
+ Returns:
1067
+ tuple[str, str, dict]: A tuple of (library_version, commit_sha, library_data).
1068
+
1069
+ Raises:
1070
+ GitCloneError: If sparse checkout fails or library metadata is invalid.
1071
+ """
1072
+ with tempfile.TemporaryDirectory() as temp_dir:
1073
+ temp_path = Path(temp_dir)
1074
+
1075
+ try:
1076
+ # Initialize empty git repository
1077
+ _run_git_command(["git", "init"], temp_dir, "Git init failed")
1078
+
1079
+ # Add remote
1080
+ _run_git_command(
1081
+ ["git", "remote", "add", "origin", remote_url],
1082
+ temp_dir,
1083
+ "Git remote add failed",
1084
+ )
1085
+
1086
+ # Enable sparse checkout
1087
+ _run_git_command(
1088
+ ["git", "config", "core.sparseCheckout", "true"],
1089
+ temp_dir,
1090
+ "Git sparse checkout config failed",
1091
+ )
1092
+
1093
+ # Configure sparse-checkout patterns
1094
+ sparse_checkout_file = temp_path / ".git" / "info" / "sparse-checkout"
1095
+ sparse_checkout_file.parent.mkdir(parents=True, exist_ok=True)
1096
+ patterns = [
1097
+ "griptape_nodes_library.json",
1098
+ "*/griptape_nodes_library.json",
1099
+ "*/*/griptape_nodes_library.json",
1100
+ "griptape-nodes-library.json",
1101
+ "*/griptape-nodes-library.json",
1102
+ "*/*/griptape-nodes-library.json",
1103
+ ]
1104
+ sparse_checkout_file.write_text("\n".join(patterns))
1105
+
1106
+ # Fetch with depth 1 (shallow clone)
1107
+ _run_git_command(
1108
+ ["git", "fetch", "--depth=1", "origin", ref],
1109
+ temp_dir,
1110
+ f"Git fetch failed for {ref}",
1111
+ )
1112
+
1113
+ # Checkout the ref
1114
+ _run_git_command(["git", "checkout", "FETCH_HEAD"], temp_dir, "Git checkout failed")
1115
+
1116
+ # Find the library JSON file
1117
+ library_json_path = find_file_in_directory(temp_path, "griptape[-_]nodes[-_]library.json")
1118
+ if library_json_path is None:
1119
+ msg = f"No library JSON file found in sparse checkout from {remote_url}"
1120
+ raise GitCloneError(msg)
1121
+
1122
+ # Extract version from JSON
1123
+ library_version = _extract_library_version_from_json(library_json_path, remote_url)
1124
+
1125
+ # Get commit SHA
1126
+ rev_parse_result = _run_git_command(
1127
+ ["git", "rev-parse", "HEAD"],
1128
+ temp_dir,
1129
+ "Git rev-parse failed",
1130
+ )
1131
+ commit_sha = rev_parse_result.stdout.strip()
1132
+
1133
+ # Read the JSON data before temp directory is deleted
1134
+ try:
1135
+ with library_json_path.open() as f:
1136
+ library_data = json.load(f)
1137
+ except (OSError, json.JSONDecodeError) as e:
1138
+ msg = f"Failed to read library file from {remote_url}: {e}"
1139
+ raise GitCloneError(msg) from e
1140
+
1141
+ except subprocess.SubprocessError as e:
1142
+ msg = f"Subprocess error during sparse checkout from {remote_url}: {e}"
1143
+ raise GitCloneError(msg) from e
1144
+
1145
+ return (library_version, commit_sha, library_data)
1146
+
1147
+
1148
+ def _shallow_clone_with_pygit2(remote_url: str, ref: str) -> tuple[str, str, dict]:
1149
+ """Perform shallow clone using pygit2 to fetch library JSON file.
1150
+
1151
+ This is a fallback method when git CLI is not available. It downloads all files
1152
+ but with limited history (depth=1).
1153
+
1154
+ Args:
1155
+ remote_url: The git repository URL (HTTPS or SSH).
1156
+ ref: The git reference (branch, tag, or commit) to checkout.
1157
+
1158
+ Returns:
1159
+ tuple[str, str, dict]: A tuple of (library_version, commit_sha, library_data).
1160
+
1161
+ Raises:
1162
+ GitCloneError: If clone fails or library metadata is invalid.
1163
+ """
1164
+ with tempfile.TemporaryDirectory() as temp_dir:
1165
+ temp_path = Path(temp_dir)
1166
+
1167
+ try:
1168
+ # Use SSH callbacks for SSH URLs
1169
+ callbacks = None
1170
+ if remote_url.startswith(("git@", "ssh://")):
1171
+ callbacks = _get_ssh_callbacks()
1172
+
1173
+ # Shallow clone with depth=1
1174
+ checkout_branch = ref if ref != "HEAD" else None
1175
+ repo = pygit2.clone_repository(
1176
+ remote_url,
1177
+ str(temp_path),
1178
+ callbacks=callbacks,
1179
+ depth=1,
1180
+ checkout_branch=checkout_branch,
1181
+ )
1182
+
1183
+ if repo is None:
1184
+ msg = f"Failed to clone repository from {remote_url}"
1185
+ raise GitCloneError(msg)
1186
+
1187
+ # Find the library JSON file
1188
+ library_json_path = find_file_in_directory(temp_path, "griptape[-_]nodes[-_]library.json")
1189
+ if library_json_path is None:
1190
+ msg = f"No library JSON file found in clone from {remote_url}"
1191
+ raise GitCloneError(msg)
1192
+
1193
+ # Extract version from JSON
1194
+ library_version = _extract_library_version_from_json(library_json_path, remote_url)
1195
+
1196
+ # Get commit SHA
1197
+ commit_sha = str(repo.head.target)
1198
+
1199
+ # Read the JSON data before temp directory is deleted
1200
+ try:
1201
+ with library_json_path.open() as f:
1202
+ library_data = json.load(f)
1203
+ except (OSError, json.JSONDecodeError) as e:
1204
+ msg = f"Failed to read library file from {remote_url}: {e}"
1205
+ raise GitCloneError(msg) from e
1206
+
1207
+ except pygit2.GitError as e:
1208
+ msg = f"Git error during clone from {remote_url}: {e}"
1209
+ raise GitCloneError(msg) from e
1210
+
1211
+ return (library_version, commit_sha, library_data)
1212
+
1213
+
1214
+ def sparse_checkout_library_json(remote_url: str, ref: str = "HEAD") -> tuple[str, str, dict]:
1215
+ """Fetch library JSON file from a git repository.
1216
+
1217
+ This function uses the most efficient method available:
1218
+ - If git CLI is available: uses sparse checkout (only downloads needed files)
1219
+ - Otherwise: falls back to pygit2 shallow clone (downloads all files with depth=1)
1220
+
1221
+ Args:
1222
+ remote_url: The git repository URL (HTTPS or SSH).
1223
+ ref: The git reference (branch, tag, or commit) to checkout. Defaults to HEAD.
1224
+
1225
+ Returns:
1226
+ tuple[str, str, dict]: A tuple of (library_version, commit_sha, library_data).
1227
+
1228
+ Raises:
1229
+ GitCloneError: If the operation fails or library metadata is invalid.
1230
+ """
1231
+ if _is_git_available():
1232
+ logger.debug("Using git CLI for sparse checkout from %s", remote_url)
1233
+ return _sparse_checkout_with_git_cli(remote_url, ref)
1234
+
1235
+ logger.debug("Git CLI not available, using pygit2 shallow clone from %s", remote_url)
1236
+ return _shallow_clone_with_pygit2(remote_url, ref)