package git import ( "bytes" "fmt" "os" "os/exec" "regexp" "strings" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" ) type MergeError struct { Message string Conflicts []ConflictInfo HasConflict bool OtherError error } type ConflictInfo struct { Filename string Reason string } func (e MergeError) Error() string { if e.HasConflict { return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts)) } if e.OtherError != nil { return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError) } return fmt.Sprintf("merge failed: %s", e.Message) } func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { tmpFile, err := os.CreateTemp("", "git-patch-*.patch") if err != nil { return "", fmt.Errorf("failed to create temporary patch file: %w", err) } if _, err := tmpFile.Write(patchData); err != nil { tmpFile.Close() os.Remove(tmpFile.Name()) return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) } if err := tmpFile.Close(); err != nil { os.Remove(tmpFile.Name()) return "", fmt.Errorf("failed to close temporary patch file: %w", err) } return tmpFile.Name(), nil } func (g *GitRepo) cloneRepository(targetBranch string) (string, error) { tmpDir, err := os.MkdirTemp("", "git-clone-") if err != nil { return "", fmt.Errorf("failed to create temporary directory: %w", err) } _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{ URL: "file://" + g.path, Depth: 1, SingleBranch: true, ReferenceName: plumbing.NewBranchReferenceName(targetBranch), }) if err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("failed to clone repository: %w", err) } return tmpDir, nil } func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool) error { var stderr bytes.Buffer var cmd *exec.Cmd if checkOnly { cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) } else { exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) } cmd.Stderr = &stderr if err := cmd.Run(); err != nil { if checkOnly { conflicts := parseGitApplyErrors(stderr.String()) return &MergeError{ Message: "patch cannot be applied cleanly", Conflicts: conflicts, HasConflict: len(conflicts) > 0, OtherError: err, } } return fmt.Errorf("patch application failed: %s", stderr.String()) } return nil } func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { patchFile, err := g.createTempFileWithPatch(patchData) if err != nil { return &MergeError{ Message: err.Error(), OtherError: err, } } defer os.Remove(patchFile) tmpDir, err := g.cloneRepository(targetBranch) if err != nil { return &MergeError{ Message: err.Error(), OtherError: err, } } defer os.RemoveAll(tmpDir) return g.applyPatch(tmpDir, patchFile, true) } func (g *GitRepo) Merge(patchData []byte, targetBranch string) error { patchFile, err := g.createTempFileWithPatch(patchData) if err != nil { return &MergeError{ Message: err.Error(), OtherError: err, } } defer os.Remove(patchFile) tmpDir, err := g.cloneRepository(targetBranch) if err != nil { return &MergeError{ Message: err.Error(), OtherError: err, } } defer os.RemoveAll(tmpDir) if err := g.applyPatch(tmpDir, patchFile, false); err != nil { return err } pushCmd := exec.Command("git", "-C", tmpDir, "push") if err := pushCmd.Run(); err != nil { return &MergeError{ Message: "failed to push changes to bare repository", OtherError: err, } } return nil } func parseGitApplyErrors(errorOutput string) []ConflictInfo { var conflicts []ConflictInfo lines := strings.Split(errorOutput, "\n") var currentFile string for i := range lines { line := strings.TrimSpace(lines[i]) if strings.HasPrefix(line, "error: patch failed:") { parts := strings.SplitN(line, ":", 3) if len(parts) >= 3 { currentFile = strings.TrimSpace(parts[2]) } continue } if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 { if currentFile == "" { currentFile = match[1] } conflicts = append(conflicts, ConflictInfo{ Filename: currentFile, Reason: match[3], }) continue } if strings.Contains(line, "already exists in working directory") { conflicts = append(conflicts, ConflictInfo{ Filename: currentFile, Reason: "file already exists", }) } else if strings.Contains(line, "does not exist in working tree") { conflicts = append(conflicts, ConflictInfo{ Filename: currentFile, Reason: "file does not exist", }) } else if strings.Contains(line, "patch does not apply") { conflicts = append(conflicts, ConflictInfo{ Filename: currentFile, Reason: "patch does not apply", }) } } return conflicts }