123 lines
4.3 KiB
Go
123 lines
4.3 KiB
Go
// Package repo reads where a repo lives, its forge host and owner/repo pair,
|
|
// from a git working directory's origin remote.
|
|
package repo
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// sshLikeRegex matches `git@host:owner/repo(.git)?` style remotes.
|
|
//
|
|
// Excludes URI-shaped strings (anything with "://") so those go through
|
|
// url.Parse instead.
|
|
var sshLikeRegex = regexp.MustCompile(`^[^@/:]+@([^:/]+):(.+?)/?$`)
|
|
|
|
// Origin is where a repo lives: the forge host, the forge's web root and the owner/repo pair.
|
|
type Origin struct {
|
|
// Host is the lowercase hostname without port, e.g. `github.com`; it decides which forge to talk to.
|
|
Host string
|
|
// WebURL is the forge's web root such as `https://gitea.example.com`, the best guess at where its API sits.
|
|
WebURL string
|
|
Owner string
|
|
Repo string
|
|
}
|
|
|
|
// DetectOrigin runs `git -C cwd remote get-url origin` and reads which host and owner/repo the repo lives at.
|
|
//
|
|
// Supported formats:
|
|
// - https://host/owner/repo(.git)
|
|
// - http://host/owner/repo(.git)
|
|
// - ssh://git@host[:port]/owner/repo(.git)
|
|
// - git@host:owner/repo(.git)
|
|
//
|
|
// owner may include subgroups (e.g. "group/subgroup") if the host supports it;
|
|
// repo is the last path segment without the .git suffix.
|
|
func DetectOrigin(cwd string) (Origin, error) {
|
|
cmd := exec.Command("git", "-C", cwd, "remote", "get-url", "origin")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return Origin{}, fmt.Errorf("git remote get-url origin 失败 (cwd=%s): %w", cwd, err)
|
|
}
|
|
raw := strings.TrimSpace(string(out))
|
|
if raw == "" {
|
|
return Origin{}, fmt.Errorf("origin remote 是空的 (cwd=%s)", cwd)
|
|
}
|
|
return parseRemoteURL(raw)
|
|
}
|
|
|
|
// parseRemoteURL is the pure-logic core of DetectOwnerRepo, extracted for testing.
|
|
// parseRemoteURL is the pure-logic core of [DetectOrigin], extracted for testing.
|
|
//
|
|
// An SSH remote says nothing about the web side, so its `WebURL` is a guess of `https://<host>`.
|
|
func parseRemoteURL(raw string) (Origin, error) {
|
|
// Try scp-like ssh: `git@host:owner/repo.git` (only if not URI-shaped).
|
|
if !strings.Contains(raw, "://") {
|
|
if m := sshLikeRegex.FindStringSubmatch(raw); m != nil {
|
|
return newOrigin(m[1], "https://"+m[1], m[2])
|
|
}
|
|
}
|
|
// Try URL form (http/https/ssh://).
|
|
u, perr := url.Parse(raw)
|
|
if perr != nil {
|
|
return Origin{}, fmt.Errorf("无法解析 origin URL %q: %w", raw, perr)
|
|
}
|
|
if u.Hostname() == "" {
|
|
return Origin{}, fmt.Errorf("origin URL %q 没有 host 部分", raw)
|
|
}
|
|
path := strings.TrimPrefix(u.Path, "/")
|
|
if path == "" {
|
|
return Origin{}, fmt.Errorf("origin URL %q 没有 path 部分", raw)
|
|
}
|
|
webURL := "https://" + u.Hostname()
|
|
if u.Scheme == "http" || u.Scheme == "https" {
|
|
webURL = u.Scheme + "://" + u.Host
|
|
}
|
|
return newOrigin(u.Hostname(), webURL, path)
|
|
}
|
|
|
|
// newOrigin assembles an [Origin] from a remote's host, web root and `owner/repo(.git)` path, lowercasing the host.
|
|
func newOrigin(host, webURL, path string) (Origin, error) {
|
|
owner, repo, err := splitOwnerRepo(path)
|
|
if err != nil {
|
|
return Origin{}, err
|
|
}
|
|
return Origin{Host: strings.ToLower(host), WebURL: strings.ToLower(webURL), Owner: owner, Repo: repo}, nil
|
|
}
|
|
|
|
// ParseHost reads a `--host` value, either a bare host like `github.com` or a URL like `https://gitea.example.com/`.
|
|
//
|
|
// @return the lowercase hostname without port, used to pick the forge, and the web root to call, which is the URL
|
|
// itself without its trailing slash, or `https://<host>` for a bare host
|
|
func ParseHost(value string) (host, webURL string, err error) {
|
|
v := strings.TrimSpace(value)
|
|
if v == "" {
|
|
return "", "", fmt.Errorf("--host 不能为空")
|
|
}
|
|
if !strings.Contains(v, "://") {
|
|
v = "https://" + v
|
|
}
|
|
u, err := url.Parse(v)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("无法解析 --host %q: %w", value, err)
|
|
}
|
|
if u.Hostname() == "" {
|
|
return "", "", fmt.Errorf("--host %q 里没有主机名", value)
|
|
}
|
|
return strings.ToLower(u.Hostname()), u.Scheme + "://" + strings.ToLower(u.Host) + strings.TrimRight(u.Path, "/"), nil
|
|
}
|
|
|
|
// splitOwnerRepo turns "owner/repo(.git)" into ("owner", "repo").
|
|
func splitOwnerRepo(path string) (owner, repo string, err error) {
|
|
path = strings.TrimSuffix(path, "/")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
idx := strings.LastIndex(path, "/")
|
|
if idx <= 0 || idx == len(path)-1 {
|
|
return "", "", fmt.Errorf("origin path %q 不是 owner/repo 形式", path)
|
|
}
|
|
return path[:idx], path[idx+1:], nil
|
|
}
|