diff --git a/knotserver/git/merge_test.go b/knotserver/git/merge_test.go new file mode 100644 index 00000000..c4e7f752 --- /dev/null +++ b/knotserver/git/merge_test.go @@ -0,0 +1,706 @@ +package git + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type Helper struct { + t *testing.T + tempDir string + repo *GitRepo +} + +func helper(t *testing.T) *Helper { + tempDir, err := os.MkdirTemp("", "git-merge-test-*") + require.NoError(t, err) + + return &Helper{ + t: t, + tempDir: tempDir, + } +} + +func (h *Helper) cleanup() { + if h.tempDir != "" { + os.RemoveAll(h.tempDir) + } +} + +// initRepo initializes a git repository with an initial commit +func (h *Helper) initRepo() *GitRepo { + repoPath := filepath.Join(h.tempDir, "test-repo") + + // initialize repository + r, err := git.PlainInit(repoPath, false) + require.NoError(h.t, err) + + // configure git user + cfg, err := r.Config() + require.NoError(h.t, err) + cfg.User.Name = "Test User" + cfg.User.Email = "test@example.com" + err = r.SetConfig(cfg) + require.NoError(h.t, err) + + // create initial commit with a file + w, err := r.Worktree() + require.NoError(h.t, err) + + // create initial file + initialFile := filepath.Join(repoPath, "README.md") + err = os.WriteFile(initialFile, []byte("# Test Repository\n\nInitial content.\n"), 0644) + require.NoError(h.t, err) + + _, err = w.Add("README.md") + require.NoError(h.t, err) + + _, err = w.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + }, + }) + require.NoError(h.t, err) + + gitRepo, err := PlainOpen(repoPath) + require.NoError(h.t, err) + + h.repo = gitRepo + return gitRepo +} + +// addFile creates a file in the repository +func (h *Helper) addFile(filename, content string) { + filePath := filepath.Join(h.repo.path, filename) + dir := filepath.Dir(filePath) + + err := os.MkdirAll(dir, 0755) + require.NoError(h.t, err) + + err = os.WriteFile(filePath, []byte(content), 0644) + require.NoError(h.t, err) +} + +// commitFile adds and commits a file +func (h *Helper) commitFile(filename, content, message string) plumbing.Hash { + h.addFile(filename, content) + + w, err := h.repo.r.Worktree() + require.NoError(h.t, err) + + _, err = w.Add(filename) + require.NoError(h.t, err) + + hash, err := w.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + }, + }) + require.NoError(h.t, err) + + return hash +} + +// readFile reads a file from the repository +func (h *Helper) readFile(filename string) string { + content, err := os.ReadFile(filepath.Join(h.repo.path, filename)) + require.NoError(h.t, err) + return string(content) +} + +// fileExists checks if a file exists in the repository +func (h *Helper) fileExists(filename string) bool { + _, err := os.Stat(filepath.Join(h.repo.path, filename)) + return err == nil +} + +func TestApplyPatch_Success(t *testing.T) { + h := helper(t) + defer h.cleanup() + + repo := h.initRepo() + + // modify README.md + patch := `diff --git a/README.md b/README.md +index 1234567..abcdefg 100644 +--- a/README.md ++++ b/README.md +@@ -1,3 +1,3 @@ + # Test Repository + +-Initial content. ++Modified content. +` + + patchFile, err := createTempFileWithPatch(patch) + require.NoError(t, err) + defer os.Remove(patchFile) + + opts := MergeOptions{ + CommitMessage: "Apply test patch", + CommitterName: "Test Committer", + CommitterEmail: "committer@example.com", + FormatPatch: false, + } + + err = repo.applyPatch(patch, patchFile, opts) + assert.NoError(t, err) + + // verify the file was modified + content := h.readFile("README.md") + assert.Contains(t, content, "Modified content.") +} + +func TestApplyPatch_AddNewFile(t *testing.T) { + h := helper(t) + defer h.cleanup() + + repo := h.initRepo() + + // add a new file + patch := `diff --git a/newfile.txt b/newfile.txt +new file mode 100644 +index 0000000..ce01362 +--- /dev/null ++++ b/newfile.txt +@@ -0,0 +1 @@ ++hello +` + + patchFile, err := createTempFileWithPatch(patch) + require.NoError(t, err) + defer os.Remove(patchFile) + + opts := MergeOptions{ + CommitMessage: "Add new file", + CommitterName: "Test Committer", + CommitterEmail: "committer@example.com", + FormatPatch: false, + } + + err = repo.applyPatch(patch, patchFile, opts) + assert.NoError(t, err) + + assert.True(t, h.fileExists("newfile.txt")) + content := h.readFile("newfile.txt") + assert.Equal(t, "hello\n", content) +} + +func TestApplyPatch_DeleteFile(t *testing.T) { + h := helper(t) + defer h.cleanup() + + repo := h.initRepo() + + // add a file + h.commitFile("deleteme.txt", "content to delete\n", "Add file to delete") + + // delete the file + patch := `diff --git a/deleteme.txt b/deleteme.txt +deleted file mode 100644 +index 1234567..0000000 +--- a/deleteme.txt ++++ /dev/null +@@ -1 +0,0 @@ +-content to delete +` + + patchFile, err := createTempFileWithPatch(patch) + require.NoError(t, err) + defer os.Remove(patchFile) + + opts := MergeOptions{ + CommitMessage: "Delete file", + CommitterName: "Test Committer", + CommitterEmail: "committer@example.com", + FormatPatch: false, + } + + err = repo.applyPatch(patch, patchFile, opts) + assert.NoError(t, err) + + assert.False(t, h.fileExists("deleteme.txt")) +} + +func TestApplyPatch_WithAuthor(t *testing.T) { + h := helper(t) + defer h.cleanup() + + repo := h.initRepo() + + patch := `diff --git a/README.md b/README.md +index 1234567..abcdefg 100644 +--- a/README.md ++++ b/README.md +@@ -1,3 +1,4 @@ + # Test Repository + + Initial content. ++New line. +` + + patchFile, err := createTempFileWithPatch(patch) + require.NoError(t, err) + defer os.Remove(patchFile) + + opts := MergeOptions{ + CommitMessage: "Patch with author", + AuthorName: "Patch Author", + AuthorEmail: "author@example.com", + CommitterName: "Test Committer", + CommitterEmail: "committer@example.com", + FormatPatch: false, + } + + err = repo.applyPatch(patch, patchFile, opts) + assert.NoError(t, err) + + head, err := repo.r.Head() + require.NoError(t, err) + + commit, err := repo.r.CommitObject(head.Hash()) + require.NoError(t, err) + + assert.Equal(t, "Patch Author", commit.Author.Name) + assert.Equal(t, "author@example.com", commit.Author.Email) +} + +func TestApplyPatch_MissingFile(t *testing.T) { + h := helper(t) + defer h.cleanup() + + repo := h.initRepo() + + // patch that modifies a non-existent file + patch := `diff --git a/nonexistent.txt b/nonexistent.txt +index 1234567..abcdefg 100644 +--- a/nonexistent.txt ++++ b/nonexistent.txt +@@ -1 +1 @@ +-old content ++new content +` + + patchFile, err := createTempFileWithPatch(patch) + require.NoError(t, err) + defer os.Remove(patchFile) + + opts := MergeOptions{ + CommitMessage: "Should fail", + CommitterName: "Test Committer", + CommitterEmail: "committer@example.com", + FormatPatch: false, + } + + err = repo.applyPatch(patch, patchFile, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "patch application failed") +} + +func TestApplyPatch_Conflict(t *testing.T) { + h := helper(t) + defer h.cleanup() + + repo := h.initRepo() + + // modify the file to create a conflict + h.commitFile("README.md", "# Test Repository\n\nDifferent content.\n", "Modify README") + + // patch that expects different content + patch := `diff --git a/README.md b/README.md +index 1234567..abcdefg 100644 +--- a/README.md ++++ b/README.md +@@ -1,3 +1,3 @@ + # Test Repository + +-Initial content. ++Modified content. +` + + patchFile, err := createTempFileWithPatch(patch) + require.NoError(t, err) + defer os.Remove(patchFile) + + opts := MergeOptions{ + CommitMessage: "Should conflict", + CommitterName: "Test Committer", + CommitterEmail: "committer@example.com", + FormatPatch: false, + } + + err = repo.applyPatch(patch, patchFile, opts) + assert.Error(t, err) +} + +func TestApplyPatch_MissingDirectory(t *testing.T) { + h := helper(t) + defer h.cleanup() + + repo := h.initRepo() + + // patch that adds a file in a non-existent directory + patch := `diff --git a/subdir/newfile.txt b/subdir/newfile.txt +new file mode 100644 +index 0000000..ce01362 +--- /dev/null ++++ b/subdir/newfile.txt +@@ -0,0 +1 @@ ++content +` + + patchFile, err := createTempFileWithPatch(patch) + require.NoError(t, err) + defer os.Remove(patchFile) + + opts := MergeOptions{ + CommitMessage: "Add file in subdir", + CommitterName: "Test Committer", + CommitterEmail: "committer@example.com", + FormatPatch: false, + } + + // git apply should create the directory automatically + err = repo.applyPatch(patch, patchFile, opts) + assert.NoError(t, err) + + // Verify the file and directory were created + assert.True(t, h.fileExists("subdir/newfile.txt")) +} + +func TestApplyMailbox_Single(t *testing.T) { + h := helper(t) + defer h.cleanup() + + repo := h.initRepo() + + // format-patch mailbox format + patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Patch Author +Date: Mon, 1 Jan 2024 12:00:00 +0000 +Subject: [PATCH] Add new feature + +This is a test patch. +--- + newfile.txt | 1 + + 1 file changed, 1 insertion(+) + create mode 100644 newfile.txt + +diff --git a/newfile.txt b/newfile.txt +new file mode 100644 +index 0000000..ce01362 +--- /dev/null ++++ b/newfile.txt +@@ -0,0 +1 @@ ++hello +-- +2.40.0 +` + + err := repo.applyMailbox(patch) + assert.NoError(t, err) + + assert.True(t, h.fileExists("newfile.txt")) + content := h.readFile("newfile.txt") + assert.Equal(t, "hello\n", content) +} + +func TestApplyMailbox_Multiple(t *testing.T) { + h := helper(t) + defer h.cleanup() + + repo := h.initRepo() + + // multiple patches in mailbox format + patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Patch Author +Date: Mon, 1 Jan 2024 12:00:00 +0000 +Subject: [PATCH 1/2] Add first file + +--- + file1.txt | 1 + + 1 file changed, 1 insertion(+) + create mode 100644 file1.txt + +diff --git a/file1.txt b/file1.txt +new file mode 100644 +index 0000000..ce01362 +--- /dev/null ++++ b/file1.txt +@@ -0,0 +1 @@ ++first +-- +2.40.0 + +From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001 +From: Patch Author +Date: Mon, 1 Jan 2024 12:01:00 +0000 +Subject: [PATCH 2/2] Add second file + +--- + file2.txt | 1 + + 1 file changed, 1 insertion(+) + create mode 100644 file2.txt + +diff --git a/file2.txt b/file2.txt +new file mode 100644 +index 0000000..ce01362 +--- /dev/null ++++ b/file2.txt +@@ -0,0 +1 @@ ++second +-- +2.40.0 +` + + err := repo.applyMailbox(patch) + assert.NoError(t, err) + + assert.True(t, h.fileExists("file1.txt")) + assert.True(t, h.fileExists("file2.txt")) + + content1 := h.readFile("file1.txt") + assert.Equal(t, "first\n", content1) + + content2 := h.readFile("file2.txt") + assert.Equal(t, "second\n", content2) +} + +func TestApplyMailbox_Conflict(t *testing.T) { + h := helper(t) + defer h.cleanup() + + repo := h.initRepo() + + h.commitFile("README.md", "# Test Repository\n\nConflicting content.\n", "Create conflict") + + patch := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Patch Author +Date: Mon, 1 Jan 2024 12:00:00 +0000 +Subject: [PATCH] Modify README + +--- + README.md | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/README.md b/README.md +index 1234567..abcdefg 100644 +--- a/README.md ++++ b/README.md +@@ -1,3 +1,3 @@ + # Test Repository + +-Initial content. ++Different content. +-- +2.40.0 +` + + err := repo.applyMailbox(patch) + assert.Error(t, err) + + var mergeErr *ErrMerge + assert.ErrorAs(t, err, &mergeErr) +} + +func TestParseGitApplyErrors(t *testing.T) { + tests := []struct { + name string + errorOutput string + expectedCount int + expectedReason string + }{ + { + name: "file already exists", + errorOutput: `error: path/to/file.txt: already exists in working directory`, + expectedCount: 1, + expectedReason: "file already exists", + }, + { + name: "file does not exist", + errorOutput: `error: path/to/file.txt: does not exist in working tree`, + expectedCount: 1, + expectedReason: "file does not exist", + }, + { + name: "patch does not apply", + errorOutput: `error: patch failed: file.txt:10 +error: file.txt: patch does not apply`, + expectedCount: 1, + expectedReason: "patch does not apply", + }, + { + name: "multiple conflicts", + errorOutput: `error: patch failed: file1.txt:5 +error: file1.txt:5: some error +error: patch failed: file2.txt:10 +error: file2.txt:10: another error`, + expectedCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conflicts := parseGitApplyErrors(tt.errorOutput) + assert.Len(t, conflicts, tt.expectedCount) + + if tt.expectedReason != "" && len(conflicts) > 0 { + assert.Equal(t, tt.expectedReason, conflicts[0].Reason) + } + }) + } +} + +func TestErrMerge_Error(t *testing.T) { + tests := []struct { + name string + err ErrMerge + expectedMsg string + }{ + { + name: "with conflicts", + err: ErrMerge{ + Message: "test merge failed", + HasConflict: true, + Conflicts: []ConflictInfo{ + {Filename: "file1.txt", Reason: "conflict 1"}, + {Filename: "file2.txt", Reason: "conflict 2"}, + }, + }, + expectedMsg: "merge failed due to conflicts: test merge failed (2 conflicts)", + }, + { + name: "with other error", + err: ErrMerge{ + Message: "command failed", + OtherError: assert.AnError, + }, + expectedMsg: "merge failed: command failed:", + }, + { + name: "message only", + err: ErrMerge{ + Message: "simple failure", + }, + expectedMsg: "merge failed: simple failure", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errMsg := tt.err.Error() + assert.Contains(t, errMsg, tt.expectedMsg) + }) + } +} + +func TestMergeWithOptions_Integration(t *testing.T) { + h := helper(t) + defer h.cleanup() + + // create a repository first with initial content + workRepoPath := filepath.Join(h.tempDir, "work-repo") + workRepo, err := git.PlainInit(workRepoPath, false) + require.NoError(t, err) + + // configure git user + cfg, err := workRepo.Config() + require.NoError(t, err) + cfg.User.Name = "Test User" + cfg.User.Email = "test@example.com" + err = workRepo.SetConfig(cfg) + require.NoError(t, err) + + // Create initial commit + w, err := workRepo.Worktree() + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(workRepoPath, "README.md"), []byte("# Initial\n"), 0644) + require.NoError(t, err) + + _, err = w.Add("README.md") + require.NoError(t, err) + + _, err = w.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + }, + }) + require.NoError(t, err) + + // create a bare repository (like production) + bareRepoPath := filepath.Join(h.tempDir, "bare-repo") + err = InitBare(bareRepoPath, "main") + require.NoError(t, err) + + // add bare repo as remote and push to it + _, err = workRepo.CreateRemote(&config.RemoteConfig{ + Name: "origin", + URLs: []string{"file://" + bareRepoPath}, + }) + require.NoError(t, err) + + err = workRepo.Push(&git.PushOptions{ + RemoteName: "origin", + RefSpecs: []config.RefSpec{"refs/heads/master:refs/heads/main"}, + }) + require.NoError(t, err) + + // now merge a patch into the bare repo + gitRepo, err := PlainOpen(bareRepoPath) + require.NoError(t, err) + + patch := `diff --git a/feature.txt b/feature.txt +new file mode 100644 +index 0000000..5e1c309 +--- /dev/null ++++ b/feature.txt +@@ -0,0 +1 @@ ++Hello World +` + + opts := MergeOptions{ + CommitMessage: "Add feature", + CommitterName: "Test Committer", + CommitterEmail: "committer@example.com", + FormatPatch: false, + } + + err = gitRepo.MergeWithOptions(patch, "main", opts) + assert.NoError(t, err) + + // Clone again and verify the changes were merged + verifyRepoPath := filepath.Join(h.tempDir, "verify-repo") + verifyRepo, err := git.PlainClone(verifyRepoPath, false, &git.CloneOptions{ + URL: "file://" + bareRepoPath, + }) + require.NoError(t, err) + + // check that feature.txt exists + featureFile := filepath.Join(verifyRepoPath, "feature.txt") + assert.FileExists(t, featureFile) + + content, err := os.ReadFile(featureFile) + require.NoError(t, err) + assert.Equal(t, "Hello World\n", string(content)) + + // verify commit message + head, err := verifyRepo.Head() + require.NoError(t, err) + + commit, err := verifyRepo.CommitObject(head.Hash()) + require.NoError(t, err) + assert.Equal(t, "Add feature", strings.TrimSpace(commit.Message)) +}