1package git
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "fmt"
7 "os"
8 "os/exec"
9 "regexp"
10 "strings"
11
12 "github.com/dgraph-io/ristretto"
13 "github.com/go-git/go-git/v5"
14 "github.com/go-git/go-git/v5/plumbing"
15)
16
17type MergeCheckCache struct {
18 cache *ristretto.Cache
19}
20
21var (
22 mergeCheckCache MergeCheckCache
23)
24
25func init() {
26 cache, _ := ristretto.NewCache(&ristretto.Config{
27 NumCounters: 1e7,
28 MaxCost: 1 << 30,
29 BufferItems: 64,
30 TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days
31 })
32 mergeCheckCache = MergeCheckCache{cache}
33}
34
35func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string {
36 sep := byte(':')
37 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch))
38 return fmt.Sprintf("%x", hash)
39}
40
41// we can't cache "mergeable" in risetto, nil is not cacheable
42//
43// we use the sentinel value instead
44func (m *MergeCheckCache) cacheVal(check error) any {
45 if check == nil {
46 return struct{}{}
47 } else {
48 return check
49 }
50}
51
52func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) {
53 key := m.cacheKey(g, patch, targetBranch)
54 val := m.cacheVal(mergeCheck)
55 m.cache.Set(key, val, 0)
56}
57
58func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) {
59 key := m.cacheKey(g, patch, targetBranch)
60 if val, ok := m.cache.Get(key); ok {
61 if val == struct{}{} {
62 // cache hit for mergeable
63 return nil, true
64 } else if e, ok := val.(error); ok {
65 // cache hit for merge conflict
66 return e, true
67 }
68 }
69
70 // cache miss
71 return nil, false
72}
73
74type ErrMerge struct {
75 Message string
76 Conflicts []ConflictInfo
77 HasConflict bool
78 OtherError error
79}
80
81type ConflictInfo struct {
82 Filename string
83 Reason string
84}
85
86// MergeOptions specifies the configuration for a merge operation
87type MergeOptions struct {
88 CommitMessage string
89 CommitBody string
90 AuthorName string
91 AuthorEmail string
92 CommitterName string
93 CommitterEmail string
94 FormatPatch bool
95}
96
97func (e ErrMerge) Error() string {
98 if e.HasConflict {
99 return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts))
100 }
101 if e.OtherError != nil {
102 return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError)
103 }
104 return fmt.Sprintf("merge failed: %s", e.Message)
105}
106
107func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) {
108 tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
109 if err != nil {
110 return "", fmt.Errorf("failed to create temporary patch file: %w", err)
111 }
112
113 if _, err := tmpFile.Write(patchData); err != nil {
114 tmpFile.Close()
115 os.Remove(tmpFile.Name())
116 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
117 }
118
119 if err := tmpFile.Close(); err != nil {
120 os.Remove(tmpFile.Name())
121 return "", fmt.Errorf("failed to close temporary patch file: %w", err)
122 }
123
124 return tmpFile.Name(), nil
125}
126
127func (g *GitRepo) cloneRepository(targetBranch string) (string, error) {
128 tmpDir, err := os.MkdirTemp("", "git-clone-")
129 if err != nil {
130 return "", fmt.Errorf("failed to create temporary directory: %w", err)
131 }
132
133 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{
134 URL: "file://" + g.path,
135 Depth: 1,
136 SingleBranch: true,
137 ReferenceName: plumbing.NewBranchReferenceName(targetBranch),
138 })
139 if err != nil {
140 os.RemoveAll(tmpDir)
141 return "", fmt.Errorf("failed to clone repository: %w", err)
142 }
143
144 return tmpDir, nil
145}
146
147func (g *GitRepo) checkPatch(tmpDir, patchFile string) error {
148 var stderr bytes.Buffer
149
150 cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
151 cmd.Stderr = &stderr
152
153 if err := cmd.Run(); err != nil {
154 conflicts := parseGitApplyErrors(stderr.String())
155 return &ErrMerge{
156 Message: "patch cannot be applied cleanly",
157 Conflicts: conflicts,
158 HasConflict: len(conflicts) > 0,
159 OtherError: err,
160 }
161 }
162 return nil
163}
164
165func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error {
166 var stderr bytes.Buffer
167 var cmd *exec.Cmd
168
169 // configure default git user before merge
170 exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run()
171 exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run()
172 exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
173
174 // if patch is a format-patch, apply using 'git am'
175 if opts.FormatPatch {
176 cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
177 } else {
178 // else, apply using 'git apply' and commit it manually
179 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
180 applyCmd.Stderr = &stderr
181 if err := applyCmd.Run(); err != nil {
182 return fmt.Errorf("patch application failed: %s", stderr.String())
183 }
184
185 stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
186 if err := stageCmd.Run(); err != nil {
187 return fmt.Errorf("failed to stage changes: %w", err)
188 }
189
190 commitArgs := []string{"-C", tmpDir, "commit"}
191
192 // Set author if provided
193 authorName := opts.AuthorName
194 authorEmail := opts.AuthorEmail
195
196 if authorName != "" && authorEmail != "" {
197 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
198 }
199 // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
200
201 commitArgs = append(commitArgs, "-m", opts.CommitMessage)
202
203 if opts.CommitBody != "" {
204 commitArgs = append(commitArgs, "-m", opts.CommitBody)
205 }
206
207 cmd = exec.Command("git", commitArgs...)
208 }
209
210 cmd.Stderr = &stderr
211
212 if err := cmd.Run(); err != nil {
213 return fmt.Errorf("patch application failed: %s", stderr.String())
214 }
215
216 return nil
217}
218
219func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
221 return val
222 }
223
224 patchFile, err := g.createTempFileWithPatch(patchData)
225 if err != nil {
226 return &ErrMerge{
227 Message: err.Error(),
228 OtherError: err,
229 }
230 }
231 defer os.Remove(patchFile)
232
233 tmpDir, err := g.cloneRepository(targetBranch)
234 if err != nil {
235 return &ErrMerge{
236 Message: err.Error(),
237 OtherError: err,
238 }
239 }
240 defer os.RemoveAll(tmpDir)
241
242 result := g.checkPatch(tmpDir, patchFile)
243 mergeCheckCache.Set(g, patchData, targetBranch, result)
244 return result
245}
246
247func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error {
248 patchFile, err := g.createTempFileWithPatch(patchData)
249 if err != nil {
250 return &ErrMerge{
251 Message: err.Error(),
252 OtherError: err,
253 }
254 }
255 defer os.Remove(patchFile)
256
257 tmpDir, err := g.cloneRepository(targetBranch)
258 if err != nil {
259 return &ErrMerge{
260 Message: err.Error(),
261 OtherError: err,
262 }
263 }
264 defer os.RemoveAll(tmpDir)
265
266 if err := g.applyPatch(tmpDir, patchFile, opts); err != nil {
267 return err
268 }
269
270 pushCmd := exec.Command("git", "-C", tmpDir, "push")
271 if err := pushCmd.Run(); err != nil {
272 return &ErrMerge{
273 Message: "failed to push changes to bare repository",
274 OtherError: err,
275 }
276 }
277
278 return nil
279}
280
281func parseGitApplyErrors(errorOutput string) []ConflictInfo {
282 var conflicts []ConflictInfo
283 lines := strings.Split(errorOutput, "\n")
284
285 var currentFile string
286
287 for i := range lines {
288 line := strings.TrimSpace(lines[i])
289
290 if strings.HasPrefix(line, "error: patch failed:") {
291 parts := strings.SplitN(line, ":", 3)
292 if len(parts) >= 3 {
293 currentFile = strings.TrimSpace(parts[2])
294 }
295 continue
296 }
297
298 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 {
299 if currentFile == "" {
300 currentFile = match[1]
301 }
302
303 conflicts = append(conflicts, ConflictInfo{
304 Filename: currentFile,
305 Reason: match[3],
306 })
307 continue
308 }
309
310 if strings.Contains(line, "already exists in working directory") {
311 conflicts = append(conflicts, ConflictInfo{
312 Filename: currentFile,
313 Reason: "file already exists",
314 })
315 } else if strings.Contains(line, "does not exist in working tree") {
316 conflicts = append(conflicts, ConflictInfo{
317 Filename: currentFile,
318 Reason: "file does not exist",
319 })
320 } else if strings.Contains(line, "patch does not apply") {
321 conflicts = append(conflicts, ConflictInfo{
322 Filename: currentFile,
323 Reason: "patch does not apply",
324 })
325 }
326 }
327
328 return conflicts
329}