github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/pkg/cmd/repo/sync/sync_test.go (about)

     1  package sync
     2  
     3  import (
     4  	"bytes"
     5  	"io/ioutil"
     6  	"net/http"
     7  	"testing"
     8  
     9  	"github.com/cli/cli/context"
    10  	"github.com/cli/cli/git"
    11  	"github.com/cli/cli/internal/ghrepo"
    12  	"github.com/cli/cli/pkg/cmdutil"
    13  	"github.com/cli/cli/pkg/httpmock"
    14  	"github.com/cli/cli/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: "cli/cli",
    38  			output: SyncOptions{
    39  				DestArg: "cli/cli",
    40  			},
    41  		},
    42  		{
    43  			name:  "source repo",
    44  			tty:   true,
    45  			input: "--source cli/cli",
    46  			output: SyncOptions{
    47  				SrcArg: "cli/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  			io, _, _, _ := iostreams.Test()
    70  			io.SetStdinTTY(tt.tty)
    71  			io.SetStdoutTTY(tt.tty)
    72  			f := &cmdutil.Factory{
    73  				IOStreams: io,
    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 - tty",
   266  			tty:  true,
   267  			opts: &SyncOptions{
   268  				DestArg: "OWNER/REPO-FORK",
   269  			},
   270  			httpStubs: func(reg *httpmock.Registry) {
   271  				reg.Register(
   272  					httpmock.GraphQL(`query RepositoryFindParent\b`),
   273  					httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`))
   274  				reg.Register(
   275  					httpmock.GraphQL(`query RepositoryInfo\b`),
   276  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   277  				reg.Register(
   278  					httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"),
   279  					httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`))
   280  				reg.Register(
   281  					httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"),
   282  					httpmock.StringResponse(`{}`))
   283  			},
   284  			wantStdout: "✓ Synced the \"trunk\" branch from OWNER/REPO to OWNER/REPO-FORK\n",
   285  		},
   286  		{
   287  			name: "sync remote fork with parent - notty",
   288  			tty:  false,
   289  			opts: &SyncOptions{
   290  				DestArg: "OWNER/REPO-FORK",
   291  			},
   292  			httpStubs: func(reg *httpmock.Registry) {
   293  				reg.Register(
   294  					httpmock.GraphQL(`query RepositoryFindParent\b`),
   295  					httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`))
   296  				reg.Register(
   297  					httpmock.GraphQL(`query RepositoryInfo\b`),
   298  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   299  				reg.Register(
   300  					httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"),
   301  					httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`))
   302  				reg.Register(
   303  					httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"),
   304  					httpmock.StringResponse(`{}`))
   305  			},
   306  			wantStdout: "",
   307  		},
   308  		{
   309  			name: "sync remote repo with no parent",
   310  			tty:  true,
   311  			opts: &SyncOptions{
   312  				DestArg: "OWNER/REPO",
   313  			},
   314  			httpStubs: func(reg *httpmock.Registry) {
   315  				reg.Register(
   316  					httpmock.GraphQL(`query RepositoryFindParent\b`),
   317  					httpmock.StringResponse(`{"data":{"repository":{}}}`))
   318  			},
   319  			wantErr: true,
   320  			errMsg:  "can't determine source repository for OWNER/REPO because repository is not fork",
   321  		},
   322  		{
   323  			name: "sync remote repo with specified source repo",
   324  			tty:  true,
   325  			opts: &SyncOptions{
   326  				DestArg: "OWNER/REPO",
   327  				SrcArg:  "OWNER2/REPO2",
   328  			},
   329  			httpStubs: func(reg *httpmock.Registry) {
   330  				reg.Register(
   331  					httpmock.GraphQL(`query RepositoryInfo\b`),
   332  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   333  				reg.Register(
   334  					httpmock.REST("GET", "repos/OWNER2/REPO2/git/refs/heads/trunk"),
   335  					httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`))
   336  				reg.Register(
   337  					httpmock.REST("PATCH", "repos/OWNER/REPO/git/refs/heads/trunk"),
   338  					httpmock.StringResponse(`{}`))
   339  			},
   340  			wantStdout: "✓ Synced the \"trunk\" branch from OWNER2/REPO2 to OWNER/REPO\n",
   341  		},
   342  		{
   343  			name: "sync remote fork with parent and specified branch",
   344  			tty:  true,
   345  			opts: &SyncOptions{
   346  				DestArg: "OWNER/REPO-FORK",
   347  				Branch:  "test",
   348  			},
   349  			httpStubs: func(reg *httpmock.Registry) {
   350  				reg.Register(
   351  					httpmock.GraphQL(`query RepositoryFindParent\b`),
   352  					httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`))
   353  				reg.Register(
   354  					httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/test"),
   355  					httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`))
   356  				reg.Register(
   357  					httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/test"),
   358  					httpmock.StringResponse(`{}`))
   359  			},
   360  			wantStdout: "✓ Synced the \"test\" branch from OWNER/REPO to OWNER/REPO-FORK\n",
   361  		},
   362  		{
   363  			name: "sync remote fork with parent and force specified",
   364  			tty:  true,
   365  			opts: &SyncOptions{
   366  				DestArg: "OWNER/REPO-FORK",
   367  				Force:   true,
   368  			},
   369  			httpStubs: func(reg *httpmock.Registry) {
   370  				reg.Register(
   371  					httpmock.GraphQL(`query RepositoryFindParent\b`),
   372  					httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`))
   373  				reg.Register(
   374  					httpmock.GraphQL(`query RepositoryInfo\b`),
   375  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   376  				reg.Register(
   377  					httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"),
   378  					httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`))
   379  				reg.Register(
   380  					httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"),
   381  					httpmock.StringResponse(`{}`))
   382  			},
   383  			wantStdout: "✓ Synced the \"trunk\" branch from OWNER/REPO to OWNER/REPO-FORK\n",
   384  		},
   385  		{
   386  			name: "sync remote fork with parent and not fast forward merge",
   387  			tty:  true,
   388  			opts: &SyncOptions{
   389  				DestArg: "OWNER/REPO-FORK",
   390  			},
   391  			httpStubs: func(reg *httpmock.Registry) {
   392  				reg.Register(
   393  					httpmock.GraphQL(`query RepositoryFindParent\b`),
   394  					httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`))
   395  				reg.Register(
   396  					httpmock.GraphQL(`query RepositoryInfo\b`),
   397  					httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`))
   398  				reg.Register(
   399  					httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"),
   400  					httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`))
   401  				reg.Register(
   402  					httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"),
   403  					func(req *http.Request) (*http.Response, error) {
   404  						return &http.Response{
   405  							StatusCode: 422,
   406  							Request:    req,
   407  							Header:     map[string][]string{"Content-Type": {"application/json"}},
   408  							Body:       ioutil.NopCloser(bytes.NewBufferString(`{"message":"Update is not a fast forward"}`)),
   409  						}, nil
   410  					})
   411  			},
   412  			wantErr: true,
   413  			errMsg:  "can't sync because there are diverging changes; use `--force` to overwrite the destination branch",
   414  		},
   415  	}
   416  	for _, tt := range tests {
   417  		reg := &httpmock.Registry{}
   418  		if tt.httpStubs != nil {
   419  			tt.httpStubs(reg)
   420  		}
   421  		tt.opts.HttpClient = func() (*http.Client, error) {
   422  			return &http.Client{Transport: reg}, nil
   423  		}
   424  
   425  		io, _, stdout, _ := iostreams.Test()
   426  		io.SetStdinTTY(tt.tty)
   427  		io.SetStdoutTTY(tt.tty)
   428  		tt.opts.IO = io
   429  
   430  		repo1, _ := ghrepo.FromFullName("OWNER/REPO")
   431  		repo2, _ := ghrepo.FromFullName("OWNER2/REPO2")
   432  		tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
   433  			return repo1, nil
   434  		}
   435  
   436  		tt.opts.Remotes = func() (context.Remotes, error) {
   437  			if tt.remotes == nil {
   438  				return []*context.Remote{
   439  					{
   440  						Remote: &git.Remote{Name: "origin"},
   441  						Repo:   repo1,
   442  					},
   443  					{
   444  						Remote: &git.Remote{Name: "upstream"},
   445  						Repo:   repo2,
   446  					},
   447  				}, nil
   448  			}
   449  			return tt.remotes, nil
   450  		}
   451  
   452  		t.Run(tt.name, func(t *testing.T) {
   453  			tt.opts.Git = newMockGitClient(t, tt.gitStubs)
   454  			defer reg.Verify(t)
   455  			err := syncRun(tt.opts)
   456  			if tt.wantErr {
   457  				assert.Error(t, err)
   458  				assert.Equal(t, tt.errMsg, err.Error())
   459  				return
   460  			}
   461  			assert.NoError(t, err)
   462  			assert.Equal(t, tt.wantStdout, stdout.String())
   463  		})
   464  	}
   465  }
   466  
   467  func newMockGitClient(t *testing.T, config func(*mockGitClient)) *mockGitClient {
   468  	t.Helper()
   469  	m := &mockGitClient{}
   470  	m.Test(t)
   471  	t.Cleanup(func() {
   472  		t.Helper()
   473  		m.AssertExpectations(t)
   474  	})
   475  	if config != nil {
   476  		config(m)
   477  	}
   478  	return m
   479  }