openhands-sdk 1.8.1__py3-none-any.whl → 1.9.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 (45) hide show
  1. openhands/sdk/agent/agent.py +64 -0
  2. openhands/sdk/agent/base.py +29 -10
  3. openhands/sdk/agent/prompts/system_prompt.j2 +1 -0
  4. openhands/sdk/context/condenser/llm_summarizing_condenser.py +7 -5
  5. openhands/sdk/context/skills/skill.py +59 -1
  6. openhands/sdk/context/skills/utils.py +6 -65
  7. openhands/sdk/context/view.py +6 -11
  8. openhands/sdk/conversation/base.py +5 -0
  9. openhands/sdk/conversation/event_store.py +84 -12
  10. openhands/sdk/conversation/impl/local_conversation.py +7 -0
  11. openhands/sdk/conversation/impl/remote_conversation.py +16 -3
  12. openhands/sdk/conversation/state.py +25 -2
  13. openhands/sdk/conversation/visualizer/base.py +23 -0
  14. openhands/sdk/critic/__init__.py +4 -1
  15. openhands/sdk/critic/base.py +17 -20
  16. openhands/sdk/critic/impl/__init__.py +2 -0
  17. openhands/sdk/critic/impl/agent_finished.py +9 -5
  18. openhands/sdk/critic/impl/api/__init__.py +18 -0
  19. openhands/sdk/critic/impl/api/chat_template.py +232 -0
  20. openhands/sdk/critic/impl/api/client.py +313 -0
  21. openhands/sdk/critic/impl/api/critic.py +90 -0
  22. openhands/sdk/critic/impl/api/taxonomy.py +180 -0
  23. openhands/sdk/critic/result.py +148 -0
  24. openhands/sdk/event/conversation_error.py +12 -0
  25. openhands/sdk/event/llm_convertible/action.py +10 -0
  26. openhands/sdk/event/llm_convertible/message.py +10 -0
  27. openhands/sdk/git/cached_repo.py +459 -0
  28. openhands/sdk/git/utils.py +118 -3
  29. openhands/sdk/hooks/__init__.py +7 -1
  30. openhands/sdk/hooks/config.py +154 -45
  31. openhands/sdk/io/base.py +52 -0
  32. openhands/sdk/io/local.py +25 -0
  33. openhands/sdk/io/memory.py +34 -1
  34. openhands/sdk/llm/llm.py +6 -2
  35. openhands/sdk/llm/utils/model_features.py +3 -0
  36. openhands/sdk/llm/utils/telemetry.py +41 -2
  37. openhands/sdk/plugin/__init__.py +17 -0
  38. openhands/sdk/plugin/fetch.py +231 -0
  39. openhands/sdk/plugin/plugin.py +61 -4
  40. openhands/sdk/plugin/types.py +394 -1
  41. openhands/sdk/secret/secrets.py +19 -4
  42. {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/METADATA +6 -1
  43. {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/RECORD +45 -37
  44. {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/WHEEL +1 -1
  45. {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,148 @@
1
+ from typing import Any, ClassVar
2
+
3
+ from pydantic import BaseModel, Field
4
+ from rich.text import Text
5
+
6
+
7
+ class CriticResult(BaseModel):
8
+ """A critic result is a score and a message."""
9
+
10
+ THRESHOLD: ClassVar[float] = 0.5
11
+ DISPLAY_THRESHOLD: ClassVar[float] = 0.2 # Only show scores above this threshold
12
+
13
+ score: float = Field(
14
+ description="A predicted probability of success between 0 and 1.",
15
+ ge=0.0,
16
+ le=1.0,
17
+ )
18
+ message: str | None = Field(description="An optional message explaining the score.")
19
+ metadata: dict[str, Any] | None = Field(
20
+ default=None,
21
+ description=(
22
+ "Optional metadata about the critic evaluation. "
23
+ "Can include event_ids and categorized_features for visualization."
24
+ ),
25
+ )
26
+
27
+ @property
28
+ def success(self) -> bool:
29
+ """Whether the agent is successful."""
30
+ return self.score >= CriticResult.THRESHOLD
31
+
32
+ @staticmethod
33
+ def _get_star_rating(score: float) -> str:
34
+ """Convert score (0-1) to a 5-star rating string.
35
+
36
+ Each star represents 20% of the score.
37
+ """
38
+ filled_stars = round(score * 5)
39
+ empty_stars = 5 - filled_stars
40
+ return "★" * filled_stars + "☆" * empty_stars
41
+
42
+ @staticmethod
43
+ def _get_star_style(score: float) -> str:
44
+ """Get the style for the star rating based on score."""
45
+ if score >= 0.6:
46
+ return "green"
47
+ elif score >= 0.4:
48
+ return "yellow"
49
+ else:
50
+ return "red"
51
+
52
+ @property
53
+ def visualize(self) -> Text:
54
+ """Return Rich Text representation of the critic result."""
55
+ content = Text()
56
+ content.append("\n\nCritic: agent success likelihood ", style="bold")
57
+
58
+ # Display star rating with percentage
59
+ stars = self._get_star_rating(self.score)
60
+ style = self._get_star_style(self.score)
61
+ percentage = self.score * 100
62
+ content.append(stars, style=style)
63
+ content.append(f" ({percentage:.1f}%)", style="dim")
64
+
65
+ # Use categorized features from metadata if available
66
+ if self.metadata and "categorized_features" in self.metadata:
67
+ categorized = self.metadata["categorized_features"]
68
+ self._append_categorized_features(content, categorized)
69
+ else:
70
+ # Fallback: display message as-is
71
+ if self.message:
72
+ content.append(f"\n {self.message}\n")
73
+ else:
74
+ content.append("\n")
75
+
76
+ return content
77
+
78
+ def _append_categorized_features(
79
+ self, content: Text, categorized: dict[str, Any]
80
+ ) -> None:
81
+ """Append categorized features to content, each category on its own line."""
82
+ has_content = False
83
+
84
+ # Agent behavioral issues
85
+ agent_issues = categorized.get("agent_behavioral_issues", [])
86
+ if agent_issues:
87
+ content.append("\n ")
88
+ content.append("Potential Issues: ", style="bold")
89
+ self._append_feature_list_inline(content, agent_issues)
90
+ has_content = True
91
+
92
+ # User follow-up patterns
93
+ user_patterns = categorized.get("user_followup_patterns", [])
94
+ if user_patterns:
95
+ content.append("\n ")
96
+ content.append("Likely Follow-up: ", style="bold")
97
+ self._append_feature_list_inline(content, user_patterns)
98
+ has_content = True
99
+
100
+ # Infrastructure issues
101
+ infra_issues = categorized.get("infrastructure_issues", [])
102
+ if infra_issues:
103
+ content.append("\n ")
104
+ content.append("Infrastructure: ", style="bold")
105
+ self._append_feature_list_inline(content, infra_issues)
106
+ has_content = True
107
+
108
+ # Other metrics
109
+ other = categorized.get("other", [])
110
+ if other:
111
+ content.append("\n ")
112
+ content.append("Other: ", style="bold")
113
+ self._append_feature_list_inline(content, other, is_other=True)
114
+ has_content = True
115
+
116
+ if not has_content:
117
+ content.append("\n")
118
+ else:
119
+ content.append("\n")
120
+
121
+ def _append_feature_list_inline(
122
+ self,
123
+ content: Text,
124
+ features: list[dict[str, Any]],
125
+ is_other: bool = False,
126
+ ) -> None:
127
+ """Append features inline with likelihood percentages."""
128
+ for i, feature in enumerate(features):
129
+ display_name = feature.get("display_name", feature.get("name", "Unknown"))
130
+ prob = feature.get("probability", 0.0)
131
+ percentage = prob * 100
132
+
133
+ # Get style based on probability
134
+ if is_other:
135
+ prob_style = "white"
136
+ elif prob >= 0.7:
137
+ prob_style = "red bold"
138
+ elif prob >= 0.5:
139
+ prob_style = "yellow"
140
+ else:
141
+ prob_style = "dim"
142
+
143
+ # Add dot separator between features
144
+ if i > 0:
145
+ content.append(" · ", style="dim")
146
+
147
+ content.append(f"{display_name}", style="white")
148
+ content.append(f" (likelihood {percentage:.0f}%)", style=prob_style)
@@ -1,4 +1,5 @@
1
1
  from pydantic import Field
2
+ from rich.text import Text
2
3
 
3
4
  from openhands.sdk.event.base import Event
4
5
 
@@ -23,3 +24,14 @@ class ConversationErrorEvent(Event):
23
24
 
24
25
  code: str = Field(description="Code for the error - typically a type")
25
26
  detail: str = Field(description="Details about the error")
27
+
28
+ @property
29
+ def visualize(self) -> Text:
30
+ """Return Rich Text representation of this conversation error event."""
31
+ content = Text()
32
+ content.append("Conversation Error\n", style="bold")
33
+ content.append("Code: ", style="bold")
34
+ content.append(self.code)
35
+ content.append("\n\nDetail:\n", style="bold")
36
+ content.append(self.detail)
37
+ return content
@@ -3,6 +3,7 @@ from collections.abc import Sequence
3
3
  from pydantic import Field
4
4
  from rich.text import Text
5
5
 
6
+ from openhands.sdk.critic.result import CriticResult
6
7
  from openhands.sdk.event.base import N_CHAR_PREVIEW, EventID, LLMConvertibleEvent
7
8
  from openhands.sdk.event.types import SourceType, ToolCallID
8
9
  from openhands.sdk.llm import (
@@ -65,6 +66,11 @@ class ActionEvent(LLMConvertibleEvent):
65
66
  description="The LLM's assessment of the safety risk of this action.",
66
67
  )
67
68
 
69
+ critic_result: CriticResult | None = Field(
70
+ default=None,
71
+ description="Optional critic evaluation of this action and preceding history.",
72
+ )
73
+
68
74
  summary: str | None = Field(
69
75
  default=None,
70
76
  description=(
@@ -125,6 +131,10 @@ class ActionEvent(LLMConvertibleEvent):
125
131
  content.append("Function call:\n", style="bold")
126
132
  content.append(f"- {self.tool_call.name} ({self.tool_call.id})\n")
127
133
 
134
+ # Display critic result if available
135
+ if self.critic_result is not None:
136
+ content.append(self.critic_result.visualize)
137
+
128
138
  return content
129
139
 
130
140
  def to_llm_message(self) -> Message:
@@ -5,6 +5,7 @@ from typing import ClassVar
5
5
  from pydantic import ConfigDict, Field
6
6
  from rich.text import Text
7
7
 
8
+ from openhands.sdk.critic.result import CriticResult
8
9
  from openhands.sdk.event.base import N_CHAR_PREVIEW, EventID, LLMConvertibleEvent
9
10
  from openhands.sdk.event.types import SourceType
10
11
  from openhands.sdk.llm import (
@@ -51,6 +52,11 @@ class MessageEvent(LLMConvertibleEvent):
51
52
  ),
52
53
  )
53
54
 
55
+ critic_result: CriticResult | None = Field(
56
+ default=None,
57
+ description="Optional critic evaluation of this message and preceding history.",
58
+ )
59
+
54
60
  @property
55
61
  def reasoning_content(self) -> str:
56
62
  return self.llm_message.reasoning_content or ""
@@ -101,6 +107,10 @@ class MessageEvent(LLMConvertibleEvent):
101
107
  )
102
108
  content.append(" ".join(text_parts))
103
109
 
110
+ # Display critic result if available
111
+ if self.critic_result is not None:
112
+ content.append(self.critic_result.visualize)
113
+
104
114
  return content
105
115
 
106
116
  def to_llm_message(self) -> Message:
@@ -0,0 +1,459 @@
1
+ """Git operations for cloning and caching remote repositories.
2
+
3
+ This module provides utilities for cloning git repositories to a local cache
4
+ and keeping them updated. Used by both the skills system and plugin fetching.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ from pathlib import Path
11
+
12
+ from filelock import FileLock, Timeout
13
+
14
+ from openhands.sdk.git.exceptions import GitCommandError
15
+ from openhands.sdk.git.utils import run_git_command
16
+ from openhands.sdk.logger import get_logger
17
+
18
+
19
+ logger = get_logger(__name__)
20
+
21
+ # Default timeout for acquiring cache locks (seconds)
22
+ # Consistent with other lock timeouts in the SDK (io/local.py, event_store.py)
23
+ DEFAULT_LOCK_TIMEOUT = 30
24
+
25
+
26
+ class GitHelper:
27
+ """Abstraction for git operations, enabling easy mocking in tests.
28
+
29
+ This class wraps git commands for cloning, fetching, and managing
30
+ cached repositories. All methods raise GitCommandError on failure.
31
+ """
32
+
33
+ def clone(
34
+ self,
35
+ url: str,
36
+ dest: Path,
37
+ depth: int | None = 1,
38
+ branch: str | None = None,
39
+ timeout: int = 120,
40
+ ) -> None:
41
+ """Clone a git repository.
42
+
43
+ Args:
44
+ url: Git URL to clone.
45
+ dest: Destination path.
46
+ depth: Clone depth (None for full clone, 1 for shallow). Note that
47
+ shallow clones only fetch the tip of the specified branch. If you
48
+ later need to checkout a specific commit that isn't the branch tip,
49
+ the checkout may fail. Use depth=None for full clones if you need
50
+ to checkout arbitrary commits.
51
+ branch: Branch/tag to checkout during clone.
52
+ timeout: Timeout in seconds.
53
+
54
+ Raises:
55
+ GitCommandError: If clone fails.
56
+ """
57
+ cmd = ["git", "clone"]
58
+
59
+ if depth is not None:
60
+ cmd.extend(["--depth", str(depth)])
61
+
62
+ if branch:
63
+ cmd.extend(["--branch", branch])
64
+
65
+ cmd.extend([url, str(dest)])
66
+
67
+ run_git_command(cmd, timeout=timeout)
68
+
69
+ def fetch(
70
+ self,
71
+ repo_path: Path,
72
+ remote: str = "origin",
73
+ ref: str | None = None,
74
+ timeout: int = 60,
75
+ ) -> None:
76
+ """Fetch from remote.
77
+
78
+ Args:
79
+ repo_path: Path to the repository.
80
+ remote: Remote name.
81
+ ref: Specific ref to fetch (optional).
82
+ timeout: Timeout in seconds.
83
+
84
+ Raises:
85
+ GitCommandError: If fetch fails.
86
+ """
87
+ cmd = ["git", "fetch", remote]
88
+ if ref:
89
+ cmd.append(ref)
90
+
91
+ run_git_command(cmd, cwd=repo_path, timeout=timeout)
92
+
93
+ def checkout(self, repo_path: Path, ref: str, timeout: int = 30) -> None:
94
+ """Checkout a ref (branch, tag, or commit).
95
+
96
+ Args:
97
+ repo_path: Path to the repository.
98
+ ref: Branch, tag, or commit to checkout.
99
+ timeout: Timeout in seconds.
100
+
101
+ Raises:
102
+ GitCommandError: If checkout fails.
103
+ """
104
+ run_git_command(["git", "checkout", ref], cwd=repo_path, timeout=timeout)
105
+
106
+ def reset_hard(self, repo_path: Path, ref: str, timeout: int = 30) -> None:
107
+ """Hard reset to a ref.
108
+
109
+ Args:
110
+ repo_path: Path to the repository.
111
+ ref: Ref to reset to (e.g., "origin/main").
112
+ timeout: Timeout in seconds.
113
+
114
+ Raises:
115
+ GitCommandError: If reset fails.
116
+ """
117
+ run_git_command(["git", "reset", "--hard", ref], cwd=repo_path, timeout=timeout)
118
+
119
+ def get_current_branch(self, repo_path: Path, timeout: int = 10) -> str | None:
120
+ """Get the current branch name.
121
+
122
+ Args:
123
+ repo_path: Path to the repository.
124
+ timeout: Timeout in seconds.
125
+
126
+ Returns:
127
+ Branch name, or None if in detached HEAD state.
128
+
129
+ Raises:
130
+ GitCommandError: If command fails.
131
+ """
132
+ branch = run_git_command(
133
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
134
+ cwd=repo_path,
135
+ timeout=timeout,
136
+ )
137
+ # "HEAD" means detached HEAD state
138
+ return None if branch == "HEAD" else branch
139
+
140
+ def get_default_branch(self, repo_path: Path, timeout: int = 10) -> str | None:
141
+ """Get the default branch name from the remote.
142
+
143
+ Queries origin/HEAD to determine the remote's default branch. This is set
144
+ during clone and points to the branch that would be checked out by default.
145
+
146
+ Args:
147
+ repo_path: Path to the repository.
148
+ timeout: Timeout in seconds.
149
+
150
+ Returns:
151
+ Default branch name (e.g., "main" or "master"), or None if it cannot
152
+ be determined (e.g., origin/HEAD is not set).
153
+
154
+ Raises:
155
+ GitCommandError: If the git command itself fails (not if ref is missing).
156
+ """
157
+ try:
158
+ # origin/HEAD is a symbolic ref pointing to the default branch
159
+ ref = run_git_command(
160
+ ["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
161
+ cwd=repo_path,
162
+ timeout=timeout,
163
+ )
164
+ # Output is like "refs/remotes/origin/main" - extract branch name
165
+ prefix = "refs/remotes/origin/"
166
+ if ref.startswith(prefix):
167
+ return ref[len(prefix) :]
168
+ return None
169
+ except GitCommandError:
170
+ # origin/HEAD may not be set (e.g., bare clone, or never configured)
171
+ return None
172
+
173
+
174
+ def try_cached_clone_or_update(
175
+ url: str,
176
+ repo_path: Path,
177
+ ref: str | None = None,
178
+ update: bool = True,
179
+ git_helper: GitHelper | None = None,
180
+ lock_timeout: float = DEFAULT_LOCK_TIMEOUT,
181
+ ) -> Path | None:
182
+ """Clone or update a git repository in a cache directory.
183
+
184
+ This is the main entry point for cached repository operations.
185
+
186
+ Behavior:
187
+ - If repo doesn't exist: clone (shallow, --depth 1) with optional ref
188
+ - If repo exists and update=True: fetch, checkout+reset to ref
189
+ - If repo exists and update=False with ref: checkout ref without fetching
190
+ - If repo exists and update=False without ref: use as-is
191
+
192
+ The update sequence is: fetch origin -> checkout ref -> reset --hard origin/ref.
193
+ This ensures local changes are discarded and the cache matches the remote.
194
+
195
+ Concurrency:
196
+ Uses file-based locking to prevent race conditions when multiple processes
197
+ access the same cache directory. The lock file is created adjacent to the
198
+ repo directory (repo_path.lock).
199
+
200
+ Args:
201
+ url: Git URL to clone.
202
+ repo_path: Path where the repository should be cached.
203
+ ref: Branch, tag, or commit to checkout. If None, uses default branch.
204
+ update: If True and repo exists, fetch and update it. If False, skip fetch.
205
+ git_helper: GitHelper instance for git operations. If None, creates one.
206
+ lock_timeout: Timeout in seconds for acquiring the lock. Default is 5 minutes.
207
+
208
+ Returns:
209
+ Path to the local repository if successful, None on failure.
210
+ Returns None (not raises) on git errors to allow graceful degradation.
211
+ """
212
+ git = git_helper if git_helper is not None else GitHelper()
213
+
214
+ # Ensure parent directory exists for both the repo and lock file
215
+ repo_path.parent.mkdir(parents=True, exist_ok=True)
216
+
217
+ # Use a lock file adjacent to the repo directory
218
+ lock_path = repo_path.with_suffix(".lock")
219
+ lock = FileLock(lock_path)
220
+
221
+ try:
222
+ with lock.acquire(timeout=lock_timeout):
223
+ return _do_clone_or_update(url, repo_path, ref, update, git)
224
+ except Timeout:
225
+ logger.warning(
226
+ f"Timed out waiting for lock on {repo_path} after {lock_timeout}s"
227
+ )
228
+ return None
229
+ except GitCommandError as e:
230
+ logger.warning(f"Git operation failed: {e}")
231
+ return None
232
+ except Exception as e:
233
+ logger.warning(f"Error managing repository: {str(e)}")
234
+ return None
235
+
236
+
237
+ def _do_clone_or_update(
238
+ url: str,
239
+ repo_path: Path,
240
+ ref: str | None,
241
+ update: bool,
242
+ git: GitHelper,
243
+ ) -> Path:
244
+ """Perform the actual clone or update operation (called while holding lock).
245
+
246
+ Args:
247
+ url: Git URL to clone.
248
+ repo_path: Path where the repository should be cached.
249
+ ref: Branch, tag, or commit to checkout.
250
+ update: Whether to update existing repos.
251
+ git: GitHelper instance.
252
+
253
+ Returns:
254
+ Path to the repository.
255
+
256
+ Raises:
257
+ GitCommandError: If git operations fail.
258
+ """
259
+ if repo_path.exists() and (repo_path / ".git").exists():
260
+ if update:
261
+ logger.debug(f"Updating repository at {repo_path}")
262
+ _update_repository(repo_path, ref, git)
263
+ elif ref:
264
+ logger.debug(f"Checking out ref {ref} at {repo_path}")
265
+ _checkout_ref(repo_path, ref, git)
266
+ else:
267
+ logger.debug(f"Using cached repository at {repo_path}")
268
+ else:
269
+ logger.info(f"Cloning repository from {url}")
270
+ _clone_repository(url, repo_path, ref, git)
271
+
272
+ return repo_path
273
+
274
+
275
+ def _clone_repository(
276
+ url: str,
277
+ dest: Path,
278
+ branch: str | None,
279
+ git: GitHelper,
280
+ ) -> None:
281
+ """Clone a git repository.
282
+
283
+ Args:
284
+ url: Git URL to clone.
285
+ dest: Destination path.
286
+ branch: Branch to checkout (optional).
287
+ git: GitHelper instance.
288
+ """
289
+ # Remove existing directory if it exists but isn't a valid git repo
290
+ if dest.exists():
291
+ shutil.rmtree(dest)
292
+
293
+ git.clone(url, dest, depth=1, branch=branch)
294
+ logger.debug(f"Repository cloned to {dest}")
295
+
296
+
297
+ def _update_repository(
298
+ repo_path: Path,
299
+ ref: str | None,
300
+ git: GitHelper,
301
+ ) -> None:
302
+ """Update an existing cached repository to the latest remote state.
303
+
304
+ Fetches from origin and resets to match the remote. On any failure, logs a
305
+ warning and returns silently—the cached repository remains usable (just
306
+ potentially stale).
307
+
308
+ Behavior by scenario:
309
+ 1. ref is specified: Checkout and reset to that ref (branch/tag/commit)
310
+ 2. ref is None, on a branch: Reset to origin/{current_branch}
311
+ 3. ref is None, detached HEAD: Checkout the remote's default branch
312
+ (determined via origin/HEAD), then reset to origin/{default_branch}.
313
+ This handles the case where a previous fetch with a specific ref
314
+ (e.g., a tag) left the repo in detached HEAD state.
315
+
316
+ The detached HEAD recovery ensures that calling fetch(source, update=True)
317
+ without a ref always updates to "the latest", even if a previous call used
318
+ a specific tag or commit. Without this, the repo would be stuck on the old
319
+ ref with no way to get back to the default branch.
320
+
321
+ Args:
322
+ repo_path: Path to the repository.
323
+ ref: Branch, tag, or commit to update to. If None, uses current branch
324
+ or falls back to the remote's default branch.
325
+ git: GitHelper instance.
326
+ """
327
+ # Fetch from origin - if this fails, we still have a usable (stale) cache
328
+ if not _try_fetch(repo_path, git):
329
+ return
330
+
331
+ # If a specific ref was requested, check it out
332
+ if ref:
333
+ _try_checkout_and_reset(repo_path, ref, git)
334
+ return
335
+
336
+ # No ref specified - update based on current state
337
+ current_branch = git.get_current_branch(repo_path)
338
+
339
+ if current_branch:
340
+ # On a branch: reset to track origin
341
+ _try_reset_to_origin(repo_path, current_branch, git)
342
+ return
343
+
344
+ # Detached HEAD: recover by checking out the default branch
345
+ _recover_from_detached_head(repo_path, git)
346
+
347
+
348
+ def _try_fetch(repo_path: Path, git: GitHelper) -> bool:
349
+ """Attempt to fetch from origin. Returns True on success, False on failure."""
350
+ try:
351
+ git.fetch(repo_path)
352
+ return True
353
+ except GitCommandError as e:
354
+ logger.warning(f"Failed to fetch updates: {e}. Using cached version.")
355
+ return False
356
+
357
+
358
+ def _try_checkout_and_reset(repo_path: Path, ref: str, git: GitHelper) -> None:
359
+ """Attempt to checkout and reset to a specific ref. Logs warning on failure."""
360
+ try:
361
+ _checkout_ref(repo_path, ref, git)
362
+ logger.debug(f"Repository updated to {ref}")
363
+ except GitCommandError as e:
364
+ logger.warning(f"Failed to checkout {ref}: {e}. Using cached version.")
365
+
366
+
367
+ def _try_reset_to_origin(repo_path: Path, branch: str, git: GitHelper) -> None:
368
+ """Attempt to reset to origin/{branch}. Logs warning on failure."""
369
+ try:
370
+ git.reset_hard(repo_path, f"origin/{branch}")
371
+ logger.debug("Repository updated successfully")
372
+ except GitCommandError as e:
373
+ logger.warning(
374
+ f"Failed to reset to origin/{branch}: {e}. Using cached version."
375
+ )
376
+
377
+
378
+ def _recover_from_detached_head(repo_path: Path, git: GitHelper) -> None:
379
+ """Recover from detached HEAD state by checking out the default branch.
380
+
381
+ This handles the scenario where:
382
+ 1. User previously fetched with ref="v1.0.0" (a tag) -> repo is in detached HEAD
383
+ 2. User now fetches with update=True but no ref -> expects "latest"
384
+
385
+ Without this recovery, the repo would stay stuck on the old tag. By checking
386
+ out the default branch, we ensure update=True without a ref means "latest
387
+ from the default branch".
388
+ """
389
+ default_branch = git.get_default_branch(repo_path)
390
+
391
+ if not default_branch:
392
+ logger.warning(
393
+ "Repository is in detached HEAD state and default branch could not be "
394
+ "determined. Specify a ref explicitly to update, or the cached version "
395
+ "will be used as-is."
396
+ )
397
+ return
398
+
399
+ logger.debug(
400
+ f"Repository in detached HEAD state, "
401
+ f"checking out default branch: {default_branch}"
402
+ )
403
+
404
+ try:
405
+ git.checkout(repo_path, default_branch)
406
+ git.reset_hard(repo_path, f"origin/{default_branch}")
407
+ logger.debug(f"Repository updated to default branch: {default_branch}")
408
+ except GitCommandError as e:
409
+ logger.warning(
410
+ f"Failed to checkout default branch {default_branch}: {e}. "
411
+ "Using cached version."
412
+ )
413
+
414
+
415
+ def _checkout_ref(repo_path: Path, ref: str, git: GitHelper) -> None:
416
+ """Checkout a specific ref (branch, tag, or commit).
417
+
418
+ Handles each ref type with appropriate semantics:
419
+
420
+ - **Branches**: Checks out the branch and resets to ``origin/{branch}`` to
421
+ ensure the local branch matches the remote state.
422
+
423
+ - **Tags**: Checks out in detached HEAD state. Tags are immutable, so no
424
+ reset is performed.
425
+
426
+ - **Commits**: Checks out in detached HEAD state. For shallow clones, the
427
+ commit must be reachable from fetched history.
428
+
429
+ Args:
430
+ repo_path: Path to the repository.
431
+ ref: Branch name, tag name, or commit SHA to checkout.
432
+ git: GitHelper instance.
433
+
434
+ Raises:
435
+ GitCommandError: If checkout fails (ref doesn't exist or isn't reachable).
436
+ """
437
+ logger.debug(f"Checking out ref: {ref}")
438
+
439
+ # Checkout is the critical operation - let it raise if it fails
440
+ git.checkout(repo_path, ref)
441
+
442
+ # Determine what we checked out by examining HEAD state
443
+ current_branch = git.get_current_branch(repo_path)
444
+
445
+ if current_branch is None:
446
+ # Detached HEAD means we checked out a tag or commit - nothing more to do
447
+ logger.debug(f"Checked out {ref} (detached HEAD - tag or commit)")
448
+ return
449
+
450
+ # We're on a branch - reset to sync with origin
451
+ try:
452
+ git.reset_hard(repo_path, f"origin/{current_branch}")
453
+ logger.debug(f"Branch {current_branch} reset to origin/{current_branch}")
454
+ except GitCommandError:
455
+ # Branch may not exist on origin (e.g., local-only branch)
456
+ logger.debug(
457
+ f"Could not reset to origin/{current_branch} "
458
+ f"(branch may not exist on remote)"
459
+ )