From aff0bc0d9fb434d0189551ec9bb24f204070ff0b Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Tue, 10 Feb 2026 15:52:16 +0800 Subject: [PATCH] fix: auto-resolve sync conflicts and preserve existing .gitignore metamorph sync (and periodic daemon syncs) would fail with merge conflicts when the upstream repo and project dir had divergent versions of scaffolded files like PROGRESS.md and .gitignore. This was caused by InitUpstream creating scaffold files independently from metamorph init, producing parallel histories that could never cleanly merge. Two fixes: - SyncToProjectDir now uses `git merge -X theirs` so conflicts auto-resolve in favor of upstream (agent work), which is the content being synced in. - metamorph init now appends to .gitignore instead of overwriting it, preserving any existing language-specific entries. Co-Authored-By: Claude Opus 4.6 --- cmd/init.go | 35 +++++++++++++++++++++++++++++----- internal/gitops/gitops.go | 4 ++-- internal/gitops/gitops_test.go | 22 ++++++++++----------- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 55e2d83..c514da7 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/robmorgan/metamorph/internal/constants" "github.com/spf13/cobra" @@ -116,13 +117,37 @@ Add project-specific instructions for your agents here. fmt.Printf(" Created %s/\n", d) } - // Write .gitignore. + // Append entries to .gitignore (create if missing, never overwrite). gitignorePath := filepath.Join(absDir, ".gitignore") - gitignoreContent := ".metamorph/\nagent_logs/\n" - if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), 0644); err != nil { - return fmt.Errorf("failed to write .gitignore: %w", err) + requiredEntries := []string{".metamorph/", "agent_logs/"} + existing, _ := os.ReadFile(gitignorePath) + existingStr := string(existing) + var toAdd []string + for _, entry := range requiredEntries { + if !strings.Contains(existingStr, entry) { + toAdd = append(toAdd, entry) + } + } + if len(toAdd) > 0 { + f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open .gitignore: %w", err) + } + // Add a newline before appending if the file doesn't end with one. + if len(existingStr) > 0 && !strings.HasSuffix(existingStr, "\n") { + _, _ = f.WriteString("\n") + } + for _, entry := range toAdd { + if _, err := f.WriteString(entry + "\n"); err != nil { + _ = f.Close() + return fmt.Errorf("failed to write .gitignore entry: %w", err) + } + } + _ = f.Close() + fmt.Println(" Updated .gitignore") + } else { + fmt.Println(" .gitignore already up to date") } - fmt.Println(" Created .gitignore") fmt.Printf("\nProject %q initialized successfully!\n\n", projectName) fmt.Println("Next steps:") diff --git a/internal/gitops/gitops.go b/internal/gitops/gitops.go index f3747e8..ee8114a 100644 --- a/internal/gitops/gitops.go +++ b/internal/gitops/gitops.go @@ -206,8 +206,8 @@ func SyncToProjectDir(upstreamPath, projectDir string) (string, error) { return "", fmt.Errorf("gitops: fetch failed: %w", err) } - // Merge FETCH_HEAD. - if _, err := git(projectDir, "merge", "FETCH_HEAD", "--no-edit"); err != nil { + // Merge FETCH_HEAD, auto-resolving conflicts in favor of upstream (agent work). + if _, err := git(projectDir, "merge", "-X", "theirs", "FETCH_HEAD", "--no-edit"); err != nil { if _, abortErr := git(projectDir, "merge", "--abort"); abortErr != nil { slog.Warn("gitops: failed to abort merge", "error", abortErr) } diff --git a/internal/gitops/gitops_test.go b/internal/gitops/gitops_test.go index 15d10f9..7e324c4 100644 --- a/internal/gitops/gitops_test.go +++ b/internal/gitops/gitops_test.go @@ -674,7 +674,7 @@ func TestSyncToProjectDir(t *testing.T) { } }) - t.Run("aborts merge on conflict", func(t *testing.T) { + t.Run("auto-resolves conflict in favor of upstream", func(t *testing.T) { projectDir, upstreamPath := setupUpstream(t) // Push a conflicting change via upstream (simulating an agent). @@ -713,19 +713,19 @@ func TestSyncToProjectDir(t *testing.T) { t.Fatal(err) } - // Sync should fail due to merge conflict. + // Sync should auto-resolve the conflict in favor of upstream (theirs). _, err := SyncToProjectDir(upstreamPath, projectDir) - if err == nil { - t.Fatal("expected merge conflict error") - } - if !strings.Contains(err.Error(), "merge failed") { - t.Errorf("expected 'merge failed' in error, got: %v", err) + if err != nil { + t.Fatalf("expected auto-resolved merge, got error: %v", err) } - // Verify merge was aborted — MERGE_HEAD should not exist. - mergeHead := filepath.Join(projectDir, ".git", "MERGE_HEAD") - if _, err := os.Stat(mergeHead); err == nil { - t.Error("MERGE_HEAD exists — merge was not aborted") + // Verify upstream version won. + content, err := os.ReadFile(filepath.Join(projectDir, "conflict.txt")) + if err != nil { + t.Fatal(err) + } + if string(content) != "agent version\n" { + t.Errorf("expected upstream content to win, got: %q", string(content)) } }) }