github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/repo/sync/sync_test.go (about)

     1  package sync
     2  
     3  import (
     4  	"bytes"
     5  	"io"
     6  	"net/http"
     7  	"testing"
     8  
     9  	"github.com/ungtb10d/cli/v2/context"
    10  	"github.com/ungtb10d/cli/v2/git"
    11  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    12  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    13  	"github.com/ungtb10d/cli/v2/pkg/httpmock"
    14  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    15  	"github.com/google/shlex"
    16  	"github.com/stretchr/testify/assert"
    17  )
    18  
    19  func TestNewCmdSync(t *testing.T) {
    20  	tests := []struct {
    21  		name    string
    22  		tty     bool
    23  		input   string
    24  		output  SyncOptions
    25  		wantErr bool
    26  		errMsg  string
    27  	}{
    28  		{
    29  			name:   "no argument",
    30  			tty:    true,
    31  			input:  "",
    32  			output: SyncOptions{},
    33  		},
    34  		{
    35  			name:  "destination repo",
    36  			tty:   true,
    37  			input: "ungtb10d/cli",
    38  			output: SyncOptions{
    39  				DestArg: "ungtb10d/cli",
    40  			},
    41  		},
    42  		{
    43  			name:  "source repo",
    44  			tty:   true,
    45  			input: "--source ungtb10d/cli",
    46  			output: SyncOptions{
    47  				SrcArg: "ungtb10d/cli",
    48  			},
    49  		},
    50  		{
    51  			name:  "branch",
    52  			tty:   true,
    53  			input: "--branch trunk",
    54  			output: SyncOptions{
    55  				Branch: "trunk",
    56  			},
    57  		},
    58  		{
    59  			name:  "force",
    60  			tty:   true,
    61  			input: "--force",
    62  			output: SyncOptions{
    63  				Force: true,
    64  			},
    65  		},
    66  	}
    67  	for _, tt := range tests {
    68  		t.Run(tt.name, func(t *testing.T) {
    69  			ios, _, _, _ := iostreams.Test()
    70  			ios.SetStdinTTY(tt.tty)
    71  			ios.SetStdoutTTY(tt.tty)
    72  			f := &cmdutil.Factory{
    73  				IOStreams: ios,
    74  			}
    75  			argv, err := shlex.Split(tt.input)
    76  			assert.NoError(t, err)
    77  			var gotOpts *SyncOptions
    78  			cmd := NewCmdSync(f, func(opts *SyncOptions) error {
    79  				gotOpts = opts
    80  				return nil
    81  			})
    82  			cmd.SetArgs(argv)
    83  			cmd.SetIn(&bytes.Buffer{})
    84  			cmd.SetOut(&bytes.Buffer{})
    85  			cmd.SetErr(&bytes.Buffer{})
    86  
    87  			_, err = cmd.ExecuteC()
    88  			if tt.wantErr {
    89  				assert.Error(t, err)
    90  				assert.Equal(t, tt.errMsg, err.Error())
    91  				return
    92  			}
    93  
    94  			assert.NoError(t, err)
    95  			assert.Equal(t, tt.output.DestArg, gotOpts.DestArg)
    96  			assert.Equal(t, tt.output.SrcArg, gotOpts.SrcArg)
    97  			assert.Equal(t, tt.output.Branch, gotOpts.Branch)
    98  			assert.Equal(t, tt.output.Force, gotOpts.Force)
    99  		})
   100  	}
   101  }
   102  
   103  func Test_SyncRun(t *testing.T) {
   104  	tests := []struct {
   105  		name       string
   106  		tty        bool
   107  		opts       *SyncOptions
   108  		remotes    []*context.Remote
   109  		httpStubs  func(*httpmock.Registry)
   110  		gitStubs   func(*mockGitClient)
   111  		wantStdout string
   112  		wantErr    bool
   113  		errMsg     string
   114  	}{
   115  		{
   116  			name: "sync local repo with parent - tty",
   117  			tty:  true,
   118  			opts: &SyncOptions{},
   119  			httpStubs: func(reg *httpmock.Registry) {
   120  				reg.Register(
   121  					httpmock.GraphQL(`query RepositoryInfo\b`),
   122  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   123  			},
   124  			gitStubs: func(mgc *mockGitClient) {
   125  				mgc.On("IsDirty").Return(false, nil).Once()
   126  				mgc.On("Fetch", "origin", "refs/heads/trunk").Return(nil).Once()
   127  				mgc.On("HasLocalBranch", "trunk").Return(true).Once()
   128  				mgc.On("BranchRemote", "trunk").Return("origin", nil).Once()
   129  				mgc.On("IsAncestor", "trunk", "FETCH_HEAD").Return(true, nil).Once()
   130  				mgc.On("CurrentBranch").Return("trunk", nil).Once()
   131  				mgc.On("MergeFastForward", "FETCH_HEAD").Return(nil).Once()
   132  			},
   133  			wantStdout: "✓ Synced the \"trunk\" branch from OWNER/REPO to local repository\n",
   134  		},
   135  		{
   136  			name: "sync local repo with parent - notty",
   137  			tty:  false,
   138  			opts: &SyncOptions{
   139  				Branch: "trunk",
   140  			},
   141  			gitStubs: func(mgc *mockGitClient) {
   142  				mgc.On("IsDirty").Return(false, nil).Once()
   143  				mgc.On("Fetch", "origin", "refs/heads/trunk").Return(nil).Once()
   144  				mgc.On("HasLocalBranch", "trunk").Return(true).Once()
   145  				mgc.On("BranchRemote", "trunk").Return("origin", nil).Once()
   146  				mgc.On("IsAncestor", "trunk", "FETCH_HEAD").Return(true, nil).Once()
   147  				mgc.On("CurrentBranch").Return("trunk", nil).Once()
   148  				mgc.On("MergeFastForward", "FETCH_HEAD").Return(nil).Once()
   149  			},
   150  			wantStdout: "",
   151  		},
   152  		{
   153  			name: "sync local repo with specified source repo",
   154  			tty:  true,
   155  			opts: &SyncOptions{
   156  				Branch: "trunk",
   157  				SrcArg: "OWNER2/REPO2",
   158  			},
   159  			gitStubs: func(mgc *mockGitClient) {
   160  				mgc.On("IsDirty").Return(false, nil).Once()
   161  				mgc.On("Fetch", "upstream", "refs/heads/trunk").Return(nil).Once()
   162  				mgc.On("HasLocalBranch", "trunk").Return(true).Once()
   163  				mgc.On("BranchRemote", "trunk").Return("upstream", nil).Once()
   164  				mgc.On("IsAncestor", "trunk", "FETCH_HEAD").Return(true, nil).Once()
   165  				mgc.On("CurrentBranch").Return("trunk", nil).Once()
   166  				mgc.On("MergeFastForward", "FETCH_HEAD").Return(nil).Once()
   167  			},
   168  			wantStdout: "✓ Synced the \"trunk\" branch from OWNER2/REPO2 to local repository\n",
   169  		},
   170  		{
   171  			name: "sync local repo with parent and force specified",
   172  			tty:  true,
   173  			opts: &SyncOptions{
   174  				Branch: "trunk",
   175  				Force:  true,
   176  			},
   177  			gitStubs: func(mgc *mockGitClient) {
   178  				mgc.On("IsDirty").Return(false, nil).Once()
   179  				mgc.On("Fetch", "origin", "refs/heads/trunk").Return(nil).Once()
   180  				mgc.On("HasLocalBranch", "trunk").Return(true).Once()
   181  				mgc.On("BranchRemote", "trunk").Return("origin", nil).Once()
   182  				mgc.On("IsAncestor", "trunk", "FETCH_HEAD").Return(false, nil).Once()
   183  				mgc.On("CurrentBranch").Return("trunk", nil).Once()
   184  				mgc.On("ResetHard", "FETCH_HEAD").Return(nil).Once()
   185  			},
   186  			wantStdout: "✓ Synced the \"trunk\" branch from OWNER/REPO to local repository\n",
   187  		},
   188  		{
   189  			name: "sync local repo with parent and not fast forward merge",
   190  			tty:  true,
   191  			opts: &SyncOptions{
   192  				Branch: "trunk",
   193  			},
   194  			gitStubs: func(mgc *mockGitClient) {
   195  				mgc.On("Fetch", "origin", "refs/heads/trunk").Return(nil).Once()
   196  				mgc.On("HasLocalBranch", "trunk").Return(true).Once()
   197  				mgc.On("BranchRemote", "trunk").Return("origin", nil).Once()
   198  				mgc.On("IsAncestor", "trunk", "FETCH_HEAD").Return(false, nil).Once()
   199  			},
   200  			wantErr: true,
   201  			errMsg:  "can't sync because there are diverging changes; use `--force` to overwrite the destination branch",
   202  		},
   203  		{
   204  			name: "sync local repo with parent and mismatching branch remotes",
   205  			tty:  true,
   206  			opts: &SyncOptions{
   207  				Branch: "trunk",
   208  			},
   209  			gitStubs: func(mgc *mockGitClient) {
   210  				mgc.On("Fetch", "origin", "refs/heads/trunk").Return(nil).Once()
   211  				mgc.On("HasLocalBranch", "trunk").Return(true).Once()
   212  				mgc.On("BranchRemote", "trunk").Return("upstream", nil).Once()
   213  			},
   214  			wantErr: true,
   215  			errMsg:  "can't sync because trunk is not tracking OWNER/REPO",
   216  		},
   217  		{
   218  			name: "sync local repo with parent and local changes",
   219  			tty:  true,
   220  			opts: &SyncOptions{
   221  				Branch: "trunk",
   222  			},
   223  			gitStubs: func(mgc *mockGitClient) {
   224  				mgc.On("Fetch", "origin", "refs/heads/trunk").Return(nil).Once()
   225  				mgc.On("HasLocalBranch", "trunk").Return(true).Once()
   226  				mgc.On("BranchRemote", "trunk").Return("origin", nil).Once()
   227  				mgc.On("IsAncestor", "trunk", "FETCH_HEAD").Return(true, nil).Once()
   228  				mgc.On("CurrentBranch").Return("trunk", nil).Once()
   229  				mgc.On("IsDirty").Return(true, nil).Once()
   230  			},
   231  			wantErr: true,
   232  			errMsg:  "can't sync because there are local changes; please stash them before trying again",
   233  		},
   234  		{
   235  			name: "sync local repo with parent - existing branch, non-current",
   236  			tty:  true,
   237  			opts: &SyncOptions{
   238  				Branch: "trunk",
   239  			},
   240  			gitStubs: func(mgc *mockGitClient) {
   241  				mgc.On("Fetch", "origin", "refs/heads/trunk").Return(nil).Once()
   242  				mgc.On("HasLocalBranch", "trunk").Return(true).Once()
   243  				mgc.On("BranchRemote", "trunk").Return("origin", nil).Once()
   244  				mgc.On("IsAncestor", "trunk", "FETCH_HEAD").Return(true, nil).Once()
   245  				mgc.On("CurrentBranch").Return("test", nil).Once()
   246  				mgc.On("UpdateBranch", "trunk", "FETCH_HEAD").Return(nil).Once()
   247  			},
   248  			wantStdout: "✓ Synced the \"trunk\" branch from OWNER/REPO to local repository\n",
   249  		},
   250  		{
   251  			name: "sync local repo with parent - create new branch",
   252  			tty:  true,
   253  			opts: &SyncOptions{
   254  				Branch: "trunk",
   255  			},
   256  			gitStubs: func(mgc *mockGitClient) {
   257  				mgc.On("Fetch", "origin", "refs/heads/trunk").Return(nil).Once()
   258  				mgc.On("HasLocalBranch", "trunk").Return(false).Once()
   259  				mgc.On("CurrentBranch").Return("test", nil).Once()
   260  				mgc.On("CreateBranch", "trunk", "FETCH_HEAD", "origin/trunk").Return(nil).Once()
   261  			},
   262  			wantStdout: "✓ Synced the \"trunk\" branch from OWNER/REPO to local repository\n",
   263  		},
   264  		{
   265  			name: "sync remote fork with parent with new api - tty",
   266  			tty:  true,
   267  			opts: &SyncOptions{
   268  				DestArg: "FORKOWNER/REPO-FORK",
   269  			},
   270  			httpStubs: func(reg *httpmock.Registry) {
   271  				reg.Register(
   272  					httpmock.GraphQL(`query RepositoryInfo\b`),
   273  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   274  				reg.Register(
   275  					httpmock.REST("POST", "repos/FORKOWNER/REPO-FORK/merge-upstream"),
   276  					httpmock.StatusStringResponse(200, `{"base_branch": "OWNER:trunk"}`))
   277  			},
   278  			wantStdout: "✓ Synced the \"FORKOWNER:trunk\" branch from \"OWNER:trunk\"\n",
   279  		},
   280  		{
   281  			name: "sync remote fork with parent using api fallback - tty",
   282  			tty:  true,
   283  			opts: &SyncOptions{
   284  				DestArg: "FORKOWNER/REPO-FORK",
   285  			},
   286  			httpStubs: func(reg *httpmock.Registry) {
   287  				reg.Register(
   288  					httpmock.GraphQL(`query RepositoryFindParent\b`),
   289  					httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`))
   290  				reg.Register(
   291  					httpmock.GraphQL(`query RepositoryInfo\b`),
   292  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   293  				reg.Register(
   294  					httpmock.REST("POST", "repos/FORKOWNER/REPO-FORK/merge-upstream"),
   295  					httpmock.StatusStringResponse(404, `{}`))
   296  				reg.Register(
   297  					httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"),
   298  					httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`))
   299  				reg.Register(
   300  					httpmock.REST("PATCH", "repos/FORKOWNER/REPO-FORK/git/refs/heads/trunk"),
   301  					httpmock.StringResponse(`{}`))
   302  			},
   303  			wantStdout: "✓ Synced the \"FORKOWNER:trunk\" branch from \"OWNER:trunk\"\n",
   304  		},
   305  		{
   306  			name: "sync remote fork with parent - notty",
   307  			tty:  false,
   308  			opts: &SyncOptions{
   309  				DestArg: "FORKOWNER/REPO-FORK",
   310  			},
   311  			httpStubs: func(reg *httpmock.Registry) {
   312  				reg.Register(
   313  					httpmock.GraphQL(`query RepositoryInfo\b`),
   314  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   315  				reg.Register(
   316  					httpmock.REST("POST", "repos/FORKOWNER/REPO-FORK/merge-upstream"),
   317  					httpmock.StatusStringResponse(200, `{"base_branch": "OWNER:trunk"}`))
   318  			},
   319  			wantStdout: "",
   320  		},
   321  		{
   322  			name: "sync remote repo with no parent",
   323  			tty:  true,
   324  			opts: &SyncOptions{
   325  				DestArg: "OWNER/REPO",
   326  				Branch:  "trunk",
   327  			},
   328  			httpStubs: func(reg *httpmock.Registry) {
   329  				reg.Register(
   330  					httpmock.REST("POST", "repos/OWNER/REPO/merge-upstream"),
   331  					httpmock.StatusStringResponse(422, `{"message": "Validation Failed"}`))
   332  				reg.Register(
   333  					httpmock.GraphQL(`query RepositoryFindParent\b`),
   334  					httpmock.StringResponse(`{"data":{"repository":{"parent":null}}}`))
   335  			},
   336  			wantErr: true,
   337  			errMsg:  "can't determine source repository for OWNER/REPO because repository is not fork",
   338  		},
   339  		{
   340  			name: "sync remote repo with specified source repo",
   341  			tty:  true,
   342  			opts: &SyncOptions{
   343  				DestArg: "OWNER/REPO",
   344  				SrcArg:  "OWNER2/REPO2",
   345  				Branch:  "trunk",
   346  			},
   347  			httpStubs: func(reg *httpmock.Registry) {
   348  				reg.Register(
   349  					httpmock.REST("POST", "repos/OWNER/REPO/merge-upstream"),
   350  					httpmock.StatusStringResponse(200, `{"base_branch": "OWNER2:trunk"}`))
   351  			},
   352  			wantStdout: "✓ Synced the \"OWNER:trunk\" branch from \"OWNER2:trunk\"\n",
   353  		},
   354  		{
   355  			name: "sync remote fork with parent and specified branch",
   356  			tty:  true,
   357  			opts: &SyncOptions{
   358  				DestArg: "OWNER/REPO-FORK",
   359  				Branch:  "test",
   360  			},
   361  			httpStubs: func(reg *httpmock.Registry) {
   362  				reg.Register(
   363  					httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"),
   364  					httpmock.StatusStringResponse(200, `{"base_branch": "OWNER:test"}`))
   365  			},
   366  			wantStdout: "✓ Synced the \"OWNER:test\" branch from \"OWNER:test\"\n",
   367  		},
   368  		{
   369  			name: "sync remote fork with parent and force specified",
   370  			tty:  true,
   371  			opts: &SyncOptions{
   372  				DestArg: "OWNER/REPO-FORK",
   373  				Force:   true,
   374  			},
   375  			httpStubs: func(reg *httpmock.Registry) {
   376  				reg.Register(
   377  					httpmock.GraphQL(`query RepositoryFindParent\b`),
   378  					httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`))
   379  				reg.Register(
   380  					httpmock.GraphQL(`query RepositoryInfo\b`),
   381  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   382  				reg.Register(
   383  					httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"),
   384  					httpmock.StatusStringResponse(409, `{"message": "Merge conflict"}`))
   385  				reg.Register(
   386  					httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"),
   387  					httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`))
   388  				reg.Register(
   389  					httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"),
   390  					httpmock.StringResponse(`{}`))
   391  			},
   392  			wantStdout: "✓ Synced the \"OWNER:trunk\" branch from \"OWNER:trunk\"\n",
   393  		},
   394  		{
   395  			name: "sync remote fork with parent and not fast forward merge",
   396  			tty:  true,
   397  			opts: &SyncOptions{
   398  				DestArg: "OWNER/REPO-FORK",
   399  			},
   400  			httpStubs: func(reg *httpmock.Registry) {
   401  				reg.Register(
   402  					httpmock.GraphQL(`query RepositoryFindParent\b`),
   403  					httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`))
   404  				reg.Register(
   405  					httpmock.GraphQL(`query RepositoryInfo\b`),
   406  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   407  				reg.Register(
   408  					httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"),
   409  					httpmock.StatusStringResponse(409, `{"message": "Merge conflict"}`))
   410  				reg.Register(
   411  					httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"),
   412  					httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`))
   413  				reg.Register(
   414  					httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"),
   415  					func(req *http.Request) (*http.Response, error) {
   416  						return &http.Response{
   417  							StatusCode: 422,
   418  							Request:    req,
   419  							Header:     map[string][]string{"Content-Type": {"application/json"}},
   420  							Body:       io.NopCloser(bytes.NewBufferString(`{"message":"Update is not a fast forward"}`)),
   421  						}, nil
   422  					})
   423  			},
   424  			wantErr: true,
   425  			errMsg:  "can't sync because there are diverging changes; use `--force` to overwrite the destination branch",
   426  		},
   427  		{
   428  			name: "sync remote fork with parent and no existing branch on fork",
   429  			tty:  true,
   430  			opts: &SyncOptions{
   431  				DestArg: "OWNER/REPO-FORK",
   432  			},
   433  			httpStubs: func(reg *httpmock.Registry) {
   434  				reg.Register(
   435  					httpmock.GraphQL(`query RepositoryFindParent\b`),
   436  					httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`))
   437  				reg.Register(
   438  					httpmock.GraphQL(`query RepositoryInfo\b`),
   439  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   440  				reg.Register(
   441  					httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"),
   442  					httpmock.StatusStringResponse(409, `{"message": "Merge conflict"}`))
   443  				reg.Register(
   444  					httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"),
   445  					httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`))
   446  				reg.Register(
   447  					httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"),
   448  					func(req *http.Request) (*http.Response, error) {
   449  						return &http.Response{
   450  							StatusCode: 422,
   451  							Request:    req,
   452  							Header:     map[string][]string{"Content-Type": {"application/json"}},
   453  							Body:       io.NopCloser(bytes.NewBufferString(`{"message":"Reference does not exist"}`)),
   454  						}, nil
   455  					})
   456  			},
   457  			wantErr: true,
   458  			errMsg:  "trunk branch does not exist on OWNER/REPO-FORK repository",
   459  		},
   460  	}
   461  	for _, tt := range tests {
   462  		reg := &httpmock.Registry{}
   463  		if tt.httpStubs != nil {
   464  			tt.httpStubs(reg)
   465  		}
   466  		tt.opts.HttpClient = func() (*http.Client, error) {
   467  			return &http.Client{Transport: reg}, nil
   468  		}
   469  
   470  		ios, _, stdout, _ := iostreams.Test()
   471  		ios.SetStdinTTY(tt.tty)
   472  		ios.SetStdoutTTY(tt.tty)
   473  		tt.opts.IO = ios
   474  
   475  		repo1, _ := ghrepo.FromFullName("OWNER/REPO")
   476  		repo2, _ := ghrepo.FromFullName("OWNER2/REPO2")
   477  		tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
   478  			return repo1, nil
   479  		}
   480  
   481  		tt.opts.Remotes = func() (context.Remotes, error) {
   482  			if tt.remotes == nil {
   483  				return []*context.Remote{
   484  					{
   485  						Remote: &git.Remote{Name: "origin"},
   486  						Repo:   repo1,
   487  					},
   488  					{
   489  						Remote: &git.Remote{Name: "upstream"},
   490  						Repo:   repo2,
   491  					},
   492  				}, nil
   493  			}
   494  			return tt.remotes, nil
   495  		}
   496  
   497  		t.Run(tt.name, func(t *testing.T) {
   498  			tt.opts.Git = newMockGitClient(t, tt.gitStubs)
   499  			defer reg.Verify(t)
   500  			err := syncRun(tt.opts)
   501  			if tt.wantErr {
   502  				assert.EqualError(t, err, tt.errMsg)
   503  				return
   504  			} else if err != nil {
   505  				t.Fatalf("syncRun() unexpected error: %v", err)
   506  			}
   507  			assert.Equal(t, tt.wantStdout, stdout.String())
   508  		})
   509  	}
   510  }
   511  
   512  func newMockGitClient(t *testing.T, config func(*mockGitClient)) *mockGitClient {
   513  	t.Helper()
   514  	m := &mockGitClient{}
   515  	m.Test(t)
   516  	t.Cleanup(func() {
   517  		t.Helper()
   518  		m.AssertExpectations(t)
   519  	})
   520  	if config != nil {
   521  		config(m)
   522  	}
   523  	return m
   524  }