diff --git a/git/client.go b/git/client.go index 1a6d9ae7f..19688ca51 100644 --- a/git/client.go +++ b/git/client.go @@ -377,19 +377,36 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, } // ReadBranchConfig parses the `branch.BRANCH.(remote|merge|gh-merge-base)` part of git config. -func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg BranchConfig) { +// If no branch config is found or there is an error in the command, it returns an empty BranchConfig. +// Downstream consumers of ReadBranchConfig should consider the behavior they desire if this errors, +// as an empty config is not necessarily breaking. +func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchConfig, error) { + prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch)) args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge|%s)$", prefix, MergeBaseConfig)} cmd, err := c.Command(ctx, args...) if err != nil { - return - } - out, err := cmd.Output() - if err != nil { - return + return BranchConfig{}, err } - for _, line := range outputLines(out) { + out, err := cmd.Output() + if err != nil { + // This is the error we expect if the git command does not run successfully. + // If the ExitCode is 1, then we just didn't find any config for the branch. + var gitError *GitError + if ok := errors.As(err, &gitError); ok && gitError.ExitCode != 1 { + return BranchConfig{}, err + } + return BranchConfig{}, nil + } + + return parseBranchConfig(outputLines(out)), nil +} + +func parseBranchConfig(configLines []string) BranchConfig { + var cfg BranchConfig + + for _, line := range configLines { parts := strings.SplitN(line, " ", 2) if len(parts) < 2 { continue @@ -412,7 +429,7 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg Branc cfg.MergeBase = parts[1] } } - return + return cfg } // SetBranchConfig sets the named value on the given branch. diff --git a/git/client_test.go b/git/client_test.go index fff1397f3..fe3047db9 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "os/exec" "path/filepath" @@ -730,14 +731,35 @@ func TestClientReadBranchConfig(t *testing.T) { cmdExitStatus int cmdStdout string cmdStderr string - wantCmdArgs string + branch string wantBranchConfig BranchConfig + wantError *GitError }{ { name: "read branch config", + cmdExitStatus: 0, cmdStdout: "branch.trunk.remote origin\nbranch.trunk.merge refs/heads/trunk\nbranch.trunk.gh-merge-base trunk", - wantCmdArgs: `path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|gh-merge-base)$`, + branch: "trunk", wantBranchConfig: BranchConfig{RemoteName: "origin", MergeRef: "refs/heads/trunk", MergeBase: "trunk"}, + wantError: nil, + }, + { + name: "git config runs successfully but returns no output (Exit Code 1)", + cmdExitStatus: 1, + cmdStdout: "", + cmdStderr: "", + branch: "trunk", + wantBranchConfig: BranchConfig{}, + wantError: nil, + }, + { + name: "output error (Exit Code > 1)", + cmdExitStatus: 2, + cmdStdout: "", + cmdStderr: "git error message", + branch: "trunk", + wantBranchConfig: BranchConfig{}, + wantError: &GitError{}, }, } for _, tt := range tests { @@ -747,9 +769,85 @@ func TestClientReadBranchConfig(t *testing.T) { GitPath: "path/to/git", commandContext: cmdCtx, } - branchConfig := client.ReadBranchConfig(context.Background(), "trunk") - assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) + branchConfig, err := client.ReadBranchConfig(context.Background(), tt.branch) + wantCmdArgs := fmt.Sprintf("path/to/git config --get-regexp ^branch\\.%s\\.(remote|merge|gh-merge-base)$", tt.branch) + assert.Equal(t, wantCmdArgs, strings.Join(cmd.Args[3:], " ")) assert.Equal(t, tt.wantBranchConfig, branchConfig) + if tt.wantError != nil { + assert.ErrorAs(t, err, &tt.wantError) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_parseBranchConfig(t *testing.T) { + tests := []struct { + name string + configLines []string + wantBranchConfig BranchConfig + }{ + { + name: "remote branch", + configLines: []string{"branch.trunk.remote origin"}, + wantBranchConfig: BranchConfig{ + RemoteName: "origin", + }, + }, + { + name: "merge ref", + configLines: []string{"branch.trunk.merge refs/heads/trunk"}, + wantBranchConfig: BranchConfig{ + MergeRef: "refs/heads/trunk", + }, + }, + { + name: "merge base", + configLines: []string{"branch.trunk.gh-merge-base gh-merge-base"}, + wantBranchConfig: BranchConfig{ + MergeBase: "gh-merge-base", + }, + }, + { + name: "remote, merge ref, and merge base all specified", + configLines: []string{ + "branch.trunk.remote origin", + "branch.trunk.merge refs/heads/trunk", + "branch.trunk.gh-merge-base gh-merge-base", + }, + wantBranchConfig: BranchConfig{ + RemoteName: "origin", + MergeRef: "refs/heads/trunk", + MergeBase: "gh-merge-base", + }, + }, + { + name: "remote URL", + configLines: []string{ + "branch.Frederick888/main.remote git@github.com:Frederick888/playground.git", + "branch.Frederick888/main.merge refs/heads/main", + }, + wantBranchConfig: BranchConfig{ + MergeRef: "refs/heads/main", + RemoteURL: &url.URL{ + Scheme: "ssh", + User: url.User("git"), + Host: "github.com", + Path: "/Frederick888/playground.git", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + branchConfig := parseBranchConfig(tt.configLines) + assert.Equal(t, tt.wantBranchConfig.RemoteName, branchConfig.RemoteName) + assert.Equal(t, tt.wantBranchConfig.MergeRef, branchConfig.MergeRef) + assert.Equal(t, tt.wantBranchConfig.MergeBase, branchConfig.MergeBase) + if tt.wantBranchConfig.RemoteURL != nil { + assert.Equal(t, tt.wantBranchConfig.RemoteURL.String(), branchConfig.RemoteURL.String()) + } }) } } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index db160b419..124bd4b07 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -263,17 +263,17 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return cmd } -func createRun(opts *CreateOptions) (err error) { +func createRun(opts *CreateOptions) error { ctx, err := NewCreateContext(opts) if err != nil { - return + return err } client := ctx.Client state, err := NewIssueState(*ctx, *opts) if err != nil { - return + return err } var openURL string @@ -288,15 +288,15 @@ func createRun(opts *CreateOptions) (err error) { } err = handlePush(*opts, *ctx) if err != nil { - return + return err } openURL, err = generateCompareURL(*ctx, *state) if err != nil { - return + return err } if !shared.ValidURL(openURL) { err = fmt.Errorf("cannot open in browser: maximum URL length exceeded") - return + return err } return previewPR(*opts, openURL) } @@ -344,7 +344,7 @@ func createRun(opts *CreateOptions) (err error) { if !opts.EditorMode && (opts.FillVerbose || opts.Autofill || opts.FillFirst || (opts.TitleProvided && opts.BodyProvided)) { err = handlePush(*opts, *ctx) if err != nil { - return + return err } return submitPR(*opts, *ctx, *state) } @@ -368,7 +368,7 @@ func createRun(opts *CreateOptions) (err error) { var template shared.Template template, err = tpl.Select(opts.Template) if err != nil { - return + return err } if state.Title == "" { state.Title = template.Title() @@ -378,18 +378,18 @@ func createRun(opts *CreateOptions) (err error) { state.Title, state.Body, err = opts.TitledEditSurvey(state.Title, state.Body) if err != nil { - return + return err } if state.Title == "" { err = fmt.Errorf("title can't be blank") - return + return err } } else { if !opts.TitleProvided { err = shared.TitleSurvey(opts.Prompter, opts.IO, state) if err != nil { - return + return err } } @@ -403,12 +403,12 @@ func createRun(opts *CreateOptions) (err error) { if opts.Template != "" { template, err = tpl.Select(opts.Template) if err != nil { - return + return err } } else { template, err = tpl.Choose() if err != nil { - return + return err } } @@ -419,13 +419,13 @@ func createRun(opts *CreateOptions) (err error) { err = shared.BodySurvey(opts.Prompter, state, templateContent) if err != nil { - return + return err } } openURL, err = generateCompareURL(*ctx, *state) if err != nil { - return + return err } allowPreview := !state.HasMetadata() && shared.ValidURL(openURL) && !opts.DryRun @@ -444,12 +444,12 @@ func createRun(opts *CreateOptions) (err error) { } err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state) if err != nil { - return + return err } action, err = shared.ConfirmPRSubmission(opts.Prompter, !state.HasMetadata() && !opts.DryRun, false, state.Draft) if err != nil { - return + return err } } } @@ -457,12 +457,12 @@ func createRun(opts *CreateOptions) (err error) { if action == shared.CancelAction { fmt.Fprintln(opts.IO.ErrOut, "Discarding.") err = cmdutil.CancelError - return + return err } err = handlePush(*opts, *ctx) if err != nil { - return + return err } if action == shared.PreviewAction { @@ -479,7 +479,7 @@ func createRun(opts *CreateOptions) (err error) { } err = errors.New("expected to cancel, preview, or submit") - return + return err } var regexPattern = regexp.MustCompile(`(?m)^`) @@ -680,7 +680,10 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { var headRepo ghrepo.Interface var headRemote *ghContext.Remote - headBranchConfig := gitClient.ReadBranchConfig(context.Background(), headBranch) + headBranchConfig, err := gitClient.ReadBranchConfig(context.Background(), headBranch) + if err != nil { + return nil, err + } if isPushEnabled { // determine whether the head branch is already pushed to a remote if trackingRef, found := tryDetermineTrackingRef(gitClient, remotes, headBranch, headBranchConfig); found { diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 6c0ff11e2..b8f5daf66 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1,7 +1,6 @@ package create import ( - ctx "context" "encoding/json" "errors" "fmt" @@ -1626,6 +1625,7 @@ func Test_tryDetermineTrackingRef(t *testing.T) { tests := []struct { name string cmdStubs func(*run.CommandStubber) + headBranchConfig git.BranchConfig remotes context.Remotes expectedTrackingRef trackingRef expectedFound bool @@ -1633,18 +1633,18 @@ func Test_tryDetermineTrackingRef(t *testing.T) { { name: "empty", cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "") cs.Register(`git show-ref --verify -- HEAD`, 0, "abc HEAD") }, + headBranchConfig: git.BranchConfig{}, expectedTrackingRef: trackingRef{}, expectedFound: false, }, { name: "no match", cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "") cs.Register("git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature", 0, "abc HEAD\nbca refs/remotes/upstream/feature") }, + headBranchConfig: git.BranchConfig{}, remotes: context.Remotes{ &context.Remote{ Remote: &git.Remote{Name: "upstream"}, @@ -1661,13 +1661,13 @@ func Test_tryDetermineTrackingRef(t *testing.T) { { name: "match", cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "") cs.Register(`git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature$`, 0, heredoc.Doc(` deadbeef HEAD deadb00f refs/remotes/upstream/feature deadbeef refs/remotes/origin/feature `)) }, + headBranchConfig: git.BranchConfig{}, remotes: context.Remotes{ &context.Remote{ Remote: &git.Remote{Name: "upstream"}, @@ -1687,15 +1687,15 @@ func Test_tryDetermineTrackingRef(t *testing.T) { { name: "respect tracking config", cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, heredoc.Doc(` - branch.feature.remote origin - branch.feature.merge refs/heads/great-feat - `)) cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/great-feat refs/remotes/origin/feature$`, 0, heredoc.Doc(` deadbeef HEAD deadb00f refs/remotes/origin/feature `)) }, + headBranchConfig: git.BranchConfig{ + RemoteName: "origin", + MergeRef: "refs/heads/great-feat", + }, remotes: context.Remotes{ &context.Remote{ Remote: &git.Remote{Name: "origin"}, @@ -1717,8 +1717,8 @@ func Test_tryDetermineTrackingRef(t *testing.T) { GhPath: "some/path/gh", GitPath: "some/path/git", } - headBranchConfig := gitClient.ReadBranchConfig(ctx.Background(), "feature") - ref, found := tryDetermineTrackingRef(gitClient, tt.remotes, "feature", headBranchConfig) + + ref, found := tryDetermineTrackingRef(gitClient, tt.remotes, "feature", tt.headBranchConfig) assert.Equal(t, tt.expectedTrackingRef, ref) assert.Equal(t, tt.expectedFound, found) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index e4a70eb22..3f036c0cd 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -37,7 +37,7 @@ type finder struct { branchFn func() (string, error) remotesFn func() (remotes.Remotes, error) httpClient func() (*http.Client, error) - branchConfig func(string) git.BranchConfig + branchConfig func(string) (git.BranchConfig, error) progress progressIndicator repo ghrepo.Interface @@ -58,7 +58,7 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { remotesFn: factory.Remotes, httpClient: factory.HttpClient, progress: factory.IOStreams, - branchConfig: func(s string) git.BranchConfig { + branchConfig: func(s string) (git.BranchConfig, error) { return factory.GitClient.ReadBranchConfig(context.Background(), s) }, } @@ -238,7 +238,10 @@ func (f *finder) parseCurrentBranch() (string, int, error) { return "", 0, err } - branchConfig := f.branchConfig(prHeadRef) + branchConfig, err := f.branchConfig(prHeadRef) + if err != nil { + return "", 0, err + } // the branch is configured to merge a special PR head ref if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 258ef01e8..dd96e684a 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -10,19 +10,21 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type args struct { + baseRepoFn func() (ghrepo.Interface, error) + branchFn func() (string, error) + branchConfig func(string) (git.BranchConfig, error) + remotesFn func() (context.Remotes, error) + selector string + fields []string + baseBranch string +} + func TestFind(t *testing.T) { - type args struct { - baseRepoFn func() (ghrepo.Interface, error) - branchFn func() (string, error) - branchConfig func(string) git.BranchConfig - remotesFn func() (context.Remotes, error) - selector string - fields []string - baseBranch string - } tests := []struct { name string args args @@ -231,9 +233,7 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: func(branch string) (c git.BranchConfig) { - return - }, + branchConfig: stubBranchConfig(git.BranchConfig{}, nil), }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -265,9 +265,7 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: func(branch string) (c git.BranchConfig) { - return - }, + branchConfig: stubBranchConfig(git.BranchConfig{}, nil), }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -315,11 +313,10 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: func(branch string) (c git.BranchConfig) { - c.MergeRef = "refs/heads/blue-upstream-berries" - c.RemoteName = "origin" - return - }, + branchConfig: stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/heads/blue-upstream-berries", + RemoteName: "origin", + }, nil), remotesFn: func() (context.Remotes, error) { return context.Remotes{{ Remote: &git.Remote{Name: "origin"}, @@ -357,11 +354,12 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: func(branch string) (c git.BranchConfig) { + branchConfig: func(branch string) (git.BranchConfig, error) { u, _ := url.Parse("https://github.com/UPSTREAMOWNER/REPO") - c.MergeRef = "refs/heads/blue-upstream-berries" - c.RemoteURL = u - return + return stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/heads/blue-upstream-berries", + RemoteURL: u, + }, nil)(branch) }, remotesFn: nil, }, @@ -395,10 +393,9 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: func(branch string) (c git.BranchConfig) { - c.RemoteName = "origin" - return - }, + branchConfig: stubBranchConfig(git.BranchConfig{ + RemoteName: "origin", + }, nil), remotesFn: func() (context.Remotes, error) { return context.Remotes{{ Remote: &git.Remote{Name: "origin"}, @@ -436,10 +433,9 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: func(branch string) (c git.BranchConfig) { - c.MergeRef = "refs/pull/13/head" - return - }, + branchConfig: stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/pull/13/head", + }, nil), }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -462,10 +458,9 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: func(branch string) (c git.BranchConfig) { - c.MergeRef = "refs/pull/13/head" - return - }, + branchConfig: stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/pull/13/head", + }, nil), }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -561,3 +556,49 @@ func TestFind(t *testing.T) { }) } } + +func Test_parseCurrentBranch(t *testing.T) { + tests := []struct { + name string + args args + wantSelector string + wantPR int + wantError error + }{ + { + name: "failed branch config", + args: args{ + branchConfig: stubBranchConfig(git.BranchConfig{}, errors.New("branchConfigErr")), + branchFn: func() (string, error) { + return "blueberries", nil + }, + }, + wantSelector: "", + wantPR: 0, + wantError: errors.New("branchConfigErr"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := finder{ + httpClient: func() (*http.Client, error) { + return &http.Client{}, nil + }, + baseRepoFn: tt.args.baseRepoFn, + branchFn: tt.args.branchFn, + branchConfig: tt.args.branchConfig, + remotesFn: tt.args.remotesFn, + } + selector, pr, err := f.parseCurrentBranch() + assert.Equal(t, tt.wantSelector, selector) + assert.Equal(t, tt.wantPR, pr) + assert.Equal(t, tt.wantError, err) + }) + } +} + +func stubBranchConfig(branchConfig git.BranchConfig, err error) func(string) (git.BranchConfig, error) { + return func(branch string) (git.BranchConfig, error) { + return branchConfig, err + } +} diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 27666461d..a97a59e44 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -72,6 +72,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co } func statusRun(opts *StatusOptions) error { + ctx := context.Background() httpClient, err := opts.HttpClient() if err != nil { return err @@ -93,7 +94,11 @@ func statusRun(opts *StatusOptions) error { } remotes, _ := opts.Remotes() - currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(opts.GitClient, baseRepo, currentBranch, remotes) + branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranch) + if err != nil { + return err + } + currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(branchConfig, baseRepo, currentBranch, remotes) if err != nil { return fmt.Errorf("could not query for pull request for current branch: %w", err) } @@ -184,31 +189,42 @@ func statusRun(opts *StatusOptions) error { return nil } -func prSelectorForCurrentBranch(gitClient *git.Client, baseRepo ghrepo.Interface, prHeadRef string, rem ghContext.Remotes) (prNumber int, selector string, err error) { - selector = prHeadRef - branchConfig := gitClient.ReadBranchConfig(context.Background(), prHeadRef) - +func prSelectorForCurrentBranch(branchConfig git.BranchConfig, baseRepo ghrepo.Interface, prHeadRef string, rem ghContext.Remotes) (int, string, error) { // the branch is configured to merge a special PR head ref prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { - prNumber, _ = strconv.Atoi(m[1]) - return + prNumber, err := strconv.Atoi(m[1]) + if err != nil { + return 0, "", err + } + return prNumber, prHeadRef, nil } var branchOwner string if branchConfig.RemoteURL != nil { // the branch merges from a remote specified by URL - if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil { - branchOwner = r.RepoOwner() + r, err := ghrepo.FromURL(branchConfig.RemoteURL) + if err != nil { + // TODO: We aren't returning the error because we discovered that it was shadowed + // before refactoring to its current return pattern. Thus, we aren't confident + // that returning the error won't break existing behavior. + return 0, prHeadRef, nil } + branchOwner = r.RepoOwner() } else if branchConfig.RemoteName != "" { // the branch merges from a remote specified by name - if r, err := rem.FindByName(branchConfig.RemoteName); err == nil { - branchOwner = r.RepoOwner() + r, err := rem.FindByName(branchConfig.RemoteName) + if err != nil { + // TODO: We aren't returning the error because we discovered that it was shadowed + // before refactoring to its current return pattern. Thus, we aren't confident + // that returning the error won't break existing behavior. + return 0, prHeadRef, nil } + branchOwner = r.RepoOwner() } if branchOwner != "" { + selector := prHeadRef if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") { selector = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") } @@ -216,9 +232,10 @@ func prSelectorForCurrentBranch(gitClient *git.Client, baseRepo ghrepo.Interface if !strings.EqualFold(branchOwner, baseRepo.RepoOwner()) { selector = fmt.Sprintf("%s:%s", branchOwner, selector) } + return 0, selector, nil } - return + return 0, prHeadRef, nil } func totalApprovals(pr *api.PullRequest) int { diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index e8e487559..43cce5f4c 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -4,11 +4,11 @@ import ( "bytes" "io" "net/http" + "net/url" "regexp" "strings" "testing" - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" @@ -21,6 +21,7 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/test" "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) { @@ -95,6 +96,11 @@ func TestPRStatus(t *testing.T) { defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatus.json")) + // stub successful git command + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 0, "") + output, err := runCommand(http, "blueberries", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) @@ -120,6 +126,11 @@ func TestPRStatus_reviewsAndChecks(t *testing.T) { // status,conclusion matches the old StatusContextRollup query http.Register(httpmock.GraphQL(`status,conclusion`), httpmock.FileResponse("./fixtures/prStatusChecks.json")) + // stub successful git command + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 0, "") + output, err := runCommand(http, "blueberries", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) @@ -145,6 +156,11 @@ func TestPRStatus_reviewsAndChecksWithStatesByCount(t *testing.T) { // checkRunCount,checkRunCountsByState matches the new StatusContextRollup query http.Register(httpmock.GraphQL(`checkRunCount,checkRunCountsByState`), httpmock.FileResponse("./fixtures/prStatusChecksWithStatesByCount.json")) + // stub successful git command + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 0, "") + output, err := runCommandWithDetector(http, "blueberries", true, "", &fd.EnabledDetectorMock{}) if err != nil { t.Errorf("error running command `pr status`: %v", err) @@ -169,6 +185,11 @@ func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) { defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranch.json")) + // stub successful git command + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 0, "") + output, err := runCommand(http, "blueberries", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) @@ -197,6 +218,11 @@ func TestPRStatus_currentBranch_defaultBranch(t *testing.T) { defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranch.json")) + // stub successful git command + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 0, "") + output, err := runCommand(http, "blueberries", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) @@ -231,6 +257,11 @@ func TestPRStatus_currentBranch_Closed(t *testing.T) { defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchClosed.json")) + // stub successful git command + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 0, "") + output, err := runCommand(http, "blueberries", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) @@ -248,6 +279,11 @@ func TestPRStatus_currentBranch_Closed_defaultBranch(t *testing.T) { defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json")) + // stub successful git command + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 0, "") + output, err := runCommand(http, "blueberries", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) @@ -265,6 +301,11 @@ func TestPRStatus_currentBranch_Merged(t *testing.T) { defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchMerged.json")) + // stub successful git command + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 0, "") + output, err := runCommand(http, "blueberries", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) @@ -282,6 +323,11 @@ func TestPRStatus_currentBranch_Merged_defaultBranch(t *testing.T) { defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json")) + // stub successful git command + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 0, "") + output, err := runCommand(http, "blueberries", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) @@ -299,6 +345,11 @@ func TestPRStatus_blankSlate(t *testing.T) { defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) + // stub successful git command + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 0, "") + output, err := runCommand(http, "blueberries", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) @@ -352,6 +403,11 @@ func TestPRStatus_detachedHead(t *testing.T) { defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) + // stub successful git command + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 0, "") + output, err := runCommand(http, "", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) @@ -375,31 +431,219 @@ Requesting a code review from you } } -func Test_prSelectorForCurrentBranch(t *testing.T) { +func TestPRStatus_error_ReadBranchConfig(t *testing.T) { rs, cleanup := run.Stub() defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 1, "") - rs.Register(`git config --get-regexp \^branch\\.`, 0, heredoc.Doc(` - branch.Frederick888/main.remote git@github.com:Frederick888/playground.git - branch.Frederick888/main.merge refs/heads/main - `)) + _, err := runCommand(initFakeHTTP(), "blueberries", true, "") + assert.Error(t, err) +} - repo := ghrepo.NewWithHost("octocat", "playground", "github.com") - rem := context.Remotes{ - &context.Remote{ - Remote: &git.Remote{Name: "origin"}, - Repo: repo, +func Test_prSelectorForCurrentBranch(t *testing.T) { + tests := []struct { + name string + branchConfig git.BranchConfig + baseRepo ghrepo.Interface + prHeadRef string + remotes context.Remotes + wantPrNumber int + wantSelector string + wantError error + }{ + { + name: "Empty branch config", + branchConfig: git.BranchConfig{}, + prHeadRef: "monalisa/main", + wantPrNumber: 0, + wantSelector: "monalisa/main", + wantError: nil, + }, + { + name: "The branch is configured to merge a special PR head ref", + branchConfig: git.BranchConfig{ + MergeRef: "refs/pull/42/head", + }, + prHeadRef: "monalisa/main", + wantPrNumber: 42, + wantSelector: "monalisa/main", + wantError: nil, + }, + { + name: "Branch merges from a remote specified by URL", + branchConfig: git.BranchConfig{ + RemoteURL: &url.URL{ + Scheme: "ssh", + User: url.User("git"), + Host: "github.com", + Path: "monalisa/playground.git", + }, + }, + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", + remotes: context.Remotes{ + &context.Remote{ + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "monalisa/main", + wantError: nil, + }, + { + name: "Branch merges from a remote specified by name", + branchConfig: git.BranchConfig{ + RemoteName: "upstream", + }, + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", + remotes: context.Remotes{ + &context.Remote{ + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"), + }, + &context.Remote{ + Remote: &git.Remote{Name: "upstream"}, + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "monalisa/main", + wantError: nil, + }, + { + name: "Branch is a fork and merges from a remote specified by URL", + branchConfig: git.BranchConfig{ + RemoteURL: &url.URL{ + Scheme: "ssh", + User: url.User("git"), + Host: "github.com", + Path: "forkName/playground.git", + }, + MergeRef: "refs/heads/main", + }, + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", + remotes: context.Remotes{ + &context.Remote{ + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "forkName:main", + wantError: nil, + }, + { + name: "Branch is a fork and merges from a remote specified by name", + branchConfig: git.BranchConfig{ + RemoteName: "origin", + }, + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", + remotes: context.Remotes{ + &context.Remote{ + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"), + }, + &context.Remote{ + Remote: &git.Remote{Name: "upstream"}, + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "forkName:monalisa/main", + wantError: nil, + }, + { + name: "Branch specifies a mergeRef and merges from a remote specified by name", + branchConfig: git.BranchConfig{ + RemoteName: "upstream", + MergeRef: "refs/heads/main", + }, + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", + remotes: context.Remotes{ + &context.Remote{ + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"), + }, + &context.Remote{ + Remote: &git.Remote{Name: "upstream"}, + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "main", + wantError: nil, + }, + { + name: "Branch is a fork, specifies a mergeRef, and merges from a remote specified by name", + branchConfig: git.BranchConfig{ + RemoteName: "origin", + MergeRef: "refs/heads/main", + }, + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", + remotes: context.Remotes{ + &context.Remote{ + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"), + }, + &context.Remote{ + Remote: &git.Remote{Name: "upstream"}, + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "forkName:main", + wantError: nil, + }, + { + name: "Remote URL errors", + branchConfig: git.BranchConfig{ + RemoteURL: &url.URL{ + Scheme: "ssh", + User: url.User("git"), + Host: "github.com", + Path: "/\\invalid?Path/", + }, + }, + prHeadRef: "monalisa/main", + wantPrNumber: 0, + wantSelector: "monalisa/main", + wantError: nil, + }, + { + name: "Remote Name errors", + branchConfig: git.BranchConfig{ + RemoteName: "nonexistentRemote", + }, + prHeadRef: "monalisa/main", + remotes: context.Remotes{ + &context.Remote{ + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"), + }, + &context.Remote{ + Remote: &git.Remote{Name: "upstream"}, + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "monalisa/main", + wantError: nil, }, } - gitClient := &git.Client{GitPath: "some/path/git"} - prNum, headRef, err := prSelectorForCurrentBranch(gitClient, repo, "Frederick888/main", rem) - if err != nil { - t.Fatalf("prSelectorForCurrentBranch error: %v", err) - } - if prNum != 0 { - t.Errorf("expected prNum to be 0, got %q", prNum) - } - if headRef != "Frederick888:main" { - t.Errorf("expected headRef to be \"Frederick888:main\", got %q", headRef) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + prNum, headRef, err := prSelectorForCurrentBranch(tt.branchConfig, tt.baseRepo, tt.prHeadRef, tt.remotes) + assert.Equal(t, tt.wantPrNumber, prNum) + assert.Equal(t, tt.wantSelector, headRef) + assert.Equal(t, tt.wantError, err) + }) } }