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}