cli/pkg/cmd/repo/clone/clone_test.go
4RH1T3CT0R7 fa95f3a21b Add --no-upstream flag to gh repo clone
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
2026-02-14 20:31:02 +03:00

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())
})
}
}