From 36436cb8b8afd1adbe77dc2ae70fd2560d75715c Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Mon, 13 Mar 2023 15:15:38 -0700 Subject: [PATCH] Retry fetching repo from template Fixes #7055 --- pkg/cmd/repo/create/create.go | 53 +++++++++------- pkg/cmd/repo/create/create_test.go | 98 ++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 22 deletions(-) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 6d6eeef30..105c7f130 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -9,8 +9,10 @@ import ( "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 +23,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 +39,7 @@ type CreateOptions struct { Config func() (config.Config, error) IO *iostreams.IOStreams Prompter iprompter + BackOff backoff.BackOff Name string Description string @@ -328,7 +335,6 @@ func createFromScratch(opts *CreateOptions) error { InitReadme: opts.AddReadme, } - var templateRepoMainBranch string if opts.Template != "" { var templateRepo ghrepo.Interface apiClient := api.NewClientFromHTTP(httpClient) @@ -352,7 +358,6 @@ func createFromScratch(opts *CreateOptions) error { } input.TemplateRepositoryID = repo.ID - templateRepoMainBranch = repo.DefaultBranchRef.Name } repo, err := repoCreate(httpClient, repoToCreate.RepoHost(), input) @@ -387,17 +392,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); err != nil { return err } } @@ -563,6 +563,25 @@ func createFromLocal(opts *CreateOptions) error { return nil } +func cloneWithRetry(opts *CreateOptions, remoteURL string) error { + // Allow injecting alternative BackOff in tests. + if opts.BackOff == nil { + opts.BackOff = backoff.NewConstantBackOff(3 * time.Second) + } + + ctx := context.Background() + return backoff.Retry(func() error { + _, err := opts.GitClient.Clone(ctx, remoteURL, []string{}) + + var execError errWithExitCode + if errors.As(err, &execError) && execError.ExitCode() == 128 { + return err + } + + 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 +639,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 +663,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..d773b06f5 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -119,6 +119,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 +569,92 @@ 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", + opts: &CreateOptions{ + Interactive: false, + Name: "REPO", + Visibility: "PRIVATE", + Clone: true, + Template: "mytemplate", + }, + 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" + } + } + }`, 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) { + cs.Register(`git clone https://github.com/OWNER/REPO`, 0, "") + }, + wantStdout: "https://github.com/OWNER/REPO\n", + }, } for _, tt := range tests { prompterMock := &prompter.PrompterMock{}