Port from cline/cline#10945 (concept): accept browser-pasted GitHub URLs in hermes plugins install
`git clone` only accepts the bare repo URL, but users routinely paste URLs they copied from a browser tab (`tree/`, `blob/`, `pull/`, `commit/`, `releases/`, `issues/`, etc.). Previously every such URL was passed verbatim to `git clone` and failed with `repository not found`. `_resolve_git_url()` now normalizes `https://github.com/{owner}/{repo}/<browser-segment>/...` down to `https://github.com/{owner}/{repo}.git` before handing off to git. Non-github hosts (gitlab, bitbucket, custom), SSH/file URLs, and the bare `https://github.com/owner/repo` form are all returned unchanged. Cline shipped the same UX concept for single-file plugins from blob URLs in cline#10945; hermes-agent plugins are directory-based, so this port is restricted to the URL-normalization piece that applies to our model.
This commit is contained in:
@@ -134,6 +134,51 @@ def _sanitize_plugin_name(
|
||||
return target
|
||||
|
||||
|
||||
def _normalize_github_browser_url(url: str) -> str:
|
||||
"""Strip browser-only GitHub path segments so ``git clone`` accepts the URL.
|
||||
|
||||
Users frequently paste URLs they copied from a browser tab, e.g.
|
||||
``https://github.com/owner/repo/tree/main/plugins/foo`` or
|
||||
``https://github.com/owner/repo/blob/main/README.md``. ``git clone`` does
|
||||
not understand those — it only accepts the bare repo URL. Normalize the
|
||||
common variants down to ``https://github.com/{owner}/{repo}.git`` so the
|
||||
paste-from-address-bar workflow Just Works.
|
||||
|
||||
Recognized trailing segments (after ``owner/repo``):
|
||||
``tree/*``, ``blob/*``, ``commit/*``, ``commits/*``, ``pull/*``,
|
||||
``pulls/*``, ``issues/*``, ``releases/*``, ``actions/*``, ``wiki/*``.
|
||||
|
||||
Non-github.com hosts and URLs without a recognized segment are returned
|
||||
unchanged — callers (and ``git clone``) will handle them as-is.
|
||||
"""
|
||||
# Only normalize https://github.com/... URLs. Leave gitlab, bitbucket,
|
||||
# custom hosts, ssh URLs, and file:// alone.
|
||||
prefix = "https://github.com/"
|
||||
if not url.startswith(prefix):
|
||||
return url
|
||||
rest = url[len(prefix):].strip("/")
|
||||
parts = rest.split("/")
|
||||
if len(parts) < 2:
|
||||
return url
|
||||
owner, repo = parts[0], parts[1]
|
||||
# Defensive: ensure owner/repo look sane before mutating.
|
||||
if not owner or not repo or owner.startswith(".") or repo.startswith("."):
|
||||
return url
|
||||
# ``owner/repo.git`` is already canonical; leave that alone.
|
||||
if len(parts) == 2 and repo.endswith(".git"):
|
||||
return url
|
||||
if len(parts) == 2:
|
||||
# Bare repo URL with no trailing segment — git clone handles this.
|
||||
return url
|
||||
browser_segments = {
|
||||
"tree", "blob", "commit", "commits", "pull", "pulls",
|
||||
"issues", "releases", "actions", "wiki",
|
||||
}
|
||||
if parts[2] in browser_segments:
|
||||
return f"https://github.com/{owner}/{repo}.git"
|
||||
return url
|
||||
|
||||
|
||||
def _resolve_git_url(identifier: str) -> str:
|
||||
"""Turn an identifier into a cloneable Git URL.
|
||||
|
||||
@@ -141,6 +186,9 @@ def _resolve_git_url(identifier: str) -> str:
|
||||
- Full URL: https://github.com/owner/repo.git
|
||||
- Full URL: git@github.com:owner/repo.git
|
||||
- Full URL: ssh://git@github.com/owner/repo.git
|
||||
- Browser URL: https://github.com/owner/repo/tree/main/path/to/plugin
|
||||
(and ``blob/``, ``commit/``, ``pull/``, etc. — normalized to the bare
|
||||
repo URL so ``git clone`` accepts it)
|
||||
- Shorthand: owner/repo → https://github.com/owner/repo.git
|
||||
|
||||
NOTE: ``http://`` and ``file://`` schemes are accepted but will trigger a
|
||||
@@ -148,7 +196,7 @@ def _resolve_git_url(identifier: str) -> str:
|
||||
"""
|
||||
# Already a URL
|
||||
if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
|
||||
return identifier
|
||||
return _normalize_github_browser_url(identifier)
|
||||
|
||||
# owner/repo shorthand
|
||||
parts = identifier.strip("/").split("/")
|
||||
|
||||
@@ -130,6 +130,74 @@ class TestResolveGitUrl:
|
||||
with pytest.raises(ValueError, match="Invalid plugin identifier"):
|
||||
_resolve_git_url("a/b/c")
|
||||
|
||||
# ── Browser-pasted GitHub URLs ──────────────────────────────────────
|
||||
# Cline parity port (cline/cline#10945). ``git clone`` only accepts the
|
||||
# bare repo URL; users routinely paste ``tree/``, ``blob/``, ``pull/``,
|
||||
# etc. URLs from a browser tab. Those must be normalized down to
|
||||
# ``https://github.com/{owner}/{repo}.git`` so install Just Works.
|
||||
|
||||
def test_github_tree_url_normalized_to_repo(self):
|
||||
url = _resolve_git_url("https://github.com/owner/repo/tree/main")
|
||||
assert url == "https://github.com/owner/repo.git"
|
||||
|
||||
def test_github_tree_with_subpath_normalized_to_repo(self):
|
||||
url = _resolve_git_url(
|
||||
"https://github.com/owner/repo/tree/main/plugins/foo"
|
||||
)
|
||||
assert url == "https://github.com/owner/repo.git"
|
||||
|
||||
def test_github_blob_url_normalized_to_repo(self):
|
||||
url = _resolve_git_url(
|
||||
"https://github.com/owner/repo/blob/main/README.md"
|
||||
)
|
||||
assert url == "https://github.com/owner/repo.git"
|
||||
|
||||
def test_github_pull_url_normalized_to_repo(self):
|
||||
url = _resolve_git_url("https://github.com/owner/repo/pull/123")
|
||||
assert url == "https://github.com/owner/repo.git"
|
||||
|
||||
def test_github_commit_url_normalized_to_repo(self):
|
||||
url = _resolve_git_url(
|
||||
"https://github.com/owner/repo/commit/abc123def"
|
||||
)
|
||||
assert url == "https://github.com/owner/repo.git"
|
||||
|
||||
def test_github_releases_url_normalized_to_repo(self):
|
||||
url = _resolve_git_url(
|
||||
"https://github.com/owner/repo/releases/tag/v1.0"
|
||||
)
|
||||
assert url == "https://github.com/owner/repo.git"
|
||||
|
||||
def test_github_issues_url_normalized_to_repo(self):
|
||||
url = _resolve_git_url("https://github.com/owner/repo/issues/42")
|
||||
assert url == "https://github.com/owner/repo.git"
|
||||
|
||||
def test_bare_github_url_unchanged(self):
|
||||
# Already a cloneable repo URL — leave alone.
|
||||
url = _resolve_git_url("https://github.com/owner/repo")
|
||||
assert url == "https://github.com/owner/repo"
|
||||
|
||||
def test_canonical_github_dotgit_url_unchanged(self):
|
||||
url = _resolve_git_url("https://github.com/owner/repo.git")
|
||||
assert url == "https://github.com/owner/repo.git"
|
||||
|
||||
def test_non_github_host_passthrough_with_tree(self):
|
||||
# gitlab, bitbucket, custom hosts — leave alone; their URL schemes
|
||||
# differ and ``git clone`` may handle them correctly as-is.
|
||||
url = _resolve_git_url("https://gitlab.com/owner/repo/tree/main")
|
||||
assert url == "https://gitlab.com/owner/repo/tree/main"
|
||||
|
||||
def test_github_user_profile_url_unchanged(self):
|
||||
# Single-segment path (no repo) — return unchanged so the caller's
|
||||
# downstream git invocation surfaces the real error.
|
||||
url = _resolve_git_url("https://github.com/owner")
|
||||
assert url == "https://github.com/owner"
|
||||
|
||||
def test_github_url_with_unknown_segment_unchanged(self):
|
||||
# Defensive: only normalize segments we explicitly recognize.
|
||||
url = _resolve_git_url("https://github.com/owner/repo/branches")
|
||||
assert url == "https://github.com/owner/repo/branches"
|
||||
|
||||
|
||||
# ── _resolve_git_executable ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user