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.
- openhands/sdk/agent/agent.py +64 -0
- openhands/sdk/agent/base.py +29 -10
- openhands/sdk/agent/prompts/system_prompt.j2 +1 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +7 -5
- openhands/sdk/context/skills/skill.py +59 -1
- openhands/sdk/context/skills/utils.py +6 -65
- openhands/sdk/context/view.py +6 -11
- openhands/sdk/conversation/base.py +5 -0
- openhands/sdk/conversation/event_store.py +84 -12
- openhands/sdk/conversation/impl/local_conversation.py +7 -0
- openhands/sdk/conversation/impl/remote_conversation.py +16 -3
- openhands/sdk/conversation/state.py +25 -2
- openhands/sdk/conversation/visualizer/base.py +23 -0
- openhands/sdk/critic/__init__.py +4 -1
- openhands/sdk/critic/base.py +17 -20
- openhands/sdk/critic/impl/__init__.py +2 -0
- openhands/sdk/critic/impl/agent_finished.py +9 -5
- openhands/sdk/critic/impl/api/__init__.py +18 -0
- openhands/sdk/critic/impl/api/chat_template.py +232 -0
- openhands/sdk/critic/impl/api/client.py +313 -0
- openhands/sdk/critic/impl/api/critic.py +90 -0
- openhands/sdk/critic/impl/api/taxonomy.py +180 -0
- openhands/sdk/critic/result.py +148 -0
- openhands/sdk/event/conversation_error.py +12 -0
- openhands/sdk/event/llm_convertible/action.py +10 -0
- openhands/sdk/event/llm_convertible/message.py +10 -0
- openhands/sdk/git/cached_repo.py +459 -0
- openhands/sdk/git/utils.py +118 -3
- openhands/sdk/hooks/__init__.py +7 -1
- openhands/sdk/hooks/config.py +154 -45
- openhands/sdk/io/base.py +52 -0
- openhands/sdk/io/local.py +25 -0
- openhands/sdk/io/memory.py +34 -1
- openhands/sdk/llm/llm.py +6 -2
- openhands/sdk/llm/utils/model_features.py +3 -0
- openhands/sdk/llm/utils/telemetry.py +41 -2
- openhands/sdk/plugin/__init__.py +17 -0
- openhands/sdk/plugin/fetch.py +231 -0
- openhands/sdk/plugin/plugin.py +61 -4
- openhands/sdk/plugin/types.py +394 -1
- openhands/sdk/secret/secrets.py +19 -4
- {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/METADATA +6 -1
- {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/RECORD +45 -37
- {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/WHEEL +1 -1
- {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
|
+
)
|