mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-27 03:21:25 +01:00
feat: accept repository shorthands and name instances in account errors
Repository arguments and --repo accept NAME (owned by the active account, or the --sudo user), OWNER/NAME.git and HOST/OWNER/NAME next to URLs; --host, TEA_HOST and GITEA_INSTANCE_URL accept a bare HOST[:PORT][/PATH], which takes the scheme of the configured instance it names, else https. --repo origin in a clone with that remote suggests --remote origin. Account errors name the instance, the accounts that are logged in and where the instance came from, such as "no account for https://gitea.com (instance from git remote \"origin\")", instead of "no selected account". Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
4 files changed
+470
-125
No files matched your search
+101
-7
@@ -5,6 +5,7 @@ package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
@@ -126,14 +127,107 @@ func looksLikeWebResourcePath(parts []string) bool {
|
||||
// ValidSlug reports whether slug is exactly owner/name.
|
||||
func ValidSlug(slug string) bool {
|
||||
pieces := strings.Split(slug, "/")
|
||||
if len(pieces) != 2 {
|
||||
return false
|
||||
return len(pieces) == 2 && validPiece(pieces[0]) && validPiece(pieces[1])
|
||||
}
|
||||
|
||||
// validPiece reports whether piece can be an owner or repository name.
|
||||
func validPiece(piece string) bool {
|
||||
// ':' keeps scp-style remotes such as git@host:owner/name.git from passing as a slug.
|
||||
return piece != "" && piece != "." && piece != ".." && !strings.ContainsAny(piece, ":?#\\ \t\r\n")
|
||||
}
|
||||
|
||||
// RepositoryRef is a parsed repository argument.
|
||||
type RepositoryRef struct {
|
||||
// Host is the instance URL the argument names, or "" when it names none.
|
||||
Host string
|
||||
// Owner is "" for a bare NAME, which belongs to the active account.
|
||||
Owner, Name string
|
||||
}
|
||||
|
||||
// Slug returns owner/name, or the bare name when the owner is not known yet.
|
||||
func (r RepositoryRef) Slug() string {
|
||||
if r.Owner == "" {
|
||||
return r.Name
|
||||
}
|
||||
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 r.Owner + "/" + r.Name
|
||||
}
|
||||
|
||||
// ParseRepository reads a repository argument: NAME, OWNER/NAME, HOST/OWNER/NAME (with an
|
||||
// optional .git suffix), or a clone or web URL. HOST may carry a port and a configured
|
||||
// instance's base path; it takes the scheme of the configured instance it names, else https.
|
||||
func ParseRepository(raw string, hosts map[string]Host) (RepositoryRef, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if strings.Contains(raw, "://") || scpLike(raw) {
|
||||
host, slug, err := RepositoryURL(raw, hosts)
|
||||
if err != nil {
|
||||
return RepositoryRef{}, err
|
||||
}
|
||||
owner, name, _ := strings.Cut(slug, "/")
|
||||
return RepositoryRef{Host: host, Owner: owner, Name: name}, nil
|
||||
}
|
||||
invalid := fmt.Errorf("invalid repository %q: use OWNER/REPO, HOST/OWNER/REPO or a URL", raw)
|
||||
trimmed := strings.TrimSuffix(raw, ".git")
|
||||
pieces := strings.Split(trimmed, "/")
|
||||
switch {
|
||||
case len(pieces) == 1 && validPiece(pieces[0]):
|
||||
return RepositoryRef{Name: pieces[0]}, nil
|
||||
case len(pieces) == 2 && ValidSlug(trimmed):
|
||||
return RepositoryRef{Owner: pieces[0], Name: pieces[1]}, nil
|
||||
case len(pieces) < 3:
|
||||
return RepositoryRef{}, invalid
|
||||
}
|
||||
// The longest configured instance prefix wins, so example.org/gitea/owner/name selects the
|
||||
// instance installed below /gitea rather than example.org.
|
||||
best := ""
|
||||
for instance := range hosts {
|
||||
prefix := bareInstance(instance)
|
||||
if len(prefix) > len(bareInstance(best)) && strings.HasPrefix(strings.ToLower(trimmed), strings.ToLower(prefix)+"/") && ValidSlug(trimmed[len(prefix)+1:]) {
|
||||
best = instance
|
||||
}
|
||||
}
|
||||
return true
|
||||
if best != "" {
|
||||
owner, name, _ := strings.Cut(trimmed[len(bareInstance(best))+1:], "/")
|
||||
return RepositoryRef{Host: best, Owner: owner, Name: name}, nil
|
||||
}
|
||||
if len(pieces) != 3 || !ValidSlug(pieces[1]+"/"+pieces[2]) {
|
||||
return RepositoryRef{}, invalid
|
||||
}
|
||||
host, err := ResolveHost(pieces[0], hosts)
|
||||
if err != nil {
|
||||
return RepositoryRef{}, invalid
|
||||
}
|
||||
return RepositoryRef{Host: host, Owner: pieces[1], Name: pieces[2]}, nil
|
||||
}
|
||||
|
||||
// ResolveHost normalizes an instance given as a URL or as a bare HOST[:PORT][/PATH]. A bare
|
||||
// host takes the scheme of the configured instance it names, else https.
|
||||
func ResolveHost(raw string, hosts map[string]Host) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if strings.Contains(raw, "://") {
|
||||
return NormalizeHost(raw)
|
||||
}
|
||||
bare := strings.TrimSuffix(raw, "/")
|
||||
var matches []string
|
||||
for instance := range hosts {
|
||||
if strings.EqualFold(bareInstance(instance), bare) {
|
||||
matches = append(matches, instance)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
instance, err := NormalizeHost("https://" + raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid instance %q: use HOST[:PORT][/PATH] or an http(s) URL", raw)
|
||||
}
|
||||
return instance, nil
|
||||
case 1:
|
||||
return matches[0], nil
|
||||
}
|
||||
return "", fmt.Errorf("instance %q is configured for both http and https; include the scheme", raw)
|
||||
}
|
||||
|
||||
// bareInstance returns an instance URL without its scheme, such as localhost:3001/gitea.
|
||||
func bareInstance(instance string) string {
|
||||
_, bare, _ := strings.Cut(instance, "://")
|
||||
return bare
|
||||
}
|
||||
@@ -68,6 +68,61 @@ func TestParseRemoteReadsSCPStyleRemotes(t *testing.T) {
|
||||
require.Equal(t, "ssh://git@git.example.org/alice/project.git", u.String())
|
||||
}
|
||||
|
||||
func TestParseRepositoryAcceptsShorthands(t *testing.T) {
|
||||
hosts := map[string]Host{"http://localhost:3001": {}, "https://example.org/gitea": {}, "https://example.org": {}}
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want RepositoryRef
|
||||
err string
|
||||
}{
|
||||
{raw: "project", want: RepositoryRef{Name: "project"}},
|
||||
{raw: "alice/project", want: RepositoryRef{Owner: "alice", Name: "project"}},
|
||||
{raw: "alice/project.git", want: RepositoryRef{Owner: "alice", Name: "project"}},
|
||||
{raw: " alice/project ", want: RepositoryRef{Owner: "alice", Name: "project"}},
|
||||
{raw: "localhost:3001/alice/project", want: RepositoryRef{Host: "http://localhost:3001", Owner: "alice", Name: "project"}},
|
||||
{raw: "LOCALHOST:3001/alice/project.git", want: RepositoryRef{Host: "http://localhost:3001", Owner: "alice", Name: "project"}},
|
||||
{raw: "example.org/gitea/alice/project", want: RepositoryRef{Host: "https://example.org/gitea", Owner: "alice", Name: "project"}},
|
||||
{raw: "example.org/alice/project", want: RepositoryRef{Host: "https://example.org", Owner: "alice", Name: "project"}},
|
||||
{raw: "gitea.com/gitea/tea", want: RepositoryRef{Host: "https://gitea.com", Owner: "gitea", Name: "tea"}},
|
||||
{raw: "http://localhost:3001/alice/project.git", want: RepositoryRef{Host: "http://localhost:3001", Owner: "alice", Name: "project"}},
|
||||
{raw: "a/b/c/d", err: `invalid repository "a/b/c/d": use OWNER/REPO, HOST/OWNER/REPO or a URL`},
|
||||
{raw: "alice/", err: "invalid repository"},
|
||||
{raw: "../x", err: "invalid repository"},
|
||||
{raw: "", err: "invalid repository"},
|
||||
} {
|
||||
got, err := ParseRepository(tc.raw, hosts)
|
||||
if tc.err != "" {
|
||||
require.ErrorContains(t, err, tc.err, tc.raw)
|
||||
continue
|
||||
}
|
||||
require.NoError(t, err, tc.raw)
|
||||
require.Equal(t, tc.want, got, tc.raw)
|
||||
}
|
||||
require.Equal(t, "project", RepositoryRef{Name: "project"}.Slug())
|
||||
require.Equal(t, "alice/project", RepositoryRef{Owner: "alice", Name: "project"}.Slug())
|
||||
}
|
||||
|
||||
func TestResolveHostAcceptsBareHosts(t *testing.T) {
|
||||
hosts := map[string]Host{"http://localhost:3001": {}, "https://example.org/gitea": {}}
|
||||
for raw, want := range map[string]string{
|
||||
"localhost:3001": "http://localhost:3001",
|
||||
"localhost:3001/": "http://localhost:3001",
|
||||
"example.org/gitea": "https://example.org/gitea",
|
||||
"gitea.com": "https://gitea.com",
|
||||
"https://localhost:3001": "https://localhost:3001",
|
||||
"https://gitea.com:443/x/": "https://gitea.com/x",
|
||||
"http://example.org/gitea/": "http://example.org/gitea",
|
||||
} {
|
||||
got, err := ResolveHost(raw, hosts)
|
||||
require.NoError(t, err, raw)
|
||||
require.Equal(t, want, got, raw)
|
||||
}
|
||||
_, err := ResolveHost("localhost:3001", map[string]Host{"http://localhost:3001": {}, "https://localhost:3001": {}})
|
||||
require.ErrorContains(t, err, "include the scheme")
|
||||
_, err = ResolveHost("bad host", hosts)
|
||||
require.ErrorContains(t, err, `invalid instance "bad host"`)
|
||||
}
|
||||
|
||||
func TestValidSlugRejectsSCPStyleRemotes(t *testing.T) {
|
||||
require.True(t, ValidSlug("alice/project"))
|
||||
require.False(t, ValidSlug("git@example.org:alice/project.git"))
|
||||
|
||||
Reference in new issue
Block a user