Add `tea clone` (#411)
Adds a new subcommand to clone repos: ``` tea clone --login try --depth 1 norwin/test tea clone gitea/tea tea clone noerw/tea # will set up `master` to track `upstream` remote tea clone try.gitea.io/noerw/test # will automatically set --login ``` This is just a replacement for `git clone` with small benefits: - [x] does not depend on `git`, as tea ships with go-git - [x] spares you typing of URLs and autoselects https/ssh based on your login config - [x] forked repos: set up origin + upstream remote Co-authored-by: Norwin <git@nroo.de> Co-authored-by: techknowlogick <techknowlogick@gitea.io> Reviewed-on: https://gitea.com/gitea/tea/pulls/411 Reviewed-by: Andrew Thornton <art27@cantab.net> Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: Norwin <noerw@noreply.gitea.io> Co-committed-by: Norwin <noerw@noreply.gitea.io>
This commit is contained in:
parent
78a95f1ca4
commit
819cc1ab21
|
@ -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: "<repo-slug> [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
|
||||
}
|
1
main.go
1
main.go
|
@ -49,6 +49,7 @@ func main() {
|
|||
|
||||
&cmd.CmdOpen,
|
||||
&cmd.CmdNotifications,
|
||||
&cmd.CmdRepoClone,
|
||||
}
|
||||
app.EnableBashCompletion = true
|
||||
err := app.Run(os.Args)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue