// 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://`. 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://` 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 }