Monorepo for Tangled
Something went wrong. Try again.
16 kB · 706 lines
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707package 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 commitfunc (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 repositoryfunc (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 filefunc (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 repositoryfunc (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 repositoryfunc (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.mdindex 1234567..abcdefg 100644--- a/README.md+++ b/README.md@@ -1,3 +1,3 @@ # Test Repository
-Initial content.+Modified content.`
patchFile, err := createTemp(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.txtnew file mode 100644index 0000000..ce01362--- /dev/null+++ b/newfile.txt@@ -0,0 +1 @@+hello`
patchFile, err := createTemp(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.txtdeleted file mode 100644index 1234567..0000000--- a/deleteme.txt+++ /dev/null@@ -1 +0,0 @@-content to delete`
patchFile, err := createTemp(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.mdindex 1234567..abcdefg 100644--- a/README.md+++ b/README.md@@ -1,3 +1,4 @@ # Test Repository
Initial content.+New line.`
patchFile, err := createTemp(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.txtindex 1234567..abcdefg 100644--- a/nonexistent.txt+++ b/nonexistent.txt@@ -1 +1 @@-old content+new content`
patchFile, err := createTemp(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.mdindex 1234567..abcdefg 100644--- a/README.md+++ b/README.md@@ -1,3 +1,3 @@ # Test Repository
-Initial content.+Modified content.`
patchFile, err := createTemp(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.txtnew file mode 100644index 0000000..ce01362--- /dev/null+++ b/subdir/newfile.txt@@ -0,0 +1 @@+content`
patchFile, err := createTemp(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 2001From: Patch Author <author@example.com>Date: Mon, 1 Jan 2024 12:00:00 +0000Subject: [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.txtnew file mode 100644index 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 2001From: Patch Author <author@example.com>Date: Mon, 1 Jan 2024 12:00:00 +0000Subject: [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.txtnew file mode 100644index 0000000..ce01362--- /dev/null+++ b/file1.txt@@ -0,0 +1 @@+first--2.40.0
From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001From: Patch Author <author@example.com>Date: Mon, 1 Jan 2024 12:01:00 +0000Subject: [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.txtnew file mode 100644index 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 2001From: Patch Author <author@example.com>Date: Mon, 1 Jan 2024 12:00:00 +0000Subject: [PATCH] Modify README
--- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.mdindex 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:10error: file.txt: patch does not apply`, expectedCount: 1, expectedReason: "patch does not apply", }, { name: "multiple conflicts", errorOutput: `error: patch failed: file1.txt:5error: file1.txt:5: some errorerror: patch failed: file2.txt:10error: 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.txtnew file mode 100644index 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))}