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