When cloning a forked repository, `gh repo clone` automatically adds the parent repo as an `upstream` remote and sets it as the default repository. This can be problematic when the user lacks access to the parent repo, the upstream fetch is expensive for large repos, or the user simply doesn't want the upstream remote. Add a `--no-upstream` flag that skips adding the upstream remote when cloning a fork. When used, origin (the fork) is set as the default repository instead. The flag is mutually exclusive with `--upstream-remote-name`. For non-fork repos the flag is a harmless no-op. Closes #8274
477 lines
12 KiB
Go
477 lines
12 KiB
Go
package clone
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"github.com/cli/cli/v2/git"
|
|
"github.com/cli/cli/v2/internal/config"
|
|
"github.com/cli/cli/v2/internal/gh"
|
|
"github.com/cli/cli/v2/internal/run"
|
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
|
"github.com/cli/cli/v2/pkg/httpmock"
|
|
"github.com/cli/cli/v2/pkg/iostreams"
|
|
"github.com/cli/cli/v2/test"
|
|
"github.com/google/shlex"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNewCmdClone(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
args string
|
|
wantOpts CloneOptions
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "no arguments",
|
|
args: "",
|
|
wantErr: "cannot clone: repository argument required",
|
|
},
|
|
{
|
|
name: "repo argument",
|
|
args: "OWNER/REPO",
|
|
wantOpts: CloneOptions{
|
|
Repository: "OWNER/REPO",
|
|
GitArgs: []string{},
|
|
},
|
|
},
|
|
{
|
|
name: "directory argument",
|
|
args: "OWNER/REPO mydir",
|
|
wantOpts: CloneOptions{
|
|
Repository: "OWNER/REPO",
|
|
GitArgs: []string{"mydir"},
|
|
},
|
|
},
|
|
{
|
|
name: "git clone arguments",
|
|
args: "OWNER/REPO -- --depth 1 --recurse-submodules",
|
|
wantOpts: CloneOptions{
|
|
Repository: "OWNER/REPO",
|
|
GitArgs: []string{"--depth", "1", "--recurse-submodules"},
|
|
},
|
|
},
|
|
{
|
|
name: "no-upstream flag",
|
|
args: "OWNER/REPO --no-upstream",
|
|
wantOpts: CloneOptions{
|
|
Repository: "OWNER/REPO",
|
|
GitArgs: []string{},
|
|
NoUpstream: true,
|
|
},
|
|
},
|
|
{
|
|
name: "no-upstream with upstream-remote-name",
|
|
args: "OWNER/REPO --no-upstream --upstream-remote-name test",
|
|
wantErr: "if any flags in the group [upstream-remote-name no-upstream] are set none of the others can be; [no-upstream upstream-remote-name] were all set",
|
|
},
|
|
{
|
|
name: "unknown argument",
|
|
args: "OWNER/REPO --depth 1",
|
|
wantErr: "unknown flag: --depth\nSeparate git clone flags with '--'.",
|
|
},
|
|
}
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ios, stdin, stdout, stderr := iostreams.Test()
|
|
fac := &cmdutil.Factory{IOStreams: ios}
|
|
|
|
var opts *CloneOptions
|
|
cmd := NewCmdClone(fac, func(co *CloneOptions) error {
|
|
opts = co
|
|
return nil
|
|
})
|
|
|
|
argv, err := shlex.Split(tt.args)
|
|
require.NoError(t, err)
|
|
cmd.SetArgs(argv)
|
|
|
|
cmd.SetIn(stdin)
|
|
cmd.SetOut(stderr)
|
|
cmd.SetErr(stderr)
|
|
|
|
_, err = cmd.ExecuteC()
|
|
if tt.wantErr != "" {
|
|
assert.EqualError(t, err, tt.wantErr)
|
|
return
|
|
} else {
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
assert.Equal(t, "", stdout.String())
|
|
assert.Equal(t, "", stderr.String())
|
|
|
|
assert.Equal(t, tt.wantOpts.Repository, opts.Repository)
|
|
assert.Equal(t, tt.wantOpts.GitArgs, opts.GitArgs)
|
|
assert.Equal(t, tt.wantOpts.NoUpstream, opts.NoUpstream)
|
|
})
|
|
}
|
|
}
|
|
|
|
func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) {
|
|
ios, stdin, stdout, stderr := iostreams.Test()
|
|
fac := &cmdutil.Factory{
|
|
IOStreams: ios,
|
|
HttpClient: func() (*http.Client, error) {
|
|
return httpClient, nil
|
|
},
|
|
Config: func() (gh.Config, error) {
|
|
return config.NewBlankConfig(), nil
|
|
},
|
|
GitClient: &git.Client{
|
|
GhPath: "some/path/gh",
|
|
GitPath: "some/path/git",
|
|
},
|
|
}
|
|
|
|
cmd := NewCmdClone(fac, nil)
|
|
|
|
argv, err := shlex.Split(cli)
|
|
cmd.SetArgs(argv)
|
|
|
|
cmd.SetIn(stdin)
|
|
cmd.SetOut(stderr)
|
|
cmd.SetErr(stderr)
|
|
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
_, err = cmd.ExecuteC()
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &test.CmdOut{OutBuf: stdout, ErrBuf: stderr}, nil
|
|
}
|
|
|
|
func Test_RepoClone(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args string
|
|
want string
|
|
}{
|
|
{
|
|
name: "shorthand",
|
|
args: "OWNER/REPO",
|
|
want: "git clone https://github.com/OWNER/REPO.git",
|
|
},
|
|
{
|
|
name: "shorthand with directory",
|
|
args: "OWNER/REPO target_directory",
|
|
want: "git clone https://github.com/OWNER/REPO.git target_directory",
|
|
},
|
|
{
|
|
name: "clone arguments",
|
|
args: "OWNER/REPO -- -o upstream --depth 1",
|
|
want: "git clone -o upstream --depth 1 https://github.com/OWNER/REPO.git",
|
|
},
|
|
{
|
|
name: "clone arguments with directory",
|
|
args: "OWNER/REPO target_directory -- -o upstream --depth 1",
|
|
want: "git clone -o upstream --depth 1 https://github.com/OWNER/REPO.git target_directory",
|
|
},
|
|
{
|
|
name: "HTTPS URL",
|
|
args: "https://github.com/OWNER/REPO",
|
|
want: "git clone https://github.com/OWNER/REPO.git",
|
|
},
|
|
{
|
|
name: "HTTPS URL with extra path parts",
|
|
args: "https://github.com/OWNER/REPO/extra/part?key=value#fragment",
|
|
want: "git clone https://github.com/OWNER/REPO.git",
|
|
},
|
|
{
|
|
name: "SSH URL",
|
|
args: "git@github.com:OWNER/REPO.git",
|
|
want: "git clone git@github.com:OWNER/REPO.git",
|
|
},
|
|
{
|
|
name: "Non-canonical capitalization",
|
|
args: "Owner/Repo",
|
|
want: "git clone https://github.com/OWNER/REPO.git",
|
|
},
|
|
{
|
|
name: "clone wiki",
|
|
args: "Owner/Repo.wiki",
|
|
want: "git clone https://github.com/OWNER/REPO.wiki.git",
|
|
},
|
|
{
|
|
name: "wiki URL",
|
|
args: "https://github.com/owner/repo.wiki",
|
|
want: "git clone https://github.com/OWNER/REPO.wiki.git",
|
|
},
|
|
{
|
|
name: "wiki URL with extra path parts",
|
|
args: "https://github.com/owner/repo.wiki/extra/path?key=value#fragment",
|
|
want: "git clone https://github.com/OWNER/REPO.wiki.git",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"name": "REPO",
|
|
"owner": {
|
|
"login": "OWNER"
|
|
},
|
|
"hasWikiEnabled": true
|
|
} } }
|
|
`))
|
|
|
|
httpClient := &http.Client{Transport: reg}
|
|
|
|
cs, restore := run.Stub()
|
|
defer restore(t)
|
|
cs.Register(tt.want, 0, "")
|
|
|
|
output, err := runCloneCommand(httpClient, tt.args)
|
|
if err != nil {
|
|
t.Fatalf("error running command `repo clone`: %v", err)
|
|
}
|
|
|
|
assert.Equal(t, "", output.String())
|
|
assert.Equal(t, "", output.Stderr())
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_RepoClone_hasParent(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"name": "REPO",
|
|
"owner": {
|
|
"login": "OWNER"
|
|
},
|
|
"parent": {
|
|
"name": "ORIG",
|
|
"owner": {
|
|
"login": "hubot"
|
|
},
|
|
"defaultBranchRef": {
|
|
"name": "trunk"
|
|
}
|
|
}
|
|
} } }
|
|
`))
|
|
|
|
httpClient := &http.Client{Transport: reg}
|
|
|
|
cs, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
cs.Register(`git clone https://github.com/OWNER/REPO.git`, 0, "")
|
|
cs.Register(`git -C REPO remote add -t trunk upstream https://github.com/hubot/ORIG.git`, 0, "")
|
|
cs.Register(`git -C REPO fetch upstream`, 0, "")
|
|
cs.Register(`git -C REPO remote set-branches upstream *`, 0, "")
|
|
cs.Register(`git -C REPO config --add remote.upstream.gh-resolved base`, 0, "")
|
|
|
|
_, err := runCloneCommand(httpClient, "OWNER/REPO")
|
|
if err != nil {
|
|
t.Fatalf("error running command `repo clone`: %v", err)
|
|
}
|
|
}
|
|
|
|
func Test_RepoClone_hasParent_upstreamRemoteName(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"name": "REPO",
|
|
"owner": {
|
|
"login": "OWNER"
|
|
},
|
|
"parent": {
|
|
"name": "ORIG",
|
|
"owner": {
|
|
"login": "hubot"
|
|
},
|
|
"defaultBranchRef": {
|
|
"name": "trunk"
|
|
}
|
|
}
|
|
} } }
|
|
`))
|
|
|
|
httpClient := &http.Client{Transport: reg}
|
|
|
|
cs, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
cs.Register(`git clone https://github.com/OWNER/REPO.git`, 0, "")
|
|
cs.Register(`git -C REPO remote add -t trunk test https://github.com/hubot/ORIG.git`, 0, "")
|
|
cs.Register(`git -C REPO fetch test`, 0, "")
|
|
cs.Register(`git -C REPO remote set-branches test *`, 0, "")
|
|
cs.Register(`git -C REPO config --add remote.test.gh-resolved base`, 0, "")
|
|
|
|
_, err := runCloneCommand(httpClient, "OWNER/REPO --upstream-remote-name test")
|
|
if err != nil {
|
|
t.Fatalf("error running command `repo clone`: %v", err)
|
|
}
|
|
}
|
|
|
|
func Test_RepoClone_withoutUsername(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query UserCurrent\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "viewer": {
|
|
"login": "OWNER"
|
|
}}}`))
|
|
reg.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"name": "REPO",
|
|
"owner": {
|
|
"login": "OWNER"
|
|
}
|
|
} } }
|
|
`))
|
|
|
|
httpClient := &http.Client{Transport: reg}
|
|
|
|
cs, restore := run.Stub()
|
|
defer restore(t)
|
|
cs.Register(`git clone https://github\.com/OWNER/REPO\.git`, 0, "")
|
|
|
|
output, err := runCloneCommand(httpClient, "REPO")
|
|
if err != nil {
|
|
t.Fatalf("error running command `repo clone`: %v", err)
|
|
}
|
|
|
|
assert.Equal(t, "", output.String())
|
|
assert.Equal(t, "", output.Stderr())
|
|
}
|
|
|
|
func Test_RepoClone_hasParent_noUpstream(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"name": "REPO",
|
|
"owner": {
|
|
"login": "OWNER"
|
|
},
|
|
"parent": {
|
|
"name": "ORIG",
|
|
"owner": {
|
|
"login": "hubot"
|
|
},
|
|
"defaultBranchRef": {
|
|
"name": "trunk"
|
|
}
|
|
}
|
|
} } }
|
|
`))
|
|
|
|
httpClient := &http.Client{Transport: reg}
|
|
|
|
cs, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
cs.Register(`git clone https://github.com/OWNER/REPO.git`, 0, "")
|
|
cs.Register(`git -C REPO config --add remote.origin.gh-resolved base`, 0, "")
|
|
|
|
_, err := runCloneCommand(httpClient, "OWNER/REPO --no-upstream")
|
|
if err != nil {
|
|
t.Fatalf("error running command `repo clone`: %v", err)
|
|
}
|
|
}
|
|
|
|
func Test_RepoClone_noParent_noUpstream(t *testing.T) {
|
|
reg := &httpmock.Registry{}
|
|
defer reg.Verify(t)
|
|
reg.Register(
|
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
|
httpmock.StringResponse(`
|
|
{ "data": { "repository": {
|
|
"name": "REPO",
|
|
"owner": {
|
|
"login": "OWNER"
|
|
}
|
|
} } }
|
|
`))
|
|
|
|
httpClient := &http.Client{Transport: reg}
|
|
|
|
cs, cmdTeardown := run.Stub()
|
|
defer cmdTeardown(t)
|
|
|
|
cs.Register(`git clone https://github.com/OWNER/REPO.git`, 0, "")
|
|
|
|
_, err := runCloneCommand(httpClient, "OWNER/REPO --no-upstream")
|
|
if err != nil {
|
|
t.Fatalf("error running command `repo clone`: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSimplifyURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
expectedRaw string
|
|
}{
|
|
{
|
|
name: "empty",
|
|
raw: "",
|
|
expectedRaw: "",
|
|
},
|
|
{
|
|
name: "no change, no path",
|
|
raw: "https://github.com",
|
|
expectedRaw: "https://github.com",
|
|
},
|
|
{
|
|
name: "no change, single part path",
|
|
raw: "https://github.com/owner",
|
|
expectedRaw: "https://github.com/owner",
|
|
},
|
|
{
|
|
name: "no change, two-part path",
|
|
raw: "https://github.com/owner/repo",
|
|
expectedRaw: "https://github.com/owner/repo",
|
|
},
|
|
{
|
|
name: "no change, three-part path",
|
|
raw: "https://github.com/owner/repo/pulls",
|
|
expectedRaw: "https://github.com/owner/repo",
|
|
},
|
|
{
|
|
name: "no change, two-part path, with query, with fragment",
|
|
raw: "https://github.com/owner/repo?key=value#fragment",
|
|
expectedRaw: "https://github.com/owner/repo",
|
|
},
|
|
{
|
|
name: "no change, single part path, with query, with fragment",
|
|
raw: "https://github.com/owner?key=value#fragment",
|
|
expectedRaw: "https://github.com/owner",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
u, err := url.Parse(tt.raw)
|
|
require.NoError(t, err)
|
|
result := simplifyURL(u)
|
|
assert.Equal(t, tt.expectedRaw, result.String())
|
|
})
|
|
}
|
|
}
|