at master 706 lines 16 kB view raw
1package git 2 3import ( 4 "os" 5 "path/filepath" 6 "strings" 7 "testing" 8 9 "github.com/go-git/go-git/v5" 10 "github.com/go-git/go-git/v5/config" 11 "github.com/go-git/go-git/v5/plumbing" 12 "github.com/go-git/go-git/v5/plumbing/object" 13 "github.com/stretchr/testify/assert" 14 "github.com/stretchr/testify/require" 15) 16 17type Helper struct { 18 t *testing.T 19 tempDir string 20 repo *GitRepo 21} 22 23func helper(t *testing.T) *Helper { 24 tempDir, err := os.MkdirTemp("", "git-merge-test-*") 25 require.NoError(t, err) 26 27 return &Helper{ 28 t: t, 29 tempDir: tempDir, 30 } 31} 32 33func (h *Helper) cleanup() { 34 if h.tempDir != "" { 35 os.RemoveAll(h.tempDir) 36 } 37} 38 39// initRepo initializes a git repository with an initial commit 40func (h *Helper) initRepo() *GitRepo { 41 repoPath := filepath.Join(h.tempDir, "test-repo") 42 43 // initialize repository 44 r, err := git.PlainInit(repoPath, false) 45 require.NoError(h.t, err) 46 47 // configure git user 48 cfg, err := r.Config() 49 require.NoError(h.t, err) 50 cfg.User.Name = "Test User" 51 cfg.User.Email = "test@example.com" 52 err = r.SetConfig(cfg) 53 require.NoError(h.t, err) 54 55 // create initial commit with a file 56 w, err := r.Worktree() 57 require.NoError(h.t, err) 58 59 // create initial file 60 initialFile := filepath.Join(repoPath, "README.md") 61 err = os.WriteFile(initialFile, []byte("# Test Repository\n\nInitial content.\n"), 0644) 62 require.NoError(h.t, err) 63 64 _, err = w.Add("README.md") 65 require.NoError(h.t, err) 66 67 _, err = w.Commit("Initial commit", &git.CommitOptions{ 68 Author: &object.Signature{ 69 Name: "Test User", 70 Email: "test@example.com", 71 }, 72 }) 73 require.NoError(h.t, err) 74 75 gitRepo, err := PlainOpen(repoPath) 76 require.NoError(h.t, err) 77 78 h.repo = gitRepo 79 return gitRepo 80} 81 82// addFile creates a file in the repository 83func (h *Helper) addFile(filename, content string) { 84 filePath := filepath.Join(h.repo.path, filename) 85 dir := filepath.Dir(filePath) 86 87 err := os.MkdirAll(dir, 0755) 88 require.NoError(h.t, err) 89 90 err = os.WriteFile(filePath, []byte(content), 0644) 91 require.NoError(h.t, err) 92} 93 94// commitFile adds and commits a file 95func (h *Helper) commitFile(filename, content, message string) plumbing.Hash { 96 h.addFile(filename, content) 97 98 w, err := h.repo.r.Worktree() 99 require.NoError(h.t, err) 100 101 _, err = w.Add(filename) 102 require.NoError(h.t, err) 103 104 hash, err := w.Commit(message, &git.CommitOptions{ 105 Author: &object.Signature{ 106 Name: "Test User", 107 Email: "test@example.com", 108 }, 109 }) 110 require.NoError(h.t, err) 111 112 return hash 113} 114 115// readFile reads a file from the repository 116func (h *Helper) readFile(filename string) string { 117 content, err := os.ReadFile(filepath.Join(h.repo.path, filename)) 118 require.NoError(h.t, err) 119 return string(content) 120} 121 122// fileExists checks if a file exists in the repository 123func (h *Helper) fileExists(filename string) bool { 124 _, err := os.Stat(filepath.Join(h.repo.path, filename)) 125 return err == nil 126} 127 128func TestApplyPatch_Success(t *testing.T) { 129 h := helper(t) 130 defer h.cleanup() 131 132 repo := h.initRepo() 133 134 // modify README.md 135 patch := `diff --git a/README.md b/README.md 136index 1234567..abcdefg 100644 137--- a/README.md 138+++ b/README.md 139@@ -1,3 +1,3 @@ 140 # Test Repository 141 142-Initial content. 143+Modified content. 144` 145 146 patchFile, err := createTemp(patch) 147 require.NoError(t, err) 148 defer os.Remove(patchFile) 149 150 opts := MergeOptions{ 151 CommitMessage: "Apply test patch", 152 CommitterName: "Test Committer", 153 CommitterEmail: "committer@example.com", 154 FormatPatch: false, 155 } 156 157 err = repo.applyPatch(patch, patchFile, opts) 158 assert.NoError(t, err) 159 160 // verify the file was modified 161 content := h.readFile("README.md") 162 assert.Contains(t, content, "Modified content.") 163} 164 165func TestApplyPatch_AddNewFile(t *testing.T) { 166 h := helper(t) 167 defer h.cleanup() 168 169 repo := h.initRepo() 170 171 // add a new file 172 patch := `diff --git a/newfile.txt b/newfile.txt 173new file mode 100644 174index 0000000..ce01362 175--- /dev/null 176+++ b/newfile.txt 177@@ -0,0 +1 @@ 178+hello 179` 180 181 patchFile, err := createTemp(patch) 182 require.NoError(t, err) 183 defer os.Remove(patchFile) 184 185 opts := MergeOptions{ 186 CommitMessage: "Add new file", 187 CommitterName: "Test Committer", 188 CommitterEmail: "committer@example.com", 189 FormatPatch: false, 190 } 191 192 err = repo.applyPatch(patch, patchFile, opts) 193 assert.NoError(t, err) 194 195 assert.True(t, h.fileExists("newfile.txt")) 196 content := h.readFile("newfile.txt") 197 assert.Equal(t, "hello\n", content) 198} 199 200func TestApplyPatch_DeleteFile(t *testing.T) { 201 h := helper(t) 202 defer h.cleanup() 203 204 repo := h.initRepo() 205 206 // add a file 207 h.commitFile("deleteme.txt", "content to delete\n", "Add file to delete") 208 209 // delete the file 210 patch := `diff --git a/deleteme.txt b/deleteme.txt 211deleted file mode 100644 212index 1234567..0000000 213--- a/deleteme.txt 214+++ /dev/null 215@@ -1 +0,0 @@ 216-content to delete 217` 218 219 patchFile, err := createTemp(patch) 220 require.NoError(t, err) 221 defer os.Remove(patchFile) 222 223 opts := MergeOptions{ 224 CommitMessage: "Delete file", 225 CommitterName: "Test Committer", 226 CommitterEmail: "committer@example.com", 227 FormatPatch: false, 228 } 229 230 err = repo.applyPatch(patch, patchFile, opts) 231 assert.NoError(t, err) 232 233 assert.False(t, h.fileExists("deleteme.txt")) 234} 235 236func TestApplyPatch_WithAuthor(t *testing.T) { 237 h := helper(t) 238 defer h.cleanup() 239 240 repo := h.initRepo() 241 242 patch := `diff --git a/README.md b/README.md 243index 1234567..abcdefg 100644 244--- a/README.md 245+++ b/README.md 246@@ -1,3 +1,4 @@ 247 # Test Repository 248 249 Initial content. 250+New line. 251` 252 253 patchFile, err := createTemp(patch) 254 require.NoError(t, err) 255 defer os.Remove(patchFile) 256 257 opts := MergeOptions{ 258 CommitMessage: "Patch with author", 259 AuthorName: "Patch Author", 260 AuthorEmail: "author@example.com", 261 CommitterName: "Test Committer", 262 CommitterEmail: "committer@example.com", 263 FormatPatch: false, 264 } 265 266 err = repo.applyPatch(patch, patchFile, opts) 267 assert.NoError(t, err) 268 269 head, err := repo.r.Head() 270 require.NoError(t, err) 271 272 commit, err := repo.r.CommitObject(head.Hash()) 273 require.NoError(t, err) 274 275 assert.Equal(t, "Patch Author", commit.Author.Name) 276 assert.Equal(t, "author@example.com", commit.Author.Email) 277} 278 279func TestApplyPatch_MissingFile(t *testing.T) { 280 h := helper(t) 281 defer h.cleanup() 282 283 repo := h.initRepo() 284 285 // patch that modifies a non-existent file 286 patch := `diff --git a/nonexistent.txt b/nonexistent.txt 287index 1234567..abcdefg 100644 288--- a/nonexistent.txt 289+++ b/nonexistent.txt 290@@ -1 +1 @@ 291-old content 292+new content 293` 294 295 patchFile, err := createTemp(patch) 296 require.NoError(t, err) 297 defer os.Remove(patchFile) 298 299 opts := MergeOptions{ 300 CommitMessage: "Should fail", 301 CommitterName: "Test Committer", 302 CommitterEmail: "committer@example.com", 303 FormatPatch: false, 304 } 305 306 err = repo.applyPatch(patch, patchFile, opts) 307 assert.Error(t, err) 308 assert.Contains(t, err.Error(), "patch application failed") 309} 310 311func TestApplyPatch_Conflict(t *testing.T) { 312 h := helper(t) 313 defer h.cleanup() 314 315 repo := h.initRepo() 316 317 // modify the file to create a conflict 318 h.commitFile("README.md", "# Test Repository\n\nDifferent content.\n", "Modify README") 319 320 // patch that expects different content 321 patch := `diff --git a/README.md b/README.md 322index 1234567..abcdefg 100644 323--- a/README.md 324+++ b/README.md 325@@ -1,3 +1,3 @@ 326 # Test Repository 327 328-Initial content. 329+Modified content. 330` 331 332 patchFile, err := createTemp(patch) 333 require.NoError(t, err) 334 defer os.Remove(patchFile) 335 336 opts := MergeOptions{ 337 CommitMessage: "Should conflict", 338 CommitterName: "Test Committer", 339 CommitterEmail: "committer@example.com", 340 FormatPatch: false, 341 } 342 343 err = repo.applyPatch(patch, patchFile, opts) 344 assert.Error(t, err) 345} 346 347func TestApplyPatch_MissingDirectory(t *testing.T) { 348 h := helper(t) 349 defer h.cleanup() 350 351 repo := h.initRepo() 352 353 // patch that adds a file in a non-existent directory 354 patch := `diff --git a/subdir/newfile.txt b/subdir/newfile.txt 355new file mode 100644 356index 0000000..ce01362 357--- /dev/null 358+++ b/subdir/newfile.txt 359@@ -0,0 +1 @@ 360+content 361` 362 363 patchFile, err := createTemp(patch) 364 require.NoError(t, err) 365 defer os.Remove(patchFile) 366 367 opts := MergeOptions{ 368 CommitMessage: "Add file in subdir", 369 CommitterName: "Test Committer", 370 CommitterEmail: "committer@example.com", 371 FormatPatch: false, 372 } 373 374 // git apply should create the directory automatically 375 err = repo.applyPatch(patch, patchFile, opts) 376 assert.NoError(t, err) 377 378 // Verify the file and directory were created 379 assert.True(t, h.fileExists("subdir/newfile.txt")) 380} 381 382func TestApplyMailbox_Single(t *testing.T) { 383 h := helper(t) 384 defer h.cleanup() 385 386 repo := h.initRepo() 387 388 // format-patch mailbox format 389 patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 390From: Patch Author <author@example.com> 391Date: Mon, 1 Jan 2024 12:00:00 +0000 392Subject: [PATCH] Add new feature 393 394This is a test patch. 395--- 396 newfile.txt | 1 + 397 1 file changed, 1 insertion(+) 398 create mode 100644 newfile.txt 399 400diff --git a/newfile.txt b/newfile.txt 401new file mode 100644 402index 0000000..ce01362 403--- /dev/null 404+++ b/newfile.txt 405@@ -0,0 +1 @@ 406+hello 407-- 4082.40.0 409` 410 411 err := repo.applyMailbox(patch) 412 assert.NoError(t, err) 413 414 assert.True(t, h.fileExists("newfile.txt")) 415 content := h.readFile("newfile.txt") 416 assert.Equal(t, "hello\n", content) 417} 418 419func TestApplyMailbox_Multiple(t *testing.T) { 420 h := helper(t) 421 defer h.cleanup() 422 423 repo := h.initRepo() 424 425 // multiple patches in mailbox format 426 patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 427From: Patch Author <author@example.com> 428Date: Mon, 1 Jan 2024 12:00:00 +0000 429Subject: [PATCH 1/2] Add first file 430 431--- 432 file1.txt | 1 + 433 1 file changed, 1 insertion(+) 434 create mode 100644 file1.txt 435 436diff --git a/file1.txt b/file1.txt 437new file mode 100644 438index 0000000..ce01362 439--- /dev/null 440+++ b/file1.txt 441@@ -0,0 +1 @@ 442+first 443-- 4442.40.0 445 446From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 447From: Patch Author <author@example.com> 448Date: Mon, 1 Jan 2024 12:01:00 +0000 449Subject: [PATCH 2/2] Add second file 450 451--- 452 file2.txt | 1 + 453 1 file changed, 1 insertion(+) 454 create mode 100644 file2.txt 455 456diff --git a/file2.txt b/file2.txt 457new file mode 100644 458index 0000000..ce01362 459--- /dev/null 460+++ b/file2.txt 461@@ -0,0 +1 @@ 462+second 463-- 4642.40.0 465` 466 467 err := repo.applyMailbox(patch) 468 assert.NoError(t, err) 469 470 assert.True(t, h.fileExists("file1.txt")) 471 assert.True(t, h.fileExists("file2.txt")) 472 473 content1 := h.readFile("file1.txt") 474 assert.Equal(t, "first\n", content1) 475 476 content2 := h.readFile("file2.txt") 477 assert.Equal(t, "second\n", content2) 478} 479 480func TestApplyMailbox_Conflict(t *testing.T) { 481 h := helper(t) 482 defer h.cleanup() 483 484 repo := h.initRepo() 485 486 h.commitFile("README.md", "# Test Repository\n\nConflicting content.\n", "Create conflict") 487 488 patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 489From: Patch Author <author@example.com> 490Date: Mon, 1 Jan 2024 12:00:00 +0000 491Subject: [PATCH] Modify README 492 493--- 494 README.md | 2 +- 495 1 file changed, 1 insertion(+), 1 deletion(-) 496 497diff --git a/README.md b/README.md 498index 1234567..abcdefg 100644 499--- a/README.md 500+++ b/README.md 501@@ -1,3 +1,3 @@ 502 # Test Repository 503 504-Initial content. 505+Different content. 506-- 5072.40.0 508` 509 510 err := repo.applyMailbox(patch) 511 assert.Error(t, err) 512 513 var mergeErr *ErrMerge 514 assert.ErrorAs(t, err, &mergeErr) 515} 516 517func TestParseGitApplyErrors(t *testing.T) { 518 tests := []struct { 519 name string 520 errorOutput string 521 expectedCount int 522 expectedReason string 523 }{ 524 { 525 name: "file already exists", 526 errorOutput: `error: path/to/file.txt: already exists in working directory`, 527 expectedCount: 1, 528 expectedReason: "file already exists", 529 }, 530 { 531 name: "file does not exist", 532 errorOutput: `error: path/to/file.txt: does not exist in working tree`, 533 expectedCount: 1, 534 expectedReason: "file does not exist", 535 }, 536 { 537 name: "patch does not apply", 538 errorOutput: `error: patch failed: file.txt:10 539error: file.txt: patch does not apply`, 540 expectedCount: 1, 541 expectedReason: "patch does not apply", 542 }, 543 { 544 name: "multiple conflicts", 545 errorOutput: `error: patch failed: file1.txt:5 546error: file1.txt:5: some error 547error: patch failed: file2.txt:10 548error: file2.txt:10: another error`, 549 expectedCount: 2, 550 }, 551 } 552 553 for _, tt := range tests { 554 t.Run(tt.name, func(t *testing.T) { 555 conflicts := parseGitApplyErrors(tt.errorOutput) 556 assert.Len(t, conflicts, tt.expectedCount) 557 558 if tt.expectedReason != "" && len(conflicts) > 0 { 559 assert.Equal(t, tt.expectedReason, conflicts[0].Reason) 560 } 561 }) 562 } 563} 564 565func TestErrMerge_Error(t *testing.T) { 566 tests := []struct { 567 name string 568 err ErrMerge 569 expectedMsg string 570 }{ 571 { 572 name: "with conflicts", 573 err: ErrMerge{ 574 Message: "test merge failed", 575 HasConflict: true, 576 Conflicts: []ConflictInfo{ 577 {Filename: "file1.txt", Reason: "conflict 1"}, 578 {Filename: "file2.txt", Reason: "conflict 2"}, 579 }, 580 }, 581 expectedMsg: "merge failed due to conflicts: test merge failed (2 conflicts)", 582 }, 583 { 584 name: "with other error", 585 err: ErrMerge{ 586 Message: "command failed", 587 OtherError: assert.AnError, 588 }, 589 expectedMsg: "merge failed: command failed:", 590 }, 591 { 592 name: "message only", 593 err: ErrMerge{ 594 Message: "simple failure", 595 }, 596 expectedMsg: "merge failed: simple failure", 597 }, 598 } 599 600 for _, tt := range tests { 601 t.Run(tt.name, func(t *testing.T) { 602 errMsg := tt.err.Error() 603 assert.Contains(t, errMsg, tt.expectedMsg) 604 }) 605 } 606} 607 608func TestMergeWithOptions_Integration(t *testing.T) { 609 h := helper(t) 610 defer h.cleanup() 611 612 // create a repository first with initial content 613 workRepoPath := filepath.Join(h.tempDir, "work-repo") 614 workRepo, err := git.PlainInit(workRepoPath, false) 615 require.NoError(t, err) 616 617 // configure git user 618 cfg, err := workRepo.Config() 619 require.NoError(t, err) 620 cfg.User.Name = "Test User" 621 cfg.User.Email = "test@example.com" 622 err = workRepo.SetConfig(cfg) 623 require.NoError(t, err) 624 625 // Create initial commit 626 w, err := workRepo.Worktree() 627 require.NoError(t, err) 628 629 err = os.WriteFile(filepath.Join(workRepoPath, "README.md"), []byte("# Initial\n"), 0644) 630 require.NoError(t, err) 631 632 _, err = w.Add("README.md") 633 require.NoError(t, err) 634 635 _, err = w.Commit("Initial commit", &git.CommitOptions{ 636 Author: &object.Signature{ 637 Name: "Test User", 638 Email: "test@example.com", 639 }, 640 }) 641 require.NoError(t, err) 642 643 // create a bare repository (like production) 644 bareRepoPath := filepath.Join(h.tempDir, "bare-repo") 645 err = InitBare(bareRepoPath, "main") 646 require.NoError(t, err) 647 648 // add bare repo as remote and push to it 649 _, err = workRepo.CreateRemote(&config.RemoteConfig{ 650 Name: "origin", 651 URLs: []string{"file://" + bareRepoPath}, 652 }) 653 require.NoError(t, err) 654 655 err = workRepo.Push(&git.PushOptions{ 656 RemoteName: "origin", 657 RefSpecs: []config.RefSpec{"refs/heads/master:refs/heads/main"}, 658 }) 659 require.NoError(t, err) 660 661 // now merge a patch into the bare repo 662 gitRepo, err := PlainOpen(bareRepoPath) 663 require.NoError(t, err) 664 665 patch := `diff --git a/feature.txt b/feature.txt 666new file mode 100644 667index 0000000..5e1c309 668--- /dev/null 669+++ b/feature.txt 670@@ -0,0 +1 @@ 671+Hello World 672` 673 674 opts := MergeOptions{ 675 CommitMessage: "Add feature", 676 CommitterName: "Test Committer", 677 CommitterEmail: "committer@example.com", 678 FormatPatch: false, 679 } 680 681 err = gitRepo.MergeWithOptions(patch, "main", opts) 682 assert.NoError(t, err) 683 684 // Clone again and verify the changes were merged 685 verifyRepoPath := filepath.Join(h.tempDir, "verify-repo") 686 verifyRepo, err := git.PlainClone(verifyRepoPath, false, &git.CloneOptions{ 687 URL: "file://" + bareRepoPath, 688 }) 689 require.NoError(t, err) 690 691 // check that feature.txt exists 692 featureFile := filepath.Join(verifyRepoPath, "feature.txt") 693 assert.FileExists(t, featureFile) 694 695 content, err := os.ReadFile(featureFile) 696 require.NoError(t, err) 697 assert.Equal(t, "Hello World\n", string(content)) 698 699 // verify commit message 700 head, err := verifyRepo.Head() 701 require.NoError(t, err) 702 703 commit, err := verifyRepo.CommitObject(head.Hash()) 704 require.NoError(t, err) 705 assert.Equal(t, "Add feature", strings.TrimSpace(commit.Message)) 706}