mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
Part 1/5 of the tea 1.0 rewrite. **Stack** (split from #1121; each PR targets `main` and builds, tests and lints on its own — the shipped CLI only changes in part 5): 1. #1122 Toolchain and foundation packages ← this PR 2. #1123 Credentials and Git 3. #1124 Terminal 4. #1125 Services 5. #1126 CLI wiring, docs, removal of the old tree ## Changes - **build:** require Go 1.27; bump gofumpt and golangci-lint to releases that can read Go 1.27 export data (own commit). - `internal/clierr`: typed errors and executable exit codes. - `internal/version`: build version and tags. - `internal/filelock`: moved unchanged from `modules/filelock`. - `internal/aliases`: user-defined command aliases. - `internal/transport`: the HTTP client the Gitea SDK runs on (part 4): token, Sudo and SSH-signature auth, retries for safe reads, connect/TLS/response-header timeouts, typed API errors that keep Gitea's message, and a redacted request log for `--debug`; plus raw requests for `tea api`, signed downloads that follow object-storage redirects, and log streaming. - `internal/settings`: split `config.yml` / `hosts.yml` with locked transactions, plus remote URL parsing. Nothing imports these packages yet. Note: `flake.nix` now asks for `go_1_27`; the pinned nixpkgs in `flake.lock` predates Go 1.27 and still needs a `nix flake update nixpkgs`. Reviewed-on: https://gitea.com/gitea/tea/pulls/1122 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
140 lines
4.8 KiB
Go
140 lines
4.8 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package settings
|
|
|
|
import (
|
|
"errors"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// RemoteIsSSH reports whether a Git remote uses SSH: an ssh:// URL or an scp-style user@host:path.
|
|
func RemoteIsSSH(remote string) bool {
|
|
return strings.HasPrefix(remote, "ssh://") || scpLike(remote)
|
|
}
|
|
|
|
func scpLike(remote string) bool {
|
|
return !strings.Contains(remote, "://") && strings.Contains(remote, "@")
|
|
}
|
|
|
|
// ParseRemote parses a Git remote URL, reading scp-style user@host:path as ssh://user@host/path
|
|
// without interpreting shell syntax.
|
|
func ParseRemote(raw string) (*url.URL, error) {
|
|
if user, path, ok := strings.Cut(raw, ":"); ok && scpLike(raw) {
|
|
raw = "ssh://" + user + "/" + path
|
|
}
|
|
return url.Parse(raw)
|
|
}
|
|
|
|
// RepositoryURL resolves a repository URL or Git remote to a configured instance and its owner/name.
|
|
// Unconfigured HTTP(S) URLs take the final two path segments as the repository.
|
|
func RepositoryURL(raw string, hosts map[string]Host) (string, string, error) {
|
|
u, err := ParseRemote(raw)
|
|
if err != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "http" && u.Scheme != "ssh") {
|
|
return "", "", errors.New("invalid repository URL")
|
|
}
|
|
password := false
|
|
if u.User != nil {
|
|
_, password = u.User.Password()
|
|
}
|
|
if u.User != nil && (u.Scheme != "ssh" || password) {
|
|
return "", "", errors.New("repository URLs must not embed credentials")
|
|
}
|
|
slug, cloneURL := strings.CutSuffix(strings.Trim(u.Path, "/"), ".git")
|
|
bestHost, bestRepo, bestLen := "", "", -1
|
|
matchedHost := false
|
|
remoteSSH, remoteErr := SSHEndpoint(u.Host)
|
|
for instance, host := range hosts {
|
|
base, _ := url.Parse(instance)
|
|
sshHost := host.SSHHost
|
|
if sshHost == "" {
|
|
sshHost = base.Hostname()
|
|
if strings.Contains(sshHost, ":") {
|
|
sshHost = "[" + sshHost + "]"
|
|
}
|
|
}
|
|
prefix := strings.Trim(base.Path, "/")
|
|
configuredSSH, configuredErr := SSHEndpoint(sshHost)
|
|
match := (u.Scheme == "ssh" && remoteErr == nil && configuredErr == nil && remoteSSH == configuredSSH) || (u.Scheme == base.Scheme && strings.EqualFold(u.Host, base.Host))
|
|
if !match {
|
|
continue
|
|
}
|
|
matchedHost = true
|
|
candidate := slug
|
|
if u.Scheme != "ssh" && prefix != "" {
|
|
if !strings.HasPrefix(slug, prefix+"/") {
|
|
continue
|
|
}
|
|
candidate = strings.TrimPrefix(slug, prefix+"/")
|
|
}
|
|
if !ValidSlug(candidate) {
|
|
continue
|
|
}
|
|
if bestHost != "" && u.Scheme == "ssh" && bestHost != instance {
|
|
return "", "", errors.New("SSH remote matches multiple instances; select --host")
|
|
}
|
|
if len(prefix) == bestLen && bestHost != instance {
|
|
return "", "", errors.New("repository URL matches multiple instances; select --host")
|
|
}
|
|
if len(prefix) > bestLen {
|
|
bestHost, bestRepo, bestLen = instance, candidate, len(prefix)
|
|
}
|
|
}
|
|
if bestHost != "" {
|
|
return bestHost, bestRepo, nil
|
|
}
|
|
if u.Scheme == "ssh" {
|
|
return "", "", errors.New("SSH remote does not match a configured instance")
|
|
}
|
|
if matchedHost {
|
|
return "", "", errors.New("repository URL path is ambiguous; use owner/repository or a clone URL")
|
|
}
|
|
parts := strings.Split(slug, "/")
|
|
if len(parts) < 2 {
|
|
return "", "", errors.New("repository URL requires owner/repository")
|
|
}
|
|
repo := strings.Join(parts[len(parts)-2:], "/")
|
|
// A .git suffix marks a clone URL, whose repository may share its name with a web route.
|
|
if !ValidSlug(repo) || (!cloneURL && looksLikeWebResourcePath(parts)) {
|
|
return "", "", errors.New("repository URL path is ambiguous; use owner/repository or a clone URL")
|
|
}
|
|
u.Path = "/" + strings.Join(parts[:len(parts)-2], "/")
|
|
u.RawPath, u.RawQuery, u.Fragment, u.User = "", "", "", nil
|
|
instance, err := NormalizeHost(u.String())
|
|
return instance, repo, err
|
|
}
|
|
|
|
// looksLikeWebResourcePath detects Gitea HTML routes such as owner/repo/issues/7, which have a
|
|
// web route keyword in a segment after the possible owner/repo. Taking the final two segments
|
|
// from those paths would invent the wrong repository.
|
|
func looksLikeWebResourcePath(parts []string) bool {
|
|
if len(parts) < 3 {
|
|
return false
|
|
}
|
|
for _, part := range parts[2:] {
|
|
switch strings.ToLower(part) {
|
|
case "issues", "pulls", "pull", "commit", "commits", "src", "tree", "blob", "raw",
|
|
"compare", "releases", "tags", "wiki", "actions", "settings", "projects",
|
|
"packages", "labels", "milestones", "activities", "forks", "watchers", "stars":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ValidSlug reports whether slug is exactly owner/name.
|
|
func ValidSlug(slug string) bool {
|
|
pieces := strings.Split(slug, "/")
|
|
if len(pieces) != 2 {
|
|
return false
|
|
}
|
|
for _, piece := range pieces {
|
|
// ':' keeps scp-style remotes such as git@host:owner/name.git from passing as a slug.
|
|
if piece == "" || piece == "." || piece == ".." || strings.ContainsAny(piece, ":?#\\ \t\r\n") {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|