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.CmdOpen,
|
||||||
&cmd.CmdNotifications,
|
&cmd.CmdNotifications,
|
||||||
|
&cmd.CmdRepoClone,
|
||||||
}
|
}
|
||||||
app.EnableBashCompletion = true
|
app.EnableBashCompletion = true
|
||||||
err := app.Run(os.Args)
|
err := app.Run(os.Args)
|
||||||
|
|
|
@ -111,6 +111,25 @@ func GetLoginByToken(token string) *Login {
|
||||||
return nil
|
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
|
// DeleteLogin delete a login by name from config
|
||||||
func DeleteLogin(name string) error {
|
func DeleteLogin(name string) error {
|
||||||
var idx = -1
|
var idx = -1
|
||||||
|
|
|
@ -22,13 +22,18 @@ type URLParser struct {
|
||||||
func (p *URLParser) Parse(rawURL string) (u *url.URL, err error) {
|
func (p *URLParser) Parse(rawURL string) (u *url.URL, err error) {
|
||||||
rawURL = strings.TrimSpace(rawURL)
|
rawURL = strings.TrimSpace(rawURL)
|
||||||
|
|
||||||
// convert the weird git ssh url format to a canonical url:
|
if !protocolRe.MatchString(rawURL) {
|
||||||
// git@gitea.com:gitea/tea -> ssh://git@gitea.com/gitea/tea
|
// convert the weird git ssh url format to a canonical url:
|
||||||
if !protocolRe.MatchString(rawURL) &&
|
// git@gitea.com:gitea/tea -> ssh://git@gitea.com/gitea/tea
|
||||||
strings.Contains(rawURL, ":") &&
|
if strings.Contains(rawURL, ":") &&
|
||||||
// not a Windows path
|
// not a Windows path
|
||||||
!strings.Contains(rawURL, "\\") {
|
!strings.Contains(rawURL, "\\") {
|
||||||
rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1)
|
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)
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
owner, _ = utils.GetOwnerAndRepo(strings.TrimLeft(url.Path, "/"), "")
|
owner, _ = utils.GetOwnerAndRepo(url.Path, "")
|
||||||
return
|
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 {
|
if len(repoPath) == 0 {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
p := strings.Split(repoPath, "/")
|
p := strings.Split(strings.TrimLeft(repoPath, "/"), "/")
|
||||||
if len(p) >= 2 {
|
if len(p) >= 2 {
|
||||||
return p[0], p[1]
|
return p[0], p[1]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue