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 }