diff --git a/cmd/clone.go b/cmd/clone.go new file mode 100644 index 0000000..e7e4e42 --- /dev/null +++ b/cmd/clone.go @@ -0,0 +1,89 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package cmd + +import ( + "fmt" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/git" + "code.gitea.io/tea/modules/interact" + "code.gitea.io/tea/modules/task" + "code.gitea.io/tea/modules/utils" + + "github.com/urfave/cli/v2" +) + +// CmdRepoClone represents a sub command of repos to create a local copy +var CmdRepoClone = cli.Command{ + Name: "clone", + Aliases: []string{"C"}, + Usage: "Clone a repository locally", + Description: `Clone a repository locally, without a local git installation required. +The repo slug can be specified in different formats: + gitea/tea + tea + gitea.com/gitea/tea + git@gitea.com:gitea/tea + https://gitea.com/gitea/tea + ssh://gitea.com:22/gitea/tea +When a host is specified in the repo-slug, it will override the login specified with --login. + `, + Category: catHelpers, + Action: runRepoClone, + ArgsUsage: " [target dir]", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "depth", + Aliases: []string{"d"}, + Usage: "num commits to fetch, defaults to all", + }, + &flags.LoginFlag, + }, +} + +func runRepoClone(cmd *cli.Context) error { + ctx := context.InitCommand(cmd) + + args := ctx.Args() + if args.Len() < 1 { + return cli.ShowCommandHelp(cmd, "clone") + } + dir := args.Get(1) + + var ( + login *config.Login = ctx.Login + owner string = ctx.Login.User + repo string + ) + + // parse first arg as repo specifier + repoSlug := args.Get(0) + url, err := git.ParseURL(repoSlug) + if err != nil { + return err + } + + owner, repo = utils.GetOwnerAndRepo(url.Path, login.User) + if url.Host != "" { + login = config.GetLoginByHost(url.Host) + if login == nil { + return fmt.Errorf("No login configured matching host '%s', run `tea login add` first", url.Host) + } + } + + _, err = task.RepoClone( + dir, + login, + owner, + repo, + interact.PromptPassword, + ctx.Int("depth"), + ) + + return err +} diff --git a/main.go b/main.go index 52735cf..580cb38 100644 --- a/main.go +++ b/main.go @@ -49,6 +49,7 @@ func main() { &cmd.CmdOpen, &cmd.CmdNotifications, + &cmd.CmdRepoClone, } app.EnableBashCompletion = true err := app.Run(os.Args) diff --git a/modules/config/login.go b/modules/config/login.go index 47f944c..ddecd2f 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -111,6 +111,25 @@ func GetLoginByToken(token string) *Login { return nil } +// GetLoginByHost finds a login by it's server URL +func GetLoginByHost(host string) *Login { + err := loadConfig() + if err != nil { + log.Fatal(err) + } + + for _, l := range config.Logins { + loginURL, err := url.Parse(l.URL) + if err != nil { + log.Fatal(err) + } + if loginURL.Host == host { + return &l + } + } + return nil +} + // DeleteLogin delete a login by name from config func DeleteLogin(name string) error { var idx = -1 diff --git a/modules/git/url.go b/modules/git/url.go index 5b5a9a5..f76a666 100644 --- a/modules/git/url.go +++ b/modules/git/url.go @@ -22,13 +22,18 @@ type URLParser struct { func (p *URLParser) Parse(rawURL string) (u *url.URL, err error) { rawURL = strings.TrimSpace(rawURL) - // convert the weird git ssh url format to a canonical url: - // git@gitea.com:gitea/tea -> ssh://git@gitea.com/gitea/tea - if !protocolRe.MatchString(rawURL) && - strings.Contains(rawURL, ":") && - // not a Windows path - !strings.Contains(rawURL, "\\") { - rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1) + if !protocolRe.MatchString(rawURL) { + // convert the weird git ssh url format to a canonical url: + // git@gitea.com:gitea/tea -> ssh://git@gitea.com/gitea/tea + if strings.Contains(rawURL, ":") && + // not a Windows path + !strings.Contains(rawURL, "\\") { + rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1) + } else if !strings.Contains(rawURL, "@") && + strings.Count(rawURL, "/") == 2 { + // match cases like gitea.com/gitea/tea + rawURL = "https://" + rawURL + } } u, err = url.Parse(rawURL) diff --git a/modules/git/url_test.go b/modules/git/url_test.go new file mode 100644 index 0000000..f342525 --- /dev/null +++ b/modules/git/url_test.go @@ -0,0 +1,56 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseUrl(t *testing.T) { + u, err := ParseURL("ssh://git@gitea.com:3000/gitea/tea") + assert.NoError(t, err) + assert.Equal(t, "gitea.com:3000", u.Host) + assert.Equal(t, "ssh", u.Scheme) + assert.Equal(t, "/gitea/tea", u.Path) + + u, err = ParseURL("https://gitea.com/gitea/tea") + assert.NoError(t, err) + assert.Equal(t, "gitea.com", u.Host) + assert.Equal(t, "https", u.Scheme) + assert.Equal(t, "/gitea/tea", u.Path) + + u, err = ParseURL("git@gitea.com:gitea/tea") + assert.NoError(t, err) + assert.Equal(t, "gitea.com", u.Host) + assert.Equal(t, "ssh", u.Scheme) + assert.Equal(t, "/gitea/tea", u.Path) + + u, err = ParseURL("gitea.com/gitea/tea") + assert.NoError(t, err) + assert.Equal(t, "gitea.com", u.Host) + assert.Equal(t, "https", u.Scheme) + assert.Equal(t, "/gitea/tea", u.Path) + + u, err = ParseURL("foo/bar") + assert.NoError(t, err) + assert.Equal(t, "", u.Host) + assert.Equal(t, "", u.Scheme) + assert.Equal(t, "foo/bar", u.Path) + + u, err = ParseURL("/foo/bar") + assert.NoError(t, err) + assert.Equal(t, "", u.Host) + assert.Equal(t, "https", u.Scheme) + assert.Equal(t, "/foo/bar", u.Path) + + // this case is unintuitive, but to ambiguous to be handled differently + u, err = ParseURL("gitea.com") + assert.NoError(t, err) + assert.Equal(t, "", u.Host) + assert.Equal(t, "", u.Scheme) + assert.Equal(t, "gitea.com", u.Path) +} diff --git a/modules/task/pull_create.go b/modules/task/pull_create.go index b7505b5..8ed6ec9 100644 --- a/modules/task/pull_create.go +++ b/modules/task/pull_create.go @@ -108,7 +108,7 @@ func GetDefaultPRHead(localRepo *local_git.TeaRepo) (owner, branch string, err e if err != nil { return } - owner, _ = utils.GetOwnerAndRepo(strings.TrimLeft(url.Path, "/"), "") + owner, _ = utils.GetOwnerAndRepo(url.Path, "") return } diff --git a/modules/task/repo_clone.go b/modules/task/repo_clone.go new file mode 100644 index 0000000..bc164fc --- /dev/null +++ b/modules/task/repo_clone.go @@ -0,0 +1,93 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package task + +import ( + "fmt" + "net/url" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/config" + local_git "code.gitea.io/tea/modules/git" + + "github.com/go-git/go-git/v5" + git_config "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" +) + +// RepoClone creates a local git clone in the given path, and sets up upstream remote +// for fork repos, for good usability with tea. +func RepoClone( + path string, + login *config.Login, + repoOwner, repoName string, + callback func(string) (string, error), + depth int, +) (*local_git.TeaRepo, error) { + + repoMeta, _, err := login.Client().GetRepo(repoOwner, repoName) + if err != nil { + return nil, err + } + + originURL, err := cloneURL(repoMeta, login) + if err != nil { + return nil, err + } + + auth, err := local_git.GetAuthForURL(originURL, login.Token, login.SSHKey, callback) + if err != nil { + return nil, err + } + + // default path behaviour as native git + if path == "" { + path = repoName + } + + repo, err := git.PlainClone(path, false, &git.CloneOptions{ + URL: originURL.String(), + Auth: auth, + Depth: depth, + InsecureSkipTLS: login.Insecure, + }) + if err != nil { + return nil, err + } + + // set up upstream remote for forks + if repoMeta.Fork && repoMeta.Parent != nil { + upstreamURL, err := cloneURL(repoMeta.Parent, login) + if err != nil { + return nil, err + } + upstreamBranch := repoMeta.Parent.DefaultBranch + repo.CreateRemote(&git_config.RemoteConfig{ + Name: "upstream", + URLs: []string{upstreamURL.String()}, + }) + repoConf, err := repo.Config() + if err != nil { + return nil, err + } + if b, ok := repoConf.Branches[upstreamBranch]; ok { + b.Remote = "upstream" + b.Merge = plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", upstreamBranch)) + } + if err = repo.SetConfig(repoConf); err != nil { + return nil, err + } + } + + return &local_git.TeaRepo{Repository: repo}, nil +} + +func cloneURL(repo *gitea.Repository, login *config.Login) (*url.URL, error) { + urlStr := repo.CloneURL + if login.SSHKey != "" { + urlStr = repo.SSHURL + } + return local_git.ParseURL(urlStr) +} diff --git a/modules/utils/parse.go b/modules/utils/parse.go index 41e88ae..63fd19e 100644 --- a/modules/utils/parse.go +++ b/modules/utils/parse.go @@ -33,7 +33,7 @@ func GetOwnerAndRepo(repoPath, user string) (string, string) { if len(repoPath) == 0 { return "", "" } - p := strings.Split(repoPath, "/") + p := strings.Split(strings.TrimLeft(repoPath, "/"), "/") if len(p) >= 2 { return p[0], p[1] }