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 }