1package git
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "os/exec"
8 "regexp"
9 "strings"
10
11 "github.com/go-git/go-git/v5"
12 "github.com/go-git/go-git/v5/plumbing"
13)
14
15type MergeError struct {
16 Message string
17 Conflicts []ConflictInfo
18 HasConflict bool
19 OtherError error
20}
21
22type ConflictInfo struct {
23 Filename string
24 Reason string
25}
26
27func (e MergeError) Error() string {
28 if e.HasConflict {
29 return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts))
30 }
31 if e.OtherError != nil {
32 return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError)
33 }
34 return fmt.Sprintf("merge failed: %s", e.Message)
35}
36
37func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) {
38 tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
39 if err != nil {
40 return "", fmt.Errorf("failed to create temporary patch file: %w", err)
41 }
42
43 if _, err := tmpFile.Write(patchData); err != nil {
44 tmpFile.Close()
45 os.Remove(tmpFile.Name())
46 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
47 }
48
49 if err := tmpFile.Close(); err != nil {
50 os.Remove(tmpFile.Name())
51 return "", fmt.Errorf("failed to close temporary patch file: %w", err)
52 }
53
54 return tmpFile.Name(), nil
55}
56
57func (g *GitRepo) cloneRepository(targetBranch string) (string, error) {
58 tmpDir, err := os.MkdirTemp("", "git-clone-")
59 if err != nil {
60 return "", fmt.Errorf("failed to create temporary directory: %w", err)
61 }
62
63 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{
64 URL: "file://" + g.path,
65 Depth: 1,
66 SingleBranch: true,
67 ReferenceName: plumbing.NewBranchReferenceName(targetBranch),
68 })
69 if err != nil {
70 os.RemoveAll(tmpDir)
71 return "", fmt.Errorf("failed to clone repository: %w", err)
72 }
73
74 return tmpDir, nil
75}
76
77func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool) error {
78 var stderr bytes.Buffer
79 var cmd *exec.Cmd
80
81 if checkOnly {
82 cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
83 } else {
84 exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
85 cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
86 }
87
88 cmd.Stderr = &stderr
89
90 if err := cmd.Run(); err != nil {
91 if checkOnly {
92 conflicts := parseGitApplyErrors(stderr.String())
93 return &MergeError{
94 Message: "patch cannot be applied cleanly",
95 Conflicts: conflicts,
96 HasConflict: len(conflicts) > 0,
97 OtherError: err,
98 }
99 }
100 return fmt.Errorf("patch application failed: %s", stderr.String())
101 }
102
103 return nil
104}
105
106func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
107 patchFile, err := g.createTempFileWithPatch(patchData)
108 if err != nil {
109 return &MergeError{
110 Message: err.Error(),
111 OtherError: err,
112 }
113 }
114 defer os.Remove(patchFile)
115
116 tmpDir, err := g.cloneRepository(targetBranch)
117 if err != nil {
118 return &MergeError{
119 Message: err.Error(),
120 OtherError: err,
121 }
122 }
123 defer os.RemoveAll(tmpDir)
124
125 return g.applyPatch(tmpDir, patchFile, true)
126}
127
128func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
129 patchFile, err := g.createTempFileWithPatch(patchData)
130 if err != nil {
131 return &MergeError{
132 Message: err.Error(),
133 OtherError: err,
134 }
135 }
136 defer os.Remove(patchFile)
137
138 tmpDir, err := g.cloneRepository(targetBranch)
139 if err != nil {
140 return &MergeError{
141 Message: err.Error(),
142 OtherError: err,
143 }
144 }
145 defer os.RemoveAll(tmpDir)
146
147 if err := g.applyPatch(tmpDir, patchFile, false); err != nil {
148 return err
149 }
150
151 pushCmd := exec.Command("git", "-C", tmpDir, "push")
152 if err := pushCmd.Run(); err != nil {
153 return &MergeError{
154 Message: "failed to push changes to bare repository",
155 OtherError: err,
156 }
157 }
158
159 return nil
160}
161
162func parseGitApplyErrors(errorOutput string) []ConflictInfo {
163 var conflicts []ConflictInfo
164 lines := strings.Split(errorOutput, "\n")
165
166 var currentFile string
167
168 for i := range lines {
169 line := strings.TrimSpace(lines[i])
170
171 if strings.HasPrefix(line, "error: patch failed:") {
172 parts := strings.SplitN(line, ":", 3)
173 if len(parts) >= 3 {
174 currentFile = strings.TrimSpace(parts[2])
175 }
176 continue
177 }
178
179 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 {
180 if currentFile == "" {
181 currentFile = match[1]
182 }
183
184 conflicts = append(conflicts, ConflictInfo{
185 Filename: currentFile,
186 Reason: match[3],
187 })
188 continue
189 }
190
191 if strings.Contains(line, "already exists in working directory") {
192 conflicts = append(conflicts, ConflictInfo{
193 Filename: currentFile,
194 Reason: "file already exists",
195 })
196 } else if strings.Contains(line, "does not exist in working tree") {
197 conflicts = append(conflicts, ConflictInfo{
198 Filename: currentFile,
199 Reason: "file does not exist",
200 })
201 } else if strings.Contains(line, "patch does not apply") {
202 conflicts = append(conflicts, ConflictInfo{
203 Filename: currentFile,
204 Reason: "patch does not apply",
205 })
206 }
207 }
208
209 return conflicts
210}