refactor: add settings, transport and alias packages (#1122)

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>
This commit is contained in:
bircni committed 2026-10-11 17:06:38 +00:00
1 parent bcd62a1fb2
commit 2b218a5bae
29 files changed
+2541 -77

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
// 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
}