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
This commit is contained in:
4RH1T3CT0R7 2026-02-14 20:31:02 +03:00
parent 1af2823fc3
commit fa95f3a21b
2 changed files with 117 additions and 24 deletions

View file

@ -27,6 +27,7 @@ type CloneOptions struct {
GitArgs []string
Repository string
UpstreamName string
NoUpstream bool
}
func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Command {
@ -60,6 +61,8 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm
the remote after the owner of the parent repository.
If the repository is a fork, its parent repository will be set as the default remote repository.
To skip adding the upstream remote entirely, use %[1]s--no-upstream%[1]s.
`, "`"),
Example: heredoc.Doc(`
# Clone a repository from a specific org
@ -77,6 +80,9 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm
# Clone a repository with additional git clone flags
$ gh repo clone cli/cli -- --depth=1
# Clone a fork without adding an upstream remote
$ gh repo clone myfork --no-upstream
`),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Repository = args[0]
@ -91,6 +97,8 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm
}
cmd.Flags().StringVarP(&opts.UpstreamName, "upstream-remote-name", "u", "upstream", "Upstream remote name when cloning a fork")
cmd.Flags().BoolVar(&opts.NoUpstream, "no-upstream", false, "Do not add an upstream remote when cloning a fork")
cmd.MarkFlagsMutuallyExclusive("upstream-remote-name", "no-upstream")
cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
if err == pflag.ErrHelp {
return err
@ -187,37 +195,43 @@ func cloneRun(opts *CloneOptions) error {
// If the repo is a fork, add the parent as an upstream remote and set the parent as the default repo.
if canonicalRepo.Parent != nil {
protocol := cfg.GitProtocol(canonicalRepo.Parent.RepoHost()).Value
upstreamURL := ghrepo.FormatRemoteURL(canonicalRepo.Parent, protocol)
upstreamName := opts.UpstreamName
if opts.UpstreamName == "@owner" {
upstreamName = canonicalRepo.Parent.RepoOwner()
}
gc := gitClient.Copy()
gc.RepoDir = cloneDir
if _, err := gc.AddRemote(ctx, upstreamName, upstreamURL, []string{canonicalRepo.Parent.DefaultBranchRef.Name}); err != nil {
return err
}
if opts.NoUpstream {
if err := gc.SetRemoteResolution(ctx, "origin", "base"); err != nil {
return err
}
} else {
protocol := cfg.GitProtocol(canonicalRepo.Parent.RepoHost()).Value
upstreamURL := ghrepo.FormatRemoteURL(canonicalRepo.Parent, protocol)
if err := gc.Fetch(ctx, upstreamName, ""); err != nil {
return err
}
upstreamName := opts.UpstreamName
if opts.UpstreamName == "@owner" {
upstreamName = canonicalRepo.Parent.RepoOwner()
}
if err := gc.SetRemoteBranches(ctx, upstreamName, `*`); err != nil {
return err
}
if _, err := gc.AddRemote(ctx, upstreamName, upstreamURL, []string{canonicalRepo.Parent.DefaultBranchRef.Name}); err != nil {
return err
}
if err = gc.SetRemoteResolution(ctx, upstreamName, "base"); err != nil {
return err
}
if err := gc.Fetch(ctx, upstreamName, ""); err != nil {
return err
}
connectedToTerminal := opts.IO.IsStdoutTTY()
if connectedToTerminal {
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(canonicalRepo.Parent)))
if err := gc.SetRemoteBranches(ctx, upstreamName, `*`); err != nil {
return err
}
if err := gc.SetRemoteResolution(ctx, upstreamName, "base"); err != nil {
return err
}
connectedToTerminal := opts.IO.IsStdoutTTY()
if connectedToTerminal {
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(canonicalRepo.Parent)))
}
}
}
return nil

View file

@ -54,6 +54,20 @@ func TestNewCmdClone(t *testing.T) {
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",
@ -92,6 +106,7 @@ func TestNewCmdClone(t *testing.T) {
assert.Equal(t, tt.wantOpts.Repository, opts.Repository)
assert.Equal(t, tt.wantOpts.GitArgs, opts.GitArgs)
assert.Equal(t, tt.wantOpts.NoUpstream, opts.NoUpstream)
})
}
}
@ -344,6 +359,70 @@ func Test_RepoClone_withoutUsername(t *testing.T) {
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