This repository has no description
17 kB
754 lines
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_DoesNotCommitPatchFile(t *testing.T) {
201 h := helper(t)
202 defer h.cleanup()
203
204 repo := h.initRepo()
205
206 patch := `diff --git a/scallop.txt b/scallop.txt
207new file mode 100644
208index 0000000..ce01362
209--- /dev/null
210+++ b/scallop.txt
211@@ -0,0 +1 @@
212+hello
213`
214
215 patchFile, err := createTempIn(repo.path, patch)
216 require.NoError(t, err)
217 defer os.Remove(patchFile)
218
219 opts := MergeOptions{
220 CommitMessage: "Add scallop.txt",
221 CommitterName: "nel",
222 CommitterEmail: "nel@nel.pet",
223 FormatPatch: false,
224 }
225
226 err = repo.applyPatch(patch, patchFile, opts)
227 require.NoError(t, err)
228
229 refreshed, err := PlainOpen(repo.path)
230 require.NoError(t, err)
231
232 head, err := refreshed.r.Head()
233 require.NoError(t, err)
234
235 commit, err := refreshed.r.CommitObject(head.Hash())
236 require.NoError(t, err)
237
238 tree, err := commit.Tree()
239 require.NoError(t, err)
240
241 _, err = tree.File("scallop.txt")
242 assert.NoError(t, err, "patched file should be committed")
243
244 _, err = tree.File(filepath.Base(patchFile))
245 assert.ErrorIs(t, err, object.ErrFileNotFound, "temporary patch file must not be committed")
246}
247
248func TestApplyPatch_DeleteFile(t *testing.T) {
249 h := helper(t)
250 defer h.cleanup()
251
252 repo := h.initRepo()
253
254 // add a file
255 h.commitFile("deleteme.txt", "content to delete\n", "Add file to delete")
256
257 // delete the file
258 patch := `diff --git a/deleteme.txt b/deleteme.txt
259deleted file mode 100644
260index 1234567..0000000
261--- a/deleteme.txt
262+++ /dev/null
263@@ -1 +0,0 @@
264-content to delete
265`
266
267 patchFile, err := createTemp(patch)
268 require.NoError(t, err)
269 defer os.Remove(patchFile)
270
271 opts := MergeOptions{
272 CommitMessage: "Delete file",
273 CommitterName: "Test Committer",
274 CommitterEmail: "committer@example.com",
275 FormatPatch: false,
276 }
277
278 err = repo.applyPatch(patch, patchFile, opts)
279 assert.NoError(t, err)
280
281 assert.False(t, h.fileExists("deleteme.txt"))
282}
283
284func TestApplyPatch_WithAuthor(t *testing.T) {
285 h := helper(t)
286 defer h.cleanup()
287
288 repo := h.initRepo()
289
290 patch := `diff --git a/README.md b/README.md
291index 1234567..abcdefg 100644
292--- a/README.md
293+++ b/README.md
294@@ -1,3 +1,4 @@
295 # Test Repository
296
297 Initial content.
298+New line.
299`
300
301 patchFile, err := createTemp(patch)
302 require.NoError(t, err)
303 defer os.Remove(patchFile)
304
305 opts := MergeOptions{
306 CommitMessage: "Patch with author",
307 AuthorName: "Patch Author",
308 AuthorEmail: "author@example.com",
309 CommitterName: "Test Committer",
310 CommitterEmail: "committer@example.com",
311 FormatPatch: false,
312 }
313
314 err = repo.applyPatch(patch, patchFile, opts)
315 assert.NoError(t, err)
316
317 head, err := repo.r.Head()
318 require.NoError(t, err)
319
320 commit, err := repo.r.CommitObject(head.Hash())
321 require.NoError(t, err)
322
323 assert.Equal(t, "Patch Author", commit.Author.Name)
324 assert.Equal(t, "author@example.com", commit.Author.Email)
325}
326
327func TestApplyPatch_MissingFile(t *testing.T) {
328 h := helper(t)
329 defer h.cleanup()
330
331 repo := h.initRepo()
332
333 // patch that modifies a non-existent file
334 patch := `diff --git a/nonexistent.txt b/nonexistent.txt
335index 1234567..abcdefg 100644
336--- a/nonexistent.txt
337+++ b/nonexistent.txt
338@@ -1 +1 @@
339-old content
340+new content
341`
342
343 patchFile, err := createTemp(patch)
344 require.NoError(t, err)
345 defer os.Remove(patchFile)
346
347 opts := MergeOptions{
348 CommitMessage: "Should fail",
349 CommitterName: "Test Committer",
350 CommitterEmail: "committer@example.com",
351 FormatPatch: false,
352 }
353
354 err = repo.applyPatch(patch, patchFile, opts)
355 assert.Error(t, err)
356 assert.Contains(t, err.Error(), "patch application failed")
357}
358
359func TestApplyPatch_Conflict(t *testing.T) {
360 h := helper(t)
361 defer h.cleanup()
362
363 repo := h.initRepo()
364
365 // modify the file to create a conflict
366 h.commitFile("README.md", "# Test Repository\n\nDifferent content.\n", "Modify README")
367
368 // patch that expects different content
369 patch := `diff --git a/README.md b/README.md
370index 1234567..abcdefg 100644
371--- a/README.md
372+++ b/README.md
373@@ -1,3 +1,3 @@
374 # Test Repository
375
376-Initial content.
377+Modified content.
378`
379
380 patchFile, err := createTemp(patch)
381 require.NoError(t, err)
382 defer os.Remove(patchFile)
383
384 opts := MergeOptions{
385 CommitMessage: "Should conflict",
386 CommitterName: "Test Committer",
387 CommitterEmail: "committer@example.com",
388 FormatPatch: false,
389 }
390
391 err = repo.applyPatch(patch, patchFile, opts)
392 assert.Error(t, err)
393}
394
395func TestApplyPatch_MissingDirectory(t *testing.T) {
396 h := helper(t)
397 defer h.cleanup()
398
399 repo := h.initRepo()
400
401 // patch that adds a file in a non-existent directory
402 patch := `diff --git a/subdir/newfile.txt b/subdir/newfile.txt
403new file mode 100644
404index 0000000..ce01362
405--- /dev/null
406+++ b/subdir/newfile.txt
407@@ -0,0 +1 @@
408+content
409`
410
411 patchFile, err := createTemp(patch)
412 require.NoError(t, err)
413 defer os.Remove(patchFile)
414
415 opts := MergeOptions{
416 CommitMessage: "Add file in subdir",
417 CommitterName: "Test Committer",
418 CommitterEmail: "committer@example.com",
419 FormatPatch: false,
420 }
421
422 // git apply should create the directory automatically
423 err = repo.applyPatch(patch, patchFile, opts)
424 assert.NoError(t, err)
425
426 // Verify the file and directory were created
427 assert.True(t, h.fileExists("subdir/newfile.txt"))
428}
429
430func TestApplyMailbox_Single(t *testing.T) {
431 h := helper(t)
432 defer h.cleanup()
433
434 repo := h.initRepo()
435
436 // format-patch mailbox format
437 patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
438From: Patch Author <author@example.com>
439Date: Mon, 1 Jan 2024 12:00:00 +0000
440Subject: [PATCH] Add new feature
441
442This is a test patch.
443---
444 newfile.txt | 1 +
445 1 file changed, 1 insertion(+)
446 create mode 100644 newfile.txt
447
448diff --git a/newfile.txt b/newfile.txt
449new file mode 100644
450index 0000000..ce01362
451--- /dev/null
452+++ b/newfile.txt
453@@ -0,0 +1 @@
454+hello
455--
4562.40.0
457`
458
459 err := repo.applyMailbox(patch)
460 assert.NoError(t, err)
461
462 assert.True(t, h.fileExists("newfile.txt"))
463 content := h.readFile("newfile.txt")
464 assert.Equal(t, "hello\n", content)
465}
466
467func TestApplyMailbox_Multiple(t *testing.T) {
468 h := helper(t)
469 defer h.cleanup()
470
471 repo := h.initRepo()
472
473 // multiple patches in mailbox format
474 patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
475From: Patch Author <author@example.com>
476Date: Mon, 1 Jan 2024 12:00:00 +0000
477Subject: [PATCH 1/2] Add first file
478
479---
480 file1.txt | 1 +
481 1 file changed, 1 insertion(+)
482 create mode 100644 file1.txt
483
484diff --git a/file1.txt b/file1.txt
485new file mode 100644
486index 0000000..ce01362
487--- /dev/null
488+++ b/file1.txt
489@@ -0,0 +1 @@
490+first
491--
4922.40.0
493
494From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
495From: Patch Author <author@example.com>
496Date: Mon, 1 Jan 2024 12:01:00 +0000
497Subject: [PATCH 2/2] Add second file
498
499---
500 file2.txt | 1 +
501 1 file changed, 1 insertion(+)
502 create mode 100644 file2.txt
503
504diff --git a/file2.txt b/file2.txt
505new file mode 100644
506index 0000000..ce01362
507--- /dev/null
508+++ b/file2.txt
509@@ -0,0 +1 @@
510+second
511--
5122.40.0
513`
514
515 err := repo.applyMailbox(patch)
516 assert.NoError(t, err)
517
518 assert.True(t, h.fileExists("file1.txt"))
519 assert.True(t, h.fileExists("file2.txt"))
520
521 content1 := h.readFile("file1.txt")
522 assert.Equal(t, "first\n", content1)
523
524 content2 := h.readFile("file2.txt")
525 assert.Equal(t, "second\n", content2)
526}
527
528func TestApplyMailbox_Conflict(t *testing.T) {
529 h := helper(t)
530 defer h.cleanup()
531
532 repo := h.initRepo()
533
534 h.commitFile("README.md", "# Test Repository\n\nConflicting content.\n", "Create conflict")
535
536 patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
537From: Patch Author <author@example.com>
538Date: Mon, 1 Jan 2024 12:00:00 +0000
539Subject: [PATCH] Modify README
540
541---
542 README.md | 2 +-
543 1 file changed, 1 insertion(+), 1 deletion(-)
544
545diff --git a/README.md b/README.md
546index 1234567..abcdefg 100644
547--- a/README.md
548+++ b/README.md
549@@ -1,3 +1,3 @@
550 # Test Repository
551
552-Initial content.
553+Different content.
554--
5552.40.0
556`
557
558 err := repo.applyMailbox(patch)
559 assert.Error(t, err)
560
561 var mergeErr *ErrMerge
562 assert.ErrorAs(t, err, &mergeErr)
563}
564
565func TestParseGitApplyErrors(t *testing.T) {
566 tests := []struct {
567 name string
568 errorOutput string
569 expectedCount int
570 expectedReason string
571 }{
572 {
573 name: "file already exists",
574 errorOutput: `error: path/to/file.txt: already exists in working directory`,
575 expectedCount: 1,
576 expectedReason: "file already exists",
577 },
578 {
579 name: "file does not exist",
580 errorOutput: `error: path/to/file.txt: does not exist in working tree`,
581 expectedCount: 1,
582 expectedReason: "file does not exist",
583 },
584 {
585 name: "patch does not apply",
586 errorOutput: `error: patch failed: file.txt:10
587error: file.txt: patch does not apply`,
588 expectedCount: 1,
589 expectedReason: "patch does not apply",
590 },
591 {
592 name: "multiple conflicts",
593 errorOutput: `error: patch failed: file1.txt:5
594error: file1.txt:5: some error
595error: patch failed: file2.txt:10
596error: file2.txt:10: another error`,
597 expectedCount: 2,
598 },
599 }
600
601 for _, tt := range tests {
602 t.Run(tt.name, func(t *testing.T) {
603 conflicts := parseGitApplyErrors(tt.errorOutput)
604 assert.Len(t, conflicts, tt.expectedCount)
605
606 if tt.expectedReason != "" && len(conflicts) > 0 {
607 assert.Equal(t, tt.expectedReason, conflicts[0].Reason)
608 }
609 })
610 }
611}
612
613func TestErrMerge_Error(t *testing.T) {
614 tests := []struct {
615 name string
616 err ErrMerge
617 expectedMsg string
618 }{
619 {
620 name: "with conflicts",
621 err: ErrMerge{
622 Message: "test merge failed",
623 HasConflict: true,
624 Conflicts: []ConflictInfo{
625 {Filename: "file1.txt", Reason: "conflict 1"},
626 {Filename: "file2.txt", Reason: "conflict 2"},
627 },
628 },
629 expectedMsg: "merge failed due to conflicts: test merge failed (2 conflicts)",
630 },
631 {
632 name: "with other error",
633 err: ErrMerge{
634 Message: "command failed",
635 OtherError: assert.AnError,
636 },
637 expectedMsg: "merge failed: command failed:",
638 },
639 {
640 name: "message only",
641 err: ErrMerge{
642 Message: "simple failure",
643 },
644 expectedMsg: "merge failed: simple failure",
645 },
646 }
647
648 for _, tt := range tests {
649 t.Run(tt.name, func(t *testing.T) {
650 errMsg := tt.err.Error()
651 assert.Contains(t, errMsg, tt.expectedMsg)
652 })
653 }
654}
655
656func TestMergeWithOptions_Integration(t *testing.T) {
657 h := helper(t)
658 defer h.cleanup()
659
660 // create a repository first with initial content
661 workRepoPath := filepath.Join(h.tempDir, "work-repo")
662 workRepo, err := git.PlainInit(workRepoPath, false)
663 require.NoError(t, err)
664
665 // configure git user
666 cfg, err := workRepo.Config()
667 require.NoError(t, err)
668 cfg.User.Name = "Test User"
669 cfg.User.Email = "test@example.com"
670 err = workRepo.SetConfig(cfg)
671 require.NoError(t, err)
672
673 // Create initial commit
674 w, err := workRepo.Worktree()
675 require.NoError(t, err)
676
677 err = os.WriteFile(filepath.Join(workRepoPath, "README.md"), []byte("# Initial\n"), 0644)
678 require.NoError(t, err)
679
680 _, err = w.Add("README.md")
681 require.NoError(t, err)
682
683 _, err = w.Commit("Initial commit", &git.CommitOptions{
684 Author: &object.Signature{
685 Name: "Test User",
686 Email: "test@example.com",
687 },
688 })
689 require.NoError(t, err)
690
691 // create a bare repository (like production)
692 bareRepoPath := filepath.Join(h.tempDir, "bare-repo")
693 err = InitBare(bareRepoPath, "main")
694 require.NoError(t, err)
695
696 // add bare repo as remote and push to it
697 _, err = workRepo.CreateRemote(&config.RemoteConfig{
698 Name: "origin",
699 URLs: []string{"file://" + bareRepoPath},
700 })
701 require.NoError(t, err)
702
703 err = workRepo.Push(&git.PushOptions{
704 RemoteName: "origin",
705 RefSpecs: []config.RefSpec{"refs/heads/master:refs/heads/main"},
706 })
707 require.NoError(t, err)
708
709 // now merge a patch into the bare repo
710 gitRepo, err := PlainOpen(bareRepoPath)
711 require.NoError(t, err)
712
713 patch := `diff --git a/feature.txt b/feature.txt
714new file mode 100644
715index 0000000..5e1c309
716--- /dev/null
717+++ b/feature.txt
718@@ -0,0 +1 @@
719+Hello World
720`
721
722 opts := MergeOptions{
723 CommitMessage: "Add feature",
724 CommitterName: "Test Committer",
725 CommitterEmail: "committer@example.com",
726 FormatPatch: false,
727 }
728
729 err = gitRepo.MergeWithOptions(patch, "main", opts)
730 assert.NoError(t, err)
731
732 // Clone again and verify the changes were merged
733 verifyRepoPath := filepath.Join(h.tempDir, "verify-repo")
734 verifyRepo, err := git.PlainClone(verifyRepoPath, false, &git.CloneOptions{
735 URL: "file://" + bareRepoPath,
736 })
737 require.NoError(t, err)
738
739 // check that feature.txt exists
740 featureFile := filepath.Join(verifyRepoPath, "feature.txt")
741 assert.FileExists(t, featureFile)
742
743 content, err := os.ReadFile(featureFile)
744 require.NoError(t, err)
745 assert.Equal(t, "Hello World\n", string(content))
746
747 // verify commit message
748 head, err := verifyRepo.Head()
749 require.NoError(t, err)
750
751 commit, err := verifyRepo.CommitObject(head.Hash())
752 require.NoError(t, err)
753 assert.Equal(t, "Add feature", strings.TrimSpace(commit.Message))
754}