openhands-sdk 1.8.2__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 +22 -10
- openhands/sdk/context/skills/skill.py +59 -1
- openhands/sdk/context/skills/utils.py +6 -65
- openhands/sdk/conversation/base.py +5 -0
- openhands/sdk/conversation/impl/remote_conversation.py +16 -3
- 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/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/llm/utils/model_features.py +3 -0
- 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-1.8.2.dist-info → openhands_sdk-1.9.0.dist-info}/METADATA +5 -1
- {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.0.dist-info}/RECORD +32 -24
- {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.0.dist-info}/WHEEL +1 -1
- {openhands_sdk-1.8.2.dist-info → openhands_sdk-1.9.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
)
|
openhands/sdk/git/utils.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import re
|
|
2
3
|
import shlex
|
|
3
4
|
import subprocess
|
|
4
5
|
from pathlib import Path
|
|
@@ -13,12 +14,17 @@ logger = logging.getLogger(__name__)
|
|
|
13
14
|
GIT_EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
14
15
|
|
|
15
16
|
|
|
16
|
-
def run_git_command(
|
|
17
|
+
def run_git_command(
|
|
18
|
+
args: list[str],
|
|
19
|
+
cwd: str | Path | None = None,
|
|
20
|
+
timeout: int = 30,
|
|
21
|
+
) -> str:
|
|
17
22
|
"""Run a git command safely without shell injection vulnerabilities.
|
|
18
23
|
|
|
19
24
|
Args:
|
|
20
25
|
args: List of command arguments (e.g., ['git', 'status', '--porcelain'])
|
|
21
|
-
cwd: Working directory to run the command in
|
|
26
|
+
cwd: Working directory to run the command in (optional for commands like clone)
|
|
27
|
+
timeout: Timeout in seconds (default: 30)
|
|
22
28
|
|
|
23
29
|
Returns:
|
|
24
30
|
Command output as string
|
|
@@ -33,7 +39,7 @@ def run_git_command(args: list[str], cwd: str | Path) -> str:
|
|
|
33
39
|
capture_output=True,
|
|
34
40
|
text=True,
|
|
35
41
|
check=False,
|
|
36
|
-
timeout=
|
|
42
|
+
timeout=timeout,
|
|
37
43
|
)
|
|
38
44
|
|
|
39
45
|
if result.returncode != 0:
|
|
@@ -212,3 +218,112 @@ def validate_git_repository(repo_dir: str | Path) -> Path:
|
|
|
212
218
|
raise GitRepositoryError(f"Not a git repository: {repo_path}") from e
|
|
213
219
|
|
|
214
220
|
return repo_path
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ============================================================================
|
|
224
|
+
# Git URL utilities
|
|
225
|
+
# ============================================================================
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def is_git_url(source: str) -> bool:
|
|
229
|
+
"""Check if a source string looks like a git URL.
|
|
230
|
+
|
|
231
|
+
Detects git URLs by their protocol/scheme rather than enumerating providers.
|
|
232
|
+
This handles any git hosting service (GitHub, GitLab, Codeberg, self-hosted, etc.)
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
source: String to check.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
True if the string appears to be a git URL, False otherwise.
|
|
239
|
+
|
|
240
|
+
Examples:
|
|
241
|
+
>>> is_git_url("https://github.com/owner/repo.git")
|
|
242
|
+
True
|
|
243
|
+
>>> is_git_url("git@github.com:owner/repo.git")
|
|
244
|
+
True
|
|
245
|
+
>>> is_git_url("/local/path")
|
|
246
|
+
False
|
|
247
|
+
"""
|
|
248
|
+
# HTTPS/HTTP URLs to git repositories
|
|
249
|
+
if source.startswith(("https://", "http://")):
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
# SSH format: git@host:path or user@host:path
|
|
253
|
+
if re.match(r"^[\w.-]+@[\w.-]+:", source):
|
|
254
|
+
return True
|
|
255
|
+
|
|
256
|
+
# Git protocol
|
|
257
|
+
if source.startswith("git://"):
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
# File protocol (for testing)
|
|
261
|
+
if source.startswith("file://"):
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def normalize_git_url(url: str) -> str:
|
|
268
|
+
"""Normalize a git URL by ensuring .git suffix for HTTPS URLs.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
url: Git URL to normalize.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Normalized URL with .git suffix for HTTPS/HTTP URLs.
|
|
275
|
+
|
|
276
|
+
Examples:
|
|
277
|
+
>>> normalize_git_url("https://github.com/owner/repo")
|
|
278
|
+
"https://github.com/owner/repo.git"
|
|
279
|
+
>>> normalize_git_url("https://github.com/owner/repo.git")
|
|
280
|
+
"https://github.com/owner/repo.git"
|
|
281
|
+
>>> normalize_git_url("git@github.com:owner/repo.git")
|
|
282
|
+
"git@github.com:owner/repo.git"
|
|
283
|
+
"""
|
|
284
|
+
if url.startswith(("https://", "http://")) and not url.endswith(".git"):
|
|
285
|
+
url = url.rstrip("/")
|
|
286
|
+
url = f"{url}.git"
|
|
287
|
+
return url
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def extract_repo_name(source: str) -> str:
|
|
291
|
+
"""Extract a human-readable repository name from a git URL or path.
|
|
292
|
+
|
|
293
|
+
Extracts the last path component (repo name) and sanitizes it for use
|
|
294
|
+
in directory names or display purposes.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
source: Git URL or local path string.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
A sanitized name suitable for use in directory names (max 32 chars).
|
|
301
|
+
|
|
302
|
+
Examples:
|
|
303
|
+
>>> extract_repo_name("https://github.com/owner/my-repo.git")
|
|
304
|
+
"my-repo"
|
|
305
|
+
>>> extract_repo_name("git@github.com:owner/my-repo.git")
|
|
306
|
+
"my-repo"
|
|
307
|
+
>>> extract_repo_name("/path/to/local-repo")
|
|
308
|
+
"local-repo"
|
|
309
|
+
"""
|
|
310
|
+
# Strip common prefixes to get to the path portion
|
|
311
|
+
name = source
|
|
312
|
+
for prefix in ("github:", "https://", "http://", "git://", "file://"):
|
|
313
|
+
if name.startswith(prefix):
|
|
314
|
+
name = name[len(prefix) :]
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
# Handle SSH format: user@host:path -> path
|
|
318
|
+
if "@" in name and ":" in name and "/" not in name.split(":")[0]:
|
|
319
|
+
name = name.split(":", 1)[1]
|
|
320
|
+
|
|
321
|
+
# Remove .git suffix and get last path component
|
|
322
|
+
name = name.rstrip("/").removesuffix(".git")
|
|
323
|
+
name = name.rsplit("/", 1)[-1]
|
|
324
|
+
|
|
325
|
+
# Sanitize: keep alphanumeric, dash, underscore only
|
|
326
|
+
name = re.sub(r"[^a-zA-Z0-9_-]", "-", name)
|
|
327
|
+
name = re.sub(r"-+", "-", name).strip("-")
|
|
328
|
+
|
|
329
|
+
return name[:32] if name else "repo"
|
openhands/sdk/hooks/__init__.py
CHANGED
|
@@ -5,7 +5,12 @@ Hooks are event-driven scripts that execute at specific lifecycle events
|
|
|
5
5
|
during agent execution, enabling deterministic control over agent behavior.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from openhands.sdk.hooks.config import
|
|
8
|
+
from openhands.sdk.hooks.config import (
|
|
9
|
+
HookConfig,
|
|
10
|
+
HookDefinition,
|
|
11
|
+
HookMatcher,
|
|
12
|
+
HookType,
|
|
13
|
+
)
|
|
9
14
|
from openhands.sdk.hooks.conversation_hooks import (
|
|
10
15
|
HookEventProcessor,
|
|
11
16
|
create_hook_callback,
|
|
@@ -19,6 +24,7 @@ __all__ = [
|
|
|
19
24
|
"HookConfig",
|
|
20
25
|
"HookDefinition",
|
|
21
26
|
"HookMatcher",
|
|
27
|
+
"HookType",
|
|
22
28
|
"HookExecutor",
|
|
23
29
|
"HookResult",
|
|
24
30
|
"HookManager",
|