diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 6d6eeef30..640fee632 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -1,16 +1,20 @@ package create import ( + "bytes" "context" "errors" "fmt" + "io" "net/http" "os/exec" "path/filepath" "sort" "strings" + "time" "github.com/MakeNowJust/heredoc" + "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" @@ -21,6 +25,10 @@ import ( "github.com/spf13/cobra" ) +type errWithExitCode interface { + ExitCode() int +} + type iprompter interface { Input(string, string) (string, error) Select(string, string, []string) (int, error) @@ -33,6 +41,7 @@ type CreateOptions struct { Config func() (config.Config, error) IO *iostreams.IOStreams Prompter iprompter + BackOff backoff.BackOff Name string Description string @@ -387,17 +396,12 @@ func createFromScratch(opts *CreateOptions) error { remoteURL := ghrepo.FormatRemoteURL(repo, protocol) - if opts.LicenseTemplate == "" && opts.GitIgnoreTemplate == "" { + if opts.LicenseTemplate == "" && opts.GitIgnoreTemplate == "" && opts.Template == "" { // cloning empty repository or template - checkoutBranch := "" - if opts.Template != "" { - // use the template's default branch - checkoutBranch = templateRepoMainBranch - } - if err := localInit(opts.GitClient, remoteURL, repo.RepoName(), checkoutBranch); err != nil { + if err := localInit(opts.GitClient, remoteURL, repo.RepoName()); err != nil { return err } - } else if _, err := opts.GitClient.Clone(context.Background(), remoteURL, []string{}); err != nil { + } else if err := cloneWithRetry(opts, remoteURL, templateRepoMainBranch); err != nil { return err } } @@ -563,6 +567,33 @@ func createFromLocal(opts *CreateOptions) error { return nil } +func cloneWithRetry(opts *CreateOptions, remoteURL, branch string) error { + // Allow injecting alternative BackOff in tests. + if opts.BackOff == nil { + opts.BackOff = backoff.NewConstantBackOff(3 * time.Second) + } + + var args []string + if branch != "" { + args = append(args, "--branch", branch) + } + + ctx := context.Background() + return backoff.Retry(func() error { + stderr := &bytes.Buffer{} + _, err := opts.GitClient.Clone(ctx, remoteURL, args, git.WithStderr(stderr)) + + var execError errWithExitCode + if errors.As(err, &execError) && execError.ExitCode() == 128 { + return err + } else { + _, _ = io.Copy(opts.IO.ErrOut, stderr) + } + + return backoff.Permanent(err) + }, backoff.WithContext(backoff.WithMaxRetries(opts.BackOff, 3), ctx)) +} + func sourceInit(gitClient *git.Client, io *iostreams.IOStreams, remoteURL, baseRemote string) error { cs := io.ColorScheme() remoteAdd, err := gitClient.Command(context.Background(), "remote", "add", baseRemote, remoteURL) @@ -620,7 +651,7 @@ func isLocalRepo(gitClient *git.Client) (bool, error) { } // clone the checkout branch to specified path -func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) error { +func localInit(gitClient *git.Client, remoteURL, path string) error { ctx := context.Background() gitInit, err := gitClient.Command(ctx, "init", path) if err != nil { @@ -644,17 +675,7 @@ func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) er return err } - if checkoutBranch == "" { - return nil - } - - refspec := fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch) - err = gc.Fetch(ctx, "origin", refspec) - if err != nil { - return err - } - - return gc.CheckoutBranch(ctx, checkoutBranch) + return nil } func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) { diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 4ba48990b..5b5fd72bb 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -6,6 +6,7 @@ import ( "net/http" "testing" + "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/prompter" @@ -119,6 +120,18 @@ func TestNewCmdCreate(t *testing.T) { IncludeAllBranches: true, }, }, + { + name: "template with .gitignore", + cli: "template-repo --template mytemplate --gitignore ../.gitignore --public", + wantsErr: true, + errMsg: ".gitignore and license templates are not added when template is provided", + }, + { + name: "template with license", + cli: "template-repo --template mytemplate --license ../.license --public", + wantsErr: true, + errMsg: ".gitignore and license templates are not added when template is provided", + }, } for _, tt := range tests { @@ -557,6 +570,98 @@ func Test_createRun(t *testing.T) { }, wantStdout: "https://github.com/OWNER/REPO\n", }, + { + name: "noninteractive clone from scratch", + opts: &CreateOptions{ + Interactive: false, + Name: "REPO", + Visibility: "PRIVATE", + Clone: true, + }, + tty: false, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.StringResponse(` + { + "data": { + "createRepository": { + "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login":"OWNER"}, + "url": "https://github.com/OWNER/REPO" + } + } + } + }`)) + }, + execStubs: func(cs *run.CommandStubber) { + cs.Register(`git init REPO`, 0, "") + cs.Register(`git -C REPO remote add origin https://github.com/OWNER/REPO`, 0, "") + }, + wantStdout: "https://github.com/OWNER/REPO\n", + }, + { + name: "noninteractive create from template with retry", + opts: &CreateOptions{ + Interactive: false, + Name: "REPO", + Visibility: "PRIVATE", + Clone: true, + Template: "mytemplate", + BackOff: &backoff.ZeroBackOff{}, + }, + tty: false, + httpStubs: func(reg *httpmock.Registry) { + // Test resolving repo owner from repo name only. + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"OWNER"}}}`)) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.GraphQLQuery(`{ + "data": { + "repository": { + "id": "REPOID", + "defaultBranchRef": { + "name": "main" + } + } + } + }`, func(s string, m map[string]interface{}) { + assert.Equal(t, "OWNER", m["owner"]) + assert.Equal(t, "mytemplate", m["name"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"id":"OWNERID"}}}`)) + reg.Register( + httpmock.GraphQL(`mutation CloneTemplateRepository\b`), + httpmock.GraphQLMutation(` + { + "data": { + "cloneTemplateRepository": { + "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login":"OWNER"}, + "url": "https://github.com/OWNER/REPO" + } + } + } + }`, func(m map[string]interface{}) { + assert.Equal(t, "REPOID", m["repositoryId"]) + })) + }, + execStubs: func(cs *run.CommandStubber) { + // fatal: Remote branch main not found in upstream origin + cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 128, "") + cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 0, "") + }, + wantStdout: "https://github.com/OWNER/REPO\n", + }, } for _, tt := range tests { prompterMock := &prompter.PrompterMock{}