From bf6fdbdfd214c97f45d2610289236ae3537d3ea4 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Tue, 7 Jan 2025 11:31:46 -0800 Subject: [PATCH 01/19] Remove named returns from ReadBranchConfig and surface errors --- git/client.go | 14 ++++++++++---- git/client_test.go | 2 +- pkg/cmd/pr/create/create.go | 6 +++++- pkg/cmd/pr/create/create_test.go | 2 +- pkg/cmd/pr/shared/finder.go | 3 ++- pkg/cmd/pr/status/status.go | 2 +- 6 files changed, 20 insertions(+), 9 deletions(-) diff --git a/git/client.go b/git/client.go index 1a6d9ae7f..501d1ba9d 100644 --- a/git/client.go +++ b/git/client.go @@ -377,16 +377,22 @@ 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) { + var cfg BranchConfig + 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 + return cfg, err } + out, err := cmd.Output() if err != nil { - return + return cfg, err } for _, line := range outputLines(out) { @@ -412,7 +418,7 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg Branc cfg.MergeBase = parts[1] } } - return + return cfg, nil } // SetBranchConfig sets the named value on the given branch. diff --git a/git/client_test.go b/git/client_test.go index fff1397f3..182ce67ea 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -747,7 +747,7 @@ func TestClientReadBranchConfig(t *testing.T) { GitPath: "path/to/git", commandContext: cmdCtx, } - branchConfig := client.ReadBranchConfig(context.Background(), "trunk") + branchConfig, _ := client.ReadBranchConfig(context.Background(), "trunk") assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) assert.Equal(t, tt.wantBranchConfig, branchConfig) }) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index db160b419..638d8eed4 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -680,7 +680,11 @@ 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 { + // We can still proceed without the branch config, so print to stderr but continue + fmt.Fprintf(opts.IO.ErrOut, "Error reading git branch config: %v\n", 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..66fe1c39a 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1717,7 +1717,7 @@ func Test_tryDetermineTrackingRef(t *testing.T) { GhPath: "some/path/gh", GitPath: "some/path/git", } - headBranchConfig := gitClient.ReadBranchConfig(ctx.Background(), "feature") + headBranchConfig, _ := gitClient.ReadBranchConfig(ctx.Background(), "feature") ref, found := tryDetermineTrackingRef(gitClient, tt.remotes, "feature", headBranchConfig) assert.Equal(t, tt.expectedTrackingRef, ref) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index e4a70eb22..3bc197b75 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -59,7 +59,8 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { httpClient: factory.HttpClient, progress: factory.IOStreams, branchConfig: func(s string) git.BranchConfig { - return factory.GitClient.ReadBranchConfig(context.Background(), s) + branchConfig, _ := factory.GitClient.ReadBranchConfig(context.Background(), s) + return branchConfig }, } } diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 27666461d..2613a3a6b 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -186,7 +186,7 @@ func statusRun(opts *StatusOptions) error { 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) + branchConfig, _ := gitClient.ReadBranchConfig(context.Background(), prHeadRef) // the branch is configured to merge a special PR head ref prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) From f43da8ea9a4c4c3caccbaa082232b36e26fad380 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Tue, 7 Jan 2025 12:19:13 -0800 Subject: [PATCH 02/19] Refactor ReadBranchConfig for test coverage of newly returned erros --- git/client.go | 22 +++++++++-- git/client_test.go | 94 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/git/client.go b/git/client.go index 501d1ba9d..b356e9ef5 100644 --- a/git/client.go +++ b/git/client.go @@ -383,19 +383,33 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchConfig, error) { var cfg BranchConfig + branchConfigOutput, err := c.readGitBranchConfig(ctx, branch) + if err != nil { + return cfg, err + } + + return c.createBranchConfig(outputLines(branchConfigOutput)), nil +} + +func (c *Client) readGitBranchConfig(ctx context.Context, branch string) ([]byte, 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 cfg, err + return []byte{}, err } out, err := cmd.Output() if err != nil { - return cfg, err + return []byte{}, err } - for _, line := range outputLines(out) { + return out, nil +} + +func (c *Client) createBranchConfig(gitBranchConfigOutput []string) BranchConfig { + var cfg BranchConfig + for _, line := range gitBranchConfigOutput { parts := strings.SplitN(line, " ", 2) if len(parts) < 2 { continue @@ -418,7 +432,7 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchCon cfg.MergeBase = parts[1] } } - return cfg, nil + return cfg } // SetBranchConfig sets the named value on the given branch. diff --git a/git/client_test.go b/git/client_test.go index 182ce67ea..448908591 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -754,6 +754,100 @@ func TestClientReadBranchConfig(t *testing.T) { } } +func Test_readGitBranchConfig(t *testing.T) { + tests := []struct { + name string + branch string + cmdExitCode int + cmdStdout string + cmdStderr string + wantCmdArgs string + wantErr *GitError + }{ + { + name: "read branch config", + branch: "trunk", + 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)$`, + }, + { + name: "command failure", + branch: "trunk", + wantCmdArgs: `path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|gh-merge-base)$`, + cmdExitCode: 1, + cmdStderr: "error", + wantErr: &GitError{ExitCode: 1, Stderr: "error"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, cmdCtx := createCommandContext(t, tt.cmdExitCode, tt.cmdStdout, tt.cmdStderr) + client := Client{ + GitPath: "path/to/git", + commandContext: cmdCtx, + } + out, err := client.readGitBranchConfig(context.Background(), tt.branch) + if tt.wantErr != nil { + assert.Equal(t, tt.wantErr.ExitCode, err.(*GitError).ExitCode) + assert.Equal(t, tt.wantErr.Stderr, err.(*GitError).Stderr) + } else { + assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) + assert.Equal(t, tt.cmdStdout, string(out)) + } + }) + } +} + +func Test_createBranchConfig(t *testing.T) { + tests := []struct { + name string + gitBranchConfigOutput []string + wantBranchConfig BranchConfig + }{ + { + name: "remote branch", + gitBranchConfigOutput: []string{"branch.trunk.remote origin"}, + wantBranchConfig: BranchConfig{ + RemoteName: "origin", + }, + }, + { + name: "merge ref", + gitBranchConfigOutput: []string{"branch.trunk.merge refs/heads/trunk"}, + wantBranchConfig: BranchConfig{ + MergeRef: "refs/heads/trunk", + }, + }, + { + name: "merge base", + gitBranchConfigOutput: []string{"branch.trunk.gh-merge-base gh-merge-base"}, + wantBranchConfig: BranchConfig{ + MergeBase: "gh-merge-base", + }, + }, + { + name: "remote, merge ref, and merge base all specified", + gitBranchConfigOutput: []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", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := Client{} + branchConfig := client.createBranchConfig(tt.gitBranchConfigOutput) + assert.Equal(t, tt.wantBranchConfig, branchConfig) + }) + } +} + func TestClientDeleteLocalTag(t *testing.T) { tests := []struct { name string From c83cf32cff2027f6506203aca5858137fbe2ea72 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Tue, 7 Jan 2025 13:41:53 -0800 Subject: [PATCH 03/19] Remove named return values from prSelectorForCurrentBranch --- pkg/cmd/pr/status/status.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 2613a3a6b..9245b1cdc 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -184,17 +184,17 @@ 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 +func prSelectorForCurrentBranch(gitClient *git.Client, baseRepo ghrepo.Interface, prHeadRef string, rem ghContext.Remotes) (int, string, error) { branchConfig, _ := gitClient.ReadBranchConfig(context.Background(), prHeadRef) // 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]) + return prNumber, prHeadRef, err } + var err error var branchOwner string if branchConfig.RemoteURL != nil { // the branch merges from a remote specified by URL @@ -208,6 +208,7 @@ func prSelectorForCurrentBranch(gitClient *git.Client, baseRepo ghrepo.Interface } } + selector := prHeadRef if branchOwner != "" { if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") { selector = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") @@ -218,7 +219,7 @@ func prSelectorForCurrentBranch(gitClient *git.Client, baseRepo ghrepo.Interface } } - return + return 0, selector, err } func totalApprovals(pr *api.PullRequest) int { From 01371508486e86f5c4b772f0eee777e74cfa9b70 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Tue, 7 Jan 2025 14:56:27 -0800 Subject: [PATCH 04/19] Refactor Test_readGitBranchConfig for easier parsing --- git/client.go | 10 ++++---- git/client_test.go | 62 ++++++++++++++++++++++++++++------------------ 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/git/client.go b/git/client.go index b356e9ef5..20a489a29 100644 --- a/git/client.go +++ b/git/client.go @@ -388,23 +388,23 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchCon return cfg, err } - return c.createBranchConfig(outputLines(branchConfigOutput)), nil + return c.createBranchConfig(branchConfigOutput), nil } -func (c *Client) readGitBranchConfig(ctx context.Context, branch string) ([]byte, error) { +func (c *Client) readGitBranchConfig(ctx context.Context, branch string) ([]string, 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 []byte{}, err + return []string{}, err } out, err := cmd.Output() if err != nil { - return []byte{}, err + return []string{}, err } - return out, nil + return outputLines(out), nil } func (c *Client) createBranchConfig(gitBranchConfigOutput []string) BranchConfig { diff --git a/git/client_test.go b/git/client_test.go index 448908591..0930a6f72 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -756,43 +756,57 @@ func TestClientReadBranchConfig(t *testing.T) { func Test_readGitBranchConfig(t *testing.T) { tests := []struct { - name string - branch string - cmdExitCode int - cmdStdout string - cmdStderr string - wantCmdArgs string - wantErr *GitError + name string + branch string + cmdExitStatus int + cmdStdout string + cmdStderr string + expectedOutput []string + expectedError *GitError }{ { - name: "read branch config", - branch: "trunk", - 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)$`, + name: "read branch config", + branch: "trunk", + cmdExitStatus: 0, + cmdStdout: "branch.trunk.remote origin\nbranch.trunk.merge refs/heads/trunk\nbranch.trunk.gh-merge-base trunk", + expectedOutput: []string{ + "branch.trunk.remote origin", + "branch.trunk.merge refs/heads/trunk", + "branch.trunk.gh-merge-base trunk"}, + expectedError: nil, }, { - name: "command failure", - branch: "trunk", - wantCmdArgs: `path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|gh-merge-base)$`, - cmdExitCode: 1, - cmdStderr: "error", - wantErr: &GitError{ExitCode: 1, Stderr: "error"}, + name: "command failure", + branch: "trunk", + cmdExitStatus: 1, + cmdStderr: "git error message", + expectedOutput: []string{}, + expectedError: &GitError{ + ExitCode: 1, + Stderr: "git error message", + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cmd, cmdCtx := createCommandContext(t, tt.cmdExitCode, tt.cmdStdout, tt.cmdStderr) + commandString := fmt.Sprintf("path/to/git config --get-regexp ^branch\\.%s\\.(remote|merge|gh-merge-base)$", tt.branch) + cmdCtx := createMockedCommandContext(t, map[args]commandResult{ + args(commandString): { + ExitStatus: tt.cmdExitStatus, + Stdout: tt.cmdStdout, + Stderr: tt.cmdStderr, + }, + }) client := Client{ GitPath: "path/to/git", commandContext: cmdCtx, } + out, err := client.readGitBranchConfig(context.Background(), tt.branch) - if tt.wantErr != nil { - assert.Equal(t, tt.wantErr.ExitCode, err.(*GitError).ExitCode) - assert.Equal(t, tt.wantErr.Stderr, err.(*GitError).Stderr) - } else { - assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) - assert.Equal(t, tt.cmdStdout, string(out)) + assert.Equal(t, tt.expectedOutput, out) + if err != nil { + assert.Equal(t, tt.cmdStderr, err.(*GitError).Stderr) + assert.Equal(t, tt.cmdExitStatus, err.(*GitError).ExitCode) } }) } From 4575692ebfbde0bfb9827e0fffea7d0751d9744f Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 8 Jan 2025 10:36:45 -0800 Subject: [PATCH 05/19] Remove private readBranchConfig method and remove parseBranchConfig from Client I think I went too far with my previous refactor and am backing out of it. Adding a private readBranchConfig method on the client wasn't providing any real additional value, so I've put it back into ReadBranchConfig. However, I think there is still value in having parseBranchConfig (formerly createBranchConfig) as a separate util function, as it both improves readability of ReadBranchConfig and makes parsing its purpose easier through the bespoke tests for it. --- git/client.go | 21 ++++------- git/client_test.go | 86 ++++++++++++---------------------------------- 2 files changed, 28 insertions(+), 79 deletions(-) diff --git a/git/client.go b/git/client.go index 20a489a29..3cc53ca96 100644 --- a/git/client.go +++ b/git/client.go @@ -381,35 +381,26 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, // 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) { - var cfg BranchConfig - branchConfigOutput, err := c.readGitBranchConfig(ctx, branch) - if err != nil { - return cfg, err - } - - return c.createBranchConfig(branchConfigOutput), nil -} - -func (c *Client) readGitBranchConfig(ctx context.Context, branch string) ([]string, 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 []string{}, err + return BranchConfig{}, err } out, err := cmd.Output() if err != nil { - return []string{}, err + return BranchConfig{}, err } - return outputLines(out), nil + return parseBranchConfig(outputLines(out)), nil } -func (c *Client) createBranchConfig(gitBranchConfigOutput []string) BranchConfig { +func parseBranchConfig(configLines []string) BranchConfig { var cfg BranchConfig - for _, line := range gitBranchConfigOutput { + + for _, line := range configLines { parts := strings.SplitN(line, " ", 2) if len(parts) < 2 { continue diff --git a/git/client_test.go b/git/client_test.go index 0930a6f72..df9577946 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -730,14 +730,26 @@ 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: "command error", + cmdExitStatus: 1, + cmdStdout: "", + cmdStderr: "git error message", + branch: "trunk", + wantBranchConfig: BranchConfig{}, + wantError: &GitError{ExitCode: 1, Stderr: "git error message"}, }, } for _, tt := range tests { @@ -747,72 +759,19 @@ 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) - }) - } -} - -func Test_readGitBranchConfig(t *testing.T) { - tests := []struct { - name string - branch string - cmdExitStatus int - cmdStdout string - cmdStderr string - expectedOutput []string - expectedError *GitError - }{ - { - name: "read branch config", - branch: "trunk", - cmdExitStatus: 0, - cmdStdout: "branch.trunk.remote origin\nbranch.trunk.merge refs/heads/trunk\nbranch.trunk.gh-merge-base trunk", - expectedOutput: []string{ - "branch.trunk.remote origin", - "branch.trunk.merge refs/heads/trunk", - "branch.trunk.gh-merge-base trunk"}, - expectedError: nil, - }, - { - name: "command failure", - branch: "trunk", - cmdExitStatus: 1, - cmdStderr: "git error message", - expectedOutput: []string{}, - expectedError: &GitError{ - ExitCode: 1, - Stderr: "git error message", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - commandString := fmt.Sprintf("path/to/git config --get-regexp ^branch\\.%s\\.(remote|merge|gh-merge-base)$", tt.branch) - cmdCtx := createMockedCommandContext(t, map[args]commandResult{ - args(commandString): { - ExitStatus: tt.cmdExitStatus, - Stdout: tt.cmdStdout, - Stderr: tt.cmdStderr, - }, - }) - client := Client{ - GitPath: "path/to/git", - commandContext: cmdCtx, - } - - out, err := client.readGitBranchConfig(context.Background(), tt.branch) - assert.Equal(t, tt.expectedOutput, out) - if err != nil { - assert.Equal(t, tt.cmdStderr, err.(*GitError).Stderr) - assert.Equal(t, tt.cmdExitStatus, err.(*GitError).ExitCode) + if tt.wantError != nil { + assert.Equal(t, tt.wantError.ExitCode, err.(*GitError).ExitCode) + assert.Equal(t, tt.wantError.Stderr, err.(*GitError).Stderr) } }) } } -func Test_createBranchConfig(t *testing.T) { +func Test_parseBranchConfig(t *testing.T) { tests := []struct { name string gitBranchConfigOutput []string @@ -855,8 +814,7 @@ func Test_createBranchConfig(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client := Client{} - branchConfig := client.createBranchConfig(tt.gitBranchConfigOutput) + branchConfig := parseBranchConfig(tt.gitBranchConfigOutput) assert.Equal(t, tt.wantBranchConfig, branchConfig) }) } From fea46c00112cd39ddcc832c79d8c884fb5a045e5 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 8 Jan 2025 11:40:42 -0800 Subject: [PATCH 06/19] Change ReadBranchConfig error message in gh pr create --- pkg/cmd/pr/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 638d8eed4..b2229b976 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -683,7 +683,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { headBranchConfig, err := gitClient.ReadBranchConfig(context.Background(), headBranch) if err != nil { // We can still proceed without the branch config, so print to stderr but continue - fmt.Fprintf(opts.IO.ErrOut, "Error reading git branch config: %v\n", err) + fmt.Fprintf(opts.IO.ErrOut, "Unable to read git branch config: %v\n", err) } if isPushEnabled { // determine whether the head branch is already pushed to a remote From 527d1db29877fdf8665723cbe4c2668fb069dbe0 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 8 Jan 2025 14:24:20 -0800 Subject: [PATCH 07/19] Add missing test for RemoteURL in parseBranchConfig --- git/client_test.go | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/git/client_test.go b/git/client_test.go index df9577946..5eb7f4585 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" @@ -811,11 +812,32 @@ func Test_parseBranchConfig(t *testing.T) { MergeBase: "gh-merge-base", }, }, + { + name: "remote URL", + gitBranchConfigOutput: []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.gitBranchConfigOutput) - assert.Equal(t, tt.wantBranchConfig, branchConfig) + 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()) + } }) } } From ec9eaef048362eec8d620f8416b4b256d0d4e9d2 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 8 Jan 2025 14:27:41 -0800 Subject: [PATCH 08/19] Refactor prSelectorForCurrentBranch and tests Replace the git config argument in prSelectorForCurrentBranch with the branchConfig it was used to fetch. The tests needed to be refactored accordingly to support this change to the prSelectorForCurrentBranch API. In addition, I've moved the test to a table test format so I can expand the test coverage in the next commit. --- pkg/cmd/pr/status/status.go | 13 +++--- pkg/cmd/pr/status/status_test.go | 68 ++++++++++++++++++++------------ 2 files changed, 51 insertions(+), 30 deletions(-) diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 9245b1cdc..499202b7e 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,8 @@ func statusRun(opts *StatusOptions) error { } remotes, _ := opts.Remotes() - currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(opts.GitClient, baseRepo, currentBranch, remotes) + branchConfig, _ := opts.GitClient.ReadBranchConfig(ctx, currentBranch) + 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,14 +186,15 @@ func statusRun(opts *StatusOptions) error { return nil } -func prSelectorForCurrentBranch(gitClient *git.Client, baseRepo ghrepo.Interface, prHeadRef string, rem ghContext.Remotes) (int, string, error) { - 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, err := strconv.Atoi(m[1]) - return prNumber, prHeadRef, err + if err != nil { + return 0, "", err + } + return prNumber, prHeadRef, nil } var err error diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index e8e487559..6e2cf71f9 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -4,23 +4,23 @@ 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" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" - "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" ) func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) { @@ -376,30 +376,48 @@ Requesting a code review from you } func Test_prSelectorForCurrentBranch(t *testing.T) { - rs, cleanup := run.Stub() - defer cleanup(t) - - 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 - `)) - - repo := ghrepo.NewWithHost("octocat", "playground", "github.com") - rem := context.Remotes{ - &context.Remote{ - Remote: &git.Remote{Name: "origin"}, - Repo: repo, + tests := []struct { + name string + branchConfig git.BranchConfig + baseRepo ghrepo.Interface + prHeadRef string + remotes context.Remotes + wantPrNumber int + wantSelector string + wantError error + }{ + { + 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: "Frederick888/playground.git", + }, + MergeRef: "refs/heads/main", + }, + baseRepo: ghrepo.NewWithHost("octocat", "playground", "github.com"), + prHeadRef: "Frederick888/main", + remotes: context.Remotes{ + &context.Remote{ + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.NewWithHost("octocat", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "Frederick888: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) + }) } } From 94b2d4ec3b0076cf33d4ec5d1ddbe9563299e988 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 8 Jan 2025 15:00:38 -0800 Subject: [PATCH 09/19] Added tests to cover prSelectorForCurrentBranch for confidence in refactor --- pkg/cmd/pr/status/status_test.go | 126 ++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index 6e2cf71f9..9f3079957 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -386,6 +386,16 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { wantSelector string wantError error }{ + { + name: "The branch is configured to merge a special PR head ref", + branchConfig: git.BranchConfig{ + MergeRef: "refs/pull/666/head", + }, + prHeadRef: "Frederick888/main", + wantPrNumber: 666, + wantSelector: "Frederick888/main", + wantError: nil, + }, { name: "Branch merges from a remote specified by URL", branchConfig: git.BranchConfig{ @@ -395,18 +405,126 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { Host: "github.com", Path: "Frederick888/playground.git", }, - MergeRef: "refs/heads/main", }, - baseRepo: ghrepo.NewWithHost("octocat", "playground", "github.com"), + baseRepo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), prHeadRef: "Frederick888/main", remotes: context.Remotes{ &context.Remote{ Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.NewWithHost("octocat", "playground", "github.com"), + Repo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), }, }, wantPrNumber: 0, - wantSelector: "Frederick888:main", + wantSelector: "Frederick888/main", + wantError: nil, + }, + { + name: "Branch merges from a remote specified by name", + branchConfig: git.BranchConfig{ + RemoteName: "upstream", + }, + baseRepo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), + prHeadRef: "Frederick888/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("Frederick888", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "Frederick888/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("Frederick888", "playground", "github.com"), + prHeadRef: "Frederick888/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("Frederick888", "playground", "github.com"), + prHeadRef: "Frederick888/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("Frederick888", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "forkName:Frederick888/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("Frederick888", "playground", "github.com"), + prHeadRef: "Frederick888/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("Frederick888", "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("Frederick888", "playground", "github.com"), + prHeadRef: "Frederick888/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("Frederick888", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "forkName:main", wantError: nil, }, } From 15ac56622212b5dac30d4b02dfbe9debf5d1e094 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Wed, 8 Jan 2025 15:20:17 -0800 Subject: [PATCH 10/19] Remove shadowed errors from prSelectorForCurrentBranch and cover with tests --- pkg/cmd/pr/status/status.go | 15 +++++++------ pkg/cmd/pr/status/status_test.go | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 499202b7e..e97770ce4 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -197,18 +197,21 @@ func prSelectorForCurrentBranch(branchConfig git.BranchConfig, baseRepo ghrepo.I return prNumber, prHeadRef, nil } - var err error 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 { + return 0, prHeadRef, err } + 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 { + return 0, prHeadRef, err } + branchOwner = r.RepoOwner() } selector := prHeadRef @@ -222,7 +225,7 @@ func prSelectorForCurrentBranch(branchConfig git.BranchConfig, baseRepo ghrepo.I } } - return 0, selector, err + return 0, selector, 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 9f3079957..94505aaba 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -2,6 +2,7 @@ package status import ( "bytes" + "fmt" "io" "net/http" "net/url" @@ -527,6 +528,41 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { wantSelector: "forkName:main", wantError: nil, }, + { + name: "Remote URL error", + branchConfig: git.BranchConfig{ + RemoteURL: &url.URL{ + Scheme: "ssh", + User: url.User("git"), + Host: "github.com", + Path: "/\\invalid?Path/", + }, + }, + prHeadRef: "Frederick888/main", + wantPrNumber: 0, + wantSelector: "Frederick888/main", + wantError: fmt.Errorf("invalid path: /\\invalid?Path/"), + }, + { + name: "Remote Name error", + branchConfig: git.BranchConfig{ + RemoteName: "nonexistentRemote", + }, + prHeadRef: "Frederick888/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("Frederick888", "playground", "github.com"), + }, + }, + wantPrNumber: 0, + wantSelector: "Frederick888/main", + wantError: fmt.Errorf("no matching remote found"), + }, } for _, tt := range tests { From d4f7576e8baaccfdd3f7e9c4418151294d67733c Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Thu, 9 Jan 2025 09:01:14 -0800 Subject: [PATCH 11/19] Add test for empty BranchConfig in prSelectorForCurrentBranch --- pkg/cmd/pr/status/status.go | 5 +++-- pkg/cmd/pr/status/status_test.go | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index e97770ce4..c15c0f7fd 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -214,8 +214,8 @@ func prSelectorForCurrentBranch(branchConfig git.BranchConfig, baseRepo ghrepo.I branchOwner = r.RepoOwner() } - selector := prHeadRef if branchOwner != "" { + selector := prHeadRef if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") { selector = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") } @@ -223,9 +223,10 @@ func prSelectorForCurrentBranch(branchConfig git.BranchConfig, baseRepo ghrepo.I if !strings.EqualFold(branchOwner, baseRepo.RepoOwner()) { selector = fmt.Sprintf("%s:%s", branchOwner, selector) } + return 0, selector, nil } - return 0, selector, nil + 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 94505aaba..431312155 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -387,6 +387,14 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { wantSelector string wantError error }{ + { + name: "Empty branch config", + branchConfig: git.BranchConfig{}, + prHeadRef: "Frederick888/main", + wantPrNumber: 0, + wantSelector: "Frederick888/main", + wantError: nil, + }, { name: "The branch is configured to merge a special PR head ref", branchConfig: git.BranchConfig{ From e1423cdbbf36a0a49385eea2312aaaf8dc70dae4 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Thu, 9 Jan 2025 10:08:47 -0800 Subject: [PATCH 12/19] Refine error handling of ReadBranchConfig cmd.Output() will return an error when the git command ran successfully but had no output. To handle this, we can check Stderr, as we expect it to be populated for any ExitErrors or otherwise when there is a command failure. This allows for propagation of this error handling up the call chain, so we are now returning errors if the call to git fails instead of just handing off an empty BranchConfig and suppressing the errors. Additionally, I've removed some more naked returns that I found in pkg/cmd/pr/create.go createRun --- git/client.go | 8 ++++++- git/client_test.go | 40 ++++++++++++++++++++++------------ pkg/cmd/pr/create/create.go | 43 ++++++++++++++++++------------------- pkg/cmd/pr/status/status.go | 5 ++++- 4 files changed, 58 insertions(+), 38 deletions(-) diff --git a/git/client.go b/git/client.go index 3cc53ca96..16e8f32b5 100644 --- a/git/client.go +++ b/git/client.go @@ -391,7 +391,13 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchCon out, err := cmd.Output() if err != nil { - return BranchConfig{}, err + // This will error if no matches are found but the git command still ran successfully. We only + // want to return an error if the command failed to run, usually and ExitError, which will be + // indicated by output on Stderr. + if err.(*GitError).Stderr != "" { + return BranchConfig{}, err + } + return BranchConfig{}, nil } return parseBranchConfig(outputLines(out)), nil diff --git a/git/client_test.go b/git/client_test.go index 5eb7f4585..4b721656e 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -744,7 +744,7 @@ func TestClientReadBranchConfig(t *testing.T) { wantError: nil, }, { - name: "command error", + name: "output error", cmdExitStatus: 1, cmdStdout: "", cmdStderr: "git error message", @@ -752,6 +752,15 @@ func TestClientReadBranchConfig(t *testing.T) { wantBranchConfig: BranchConfig{}, wantError: &GitError{ExitCode: 1, Stderr: "git error message"}, }, + { + name: "git config runs successfully but returns no output", + cmdExitStatus: 1, + cmdStdout: "", + cmdStderr: "", + branch: "trunk", + wantBranchConfig: BranchConfig{}, + wantError: nil, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -764,7 +773,10 @@ func TestClientReadBranchConfig(t *testing.T) { 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 { + if err != nil { + if tt.wantError == nil { + t.Fatalf("expected no error but got %v", err) + } assert.Equal(t, tt.wantError.ExitCode, err.(*GitError).ExitCode) assert.Equal(t, tt.wantError.Stderr, err.(*GitError).Stderr) } @@ -774,34 +786,34 @@ func TestClientReadBranchConfig(t *testing.T) { func Test_parseBranchConfig(t *testing.T) { tests := []struct { - name string - gitBranchConfigOutput []string - wantBranchConfig BranchConfig + name string + configLines []string + wantBranchConfig BranchConfig }{ { - name: "remote branch", - gitBranchConfigOutput: []string{"branch.trunk.remote origin"}, + name: "remote branch", + configLines: []string{"branch.trunk.remote origin"}, wantBranchConfig: BranchConfig{ RemoteName: "origin", }, }, { - name: "merge ref", - gitBranchConfigOutput: []string{"branch.trunk.merge refs/heads/trunk"}, + name: "merge ref", + configLines: []string{"branch.trunk.merge refs/heads/trunk"}, wantBranchConfig: BranchConfig{ MergeRef: "refs/heads/trunk", }, }, { - name: "merge base", - gitBranchConfigOutput: []string{"branch.trunk.gh-merge-base gh-merge-base"}, + 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", - gitBranchConfigOutput: []string{ + configLines: []string{ "branch.trunk.remote origin", "branch.trunk.merge refs/heads/trunk", "branch.trunk.gh-merge-base gh-merge-base", @@ -814,7 +826,7 @@ func Test_parseBranchConfig(t *testing.T) { }, { name: "remote URL", - gitBranchConfigOutput: []string{ + configLines: []string{ "branch.Frederick888/main.remote git@github.com:Frederick888/playground.git", "branch.Frederick888/main.merge refs/heads/main", }, @@ -831,7 +843,7 @@ func Test_parseBranchConfig(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - branchConfig := parseBranchConfig(tt.gitBranchConfigOutput) + 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) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index b2229b976..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)^`) @@ -682,8 +682,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { headBranchConfig, err := gitClient.ReadBranchConfig(context.Background(), headBranch) if err != nil { - // We can still proceed without the branch config, so print to stderr but continue - fmt.Fprintf(opts.IO.ErrOut, "Unable to read git branch config: %v\n", err) + return nil, err } if isPushEnabled { // determine whether the head branch is already pushed to a remote diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index c15c0f7fd..d22ae4e2f 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -94,7 +94,10 @@ func statusRun(opts *StatusOptions) error { } remotes, _ := opts.Remotes() - branchConfig, _ := opts.GitClient.ReadBranchConfig(ctx, currentBranch) + 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) From 48d45d0b9db6e5be0ce8f7fe50a4cac9b01e04cc Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Thu, 9 Jan 2025 11:45:45 -0800 Subject: [PATCH 13/19] Refactor error handling in ReadBranchConfig to avoid panic --- git/client.go | 12 +++++++----- git/client_test.go | 12 +++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/git/client.go b/git/client.go index 16e8f32b5..820a9cdc7 100644 --- a/git/client.go +++ b/git/client.go @@ -391,11 +391,13 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchCon out, err := cmd.Output() if err != nil { - // This will error if no matches are found but the git command still ran successfully. We only - // want to return an error if the command failed to run, usually and ExitError, which will be - // indicated by output on Stderr. - if err.(*GitError).Stderr != "" { - return BranchConfig{}, err + // This is the error we expect if the git command does not run successfully. + // Note: err is non-nil if the command is successful but has no output + var gitError *GitError + if errors.As(err, &gitError) { + if gitError.Stderr != "" { + return BranchConfig{}, err + } } return BranchConfig{}, nil } diff --git a/git/client_test.go b/git/client_test.go index 4b721656e..94d1c3969 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -750,7 +750,7 @@ func TestClientReadBranchConfig(t *testing.T) { cmdStderr: "git error message", branch: "trunk", wantBranchConfig: BranchConfig{}, - wantError: &GitError{ExitCode: 1, Stderr: "git error message"}, + wantError: &GitError{}, }, { name: "git config runs successfully but returns no output", @@ -773,12 +773,10 @@ func TestClientReadBranchConfig(t *testing.T) { 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 err != nil { - if tt.wantError == nil { - t.Fatalf("expected no error but got %v", err) - } - assert.Equal(t, tt.wantError.ExitCode, err.(*GitError).ExitCode) - assert.Equal(t, tt.wantError.Stderr, err.(*GitError).Stderr) + if tt.wantError != nil { + assert.ErrorAs(t, err, &tt.wantError) + } else { + assert.NoError(t, err) } }) } From e4320ccc4bf39dd3cf0e8eba2a6872b6d42eccca Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Thu, 9 Jan 2025 11:58:33 -0800 Subject: [PATCH 14/19] Directly stub headBranchConfig in Test_tryDetermineTrackingRef --- pkg/cmd/pr/create/create_test.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 66fe1c39a..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) From e4f0b791731e3093cc998d773e083a22f17c223e Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Thu, 9 Jan 2025 12:46:48 -0800 Subject: [PATCH 15/19] Surface and handle error from ReadBranchConfig in parseCurrentBranch I've only added the one test for parseCurrentBranch because the function appears to be largely exercised by TestFind. There's definitely an opportunity for a bigger refactor of the tests, here, but I want to avoid scope creep as I propagate the ReadBranchConfig api changes throughout the codebase --- pkg/cmd/pr/shared/finder.go | 12 ++-- pkg/cmd/pr/shared/finder_test.go | 113 +++++++++++++++++++++---------- 2 files changed, 84 insertions(+), 41 deletions(-) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 3bc197b75..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,9 +58,8 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { remotesFn: factory.Remotes, httpClient: factory.HttpClient, progress: factory.IOStreams, - branchConfig: func(s string) git.BranchConfig { - branchConfig, _ := factory.GitClient.ReadBranchConfig(context.Background(), s) - return branchConfig + branchConfig: func(s string) (git.BranchConfig, error) { + return factory.GitClient.ReadBranchConfig(context.Background(), s) }, } } @@ -239,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 + } +} From 100f9b473f8e7c53cb640c41fd00eb74b9e8926c Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Thu, 9 Jan 2025 12:57:23 -0800 Subject: [PATCH 16/19] Change pr number in test --- pkg/cmd/pr/status/status_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index 431312155..5ccc80eab 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -398,10 +398,10 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { { name: "The branch is configured to merge a special PR head ref", branchConfig: git.BranchConfig{ - MergeRef: "refs/pull/666/head", + MergeRef: "refs/pull/42/head", }, prHeadRef: "Frederick888/main", - wantPrNumber: 666, + wantPrNumber: 42, wantSelector: "Frederick888/main", wantError: nil, }, From 3d2748789e18fbd0f910d73d2c4c78712efbb1f6 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Thu, 9 Jan 2025 12:58:31 -0800 Subject: [PATCH 17/19] Rename test user in tests --- pkg/cmd/pr/status/status_test.go | 60 ++++++++++++++++---------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index 5ccc80eab..eb40912b4 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -390,9 +390,9 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { { name: "Empty branch config", branchConfig: git.BranchConfig{}, - prHeadRef: "Frederick888/main", + prHeadRef: "monalisa/main", wantPrNumber: 0, - wantSelector: "Frederick888/main", + wantSelector: "monalisa/main", wantError: nil, }, { @@ -400,9 +400,9 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { branchConfig: git.BranchConfig{ MergeRef: "refs/pull/42/head", }, - prHeadRef: "Frederick888/main", + prHeadRef: "monalisa/main", wantPrNumber: 42, - wantSelector: "Frederick888/main", + wantSelector: "monalisa/main", wantError: nil, }, { @@ -412,19 +412,19 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { Scheme: "ssh", User: url.User("git"), Host: "github.com", - Path: "Frederick888/playground.git", + Path: "monalisa/playground.git", }, }, - baseRepo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), - prHeadRef: "Frederick888/main", + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", remotes: context.Remotes{ &context.Remote{ Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), }, }, wantPrNumber: 0, - wantSelector: "Frederick888/main", + wantSelector: "monalisa/main", wantError: nil, }, { @@ -432,8 +432,8 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { branchConfig: git.BranchConfig{ RemoteName: "upstream", }, - baseRepo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), - prHeadRef: "Frederick888/main", + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", remotes: context.Remotes{ &context.Remote{ Remote: &git.Remote{Name: "origin"}, @@ -441,11 +441,11 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { }, &context.Remote{ Remote: &git.Remote{Name: "upstream"}, - Repo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), }, }, wantPrNumber: 0, - wantSelector: "Frederick888/main", + wantSelector: "monalisa/main", wantError: nil, }, { @@ -459,8 +459,8 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { }, MergeRef: "refs/heads/main", }, - baseRepo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), - prHeadRef: "Frederick888/main", + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", remotes: context.Remotes{ &context.Remote{ Remote: &git.Remote{Name: "origin"}, @@ -476,8 +476,8 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { branchConfig: git.BranchConfig{ RemoteName: "origin", }, - baseRepo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), - prHeadRef: "Frederick888/main", + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", remotes: context.Remotes{ &context.Remote{ Remote: &git.Remote{Name: "origin"}, @@ -485,11 +485,11 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { }, &context.Remote{ Remote: &git.Remote{Name: "upstream"}, - Repo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), }, }, wantPrNumber: 0, - wantSelector: "forkName:Frederick888/main", + wantSelector: "forkName:monalisa/main", wantError: nil, }, { @@ -498,8 +498,8 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { RemoteName: "upstream", MergeRef: "refs/heads/main", }, - baseRepo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), - prHeadRef: "Frederick888/main", + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", remotes: context.Remotes{ &context.Remote{ Remote: &git.Remote{Name: "origin"}, @@ -507,7 +507,7 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { }, &context.Remote{ Remote: &git.Remote{Name: "upstream"}, - Repo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), }, }, wantPrNumber: 0, @@ -520,8 +520,8 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { RemoteName: "origin", MergeRef: "refs/heads/main", }, - baseRepo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), - prHeadRef: "Frederick888/main", + baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), + prHeadRef: "monalisa/main", remotes: context.Remotes{ &context.Remote{ Remote: &git.Remote{Name: "origin"}, @@ -529,7 +529,7 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { }, &context.Remote{ Remote: &git.Remote{Name: "upstream"}, - Repo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), }, }, wantPrNumber: 0, @@ -546,9 +546,9 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { Path: "/\\invalid?Path/", }, }, - prHeadRef: "Frederick888/main", + prHeadRef: "monalisa/main", wantPrNumber: 0, - wantSelector: "Frederick888/main", + wantSelector: "monalisa/main", wantError: fmt.Errorf("invalid path: /\\invalid?Path/"), }, { @@ -556,7 +556,7 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { branchConfig: git.BranchConfig{ RemoteName: "nonexistentRemote", }, - prHeadRef: "Frederick888/main", + prHeadRef: "monalisa/main", remotes: context.Remotes{ &context.Remote{ Remote: &git.Remote{Name: "origin"}, @@ -564,11 +564,11 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { }, &context.Remote{ Remote: &git.Remote{Name: "upstream"}, - Repo: ghrepo.NewWithHost("Frederick888", "playground", "github.com"), + Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"), }, }, wantPrNumber: 0, - wantSelector: "Frederick888/main", + wantSelector: "monalisa/main", wantError: fmt.Errorf("no matching remote found"), }, } From 322a43feff32c0071705c8b3fd75091ffb8ebc2f Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Fri, 10 Jan 2025 09:59:21 -0800 Subject: [PATCH 18/19] Change error handling on ReadBranchConfig to respect git Exit Codes --- git/client.go | 8 +++----- git/client_test.go | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/git/client.go b/git/client.go index 820a9cdc7..19688ca51 100644 --- a/git/client.go +++ b/git/client.go @@ -392,12 +392,10 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchCon out, err := cmd.Output() if err != nil { // This is the error we expect if the git command does not run successfully. - // Note: err is non-nil if the command is successful but has no output + // If the ExitCode is 1, then we just didn't find any config for the branch. var gitError *GitError - if errors.As(err, &gitError) { - if gitError.Stderr != "" { - return BranchConfig{}, err - } + if ok := errors.As(err, &gitError); ok && gitError.ExitCode != 1 { + return BranchConfig{}, err } return BranchConfig{}, nil } diff --git a/git/client_test.go b/git/client_test.go index 94d1c3969..fe3047db9 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -744,16 +744,7 @@ func TestClientReadBranchConfig(t *testing.T) { wantError: nil, }, { - name: "output error", - cmdExitStatus: 1, - cmdStdout: "", - cmdStderr: "git error message", - branch: "trunk", - wantBranchConfig: BranchConfig{}, - wantError: &GitError{}, - }, - { - name: "git config runs successfully but returns no output", + name: "git config runs successfully but returns no output (Exit Code 1)", cmdExitStatus: 1, cmdStdout: "", cmdStderr: "", @@ -761,6 +752,15 @@ func TestClientReadBranchConfig(t *testing.T) { 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 { t.Run(tt.name, func(t *testing.T) { From 74cbd50d3ff44cef698d8b77056122c257fb32b9 Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Fri, 10 Jan 2025 10:51:55 -0800 Subject: [PATCH 19/19] Restore old error functionality of prSelectorForCurrentBranch Before this refactor, the errors emitted by ghrepo.FromURL and rem.FindName() were suppressed. It isn't clear whether this was intentional or not, but we've made the decision here to maintain the original error behavior while still refactoring the return values for more clarity. I've left a comment at each error handling block to explain this decision. Additionally, I've added the necessary git command stubs to the other tests in status_test.go so that the tests are now passing. --- pkg/cmd/pr/status/status.go | 10 ++++- pkg/cmd/pr/status/status_test.go | 74 +++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index d22ae4e2f..a97a59e44 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -205,14 +205,20 @@ func prSelectorForCurrentBranch(branchConfig git.BranchConfig, baseRepo ghrepo.I // the branch merges from a remote specified by URL r, err := ghrepo.FromURL(branchConfig.RemoteURL) if err != nil { - return 0, prHeadRef, err + // 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 r, err := rem.FindByName(branchConfig.RemoteName) if err != nil { - return 0, prHeadRef, err + // 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() } diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index eb40912b4..43cce5f4c 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -2,7 +2,6 @@ package status import ( "bytes" - "fmt" "io" "net/http" "net/url" @@ -16,6 +15,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "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" @@ -96,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) @@ -121,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) @@ -146,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) @@ -170,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) @@ -198,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) @@ -232,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) @@ -249,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) @@ -266,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) @@ -283,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) @@ -300,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) @@ -353,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) @@ -376,6 +431,15 @@ Requesting a code review from you } } +func TestPRStatus_error_ReadBranchConfig(t *testing.T) { + rs, cleanup := run.Stub() + defer cleanup(t) + rs.Register(`git config --get-regexp \^branch\\.`, 1, "") + + _, err := runCommand(initFakeHTTP(), "blueberries", true, "") + assert.Error(t, err) +} + func Test_prSelectorForCurrentBranch(t *testing.T) { tests := []struct { name string @@ -537,7 +601,7 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { wantError: nil, }, { - name: "Remote URL error", + name: "Remote URL errors", branchConfig: git.BranchConfig{ RemoteURL: &url.URL{ Scheme: "ssh", @@ -549,10 +613,10 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { prHeadRef: "monalisa/main", wantPrNumber: 0, wantSelector: "monalisa/main", - wantError: fmt.Errorf("invalid path: /\\invalid?Path/"), + wantError: nil, }, { - name: "Remote Name error", + name: "Remote Name errors", branchConfig: git.BranchConfig{ RemoteName: "nonexistentRemote", }, @@ -569,7 +633,7 @@ func Test_prSelectorForCurrentBranch(t *testing.T) { }, wantPrNumber: 0, wantSelector: "monalisa/main", - wantError: fmt.Errorf("no matching remote found"), + wantError: nil, }, }