this repo has no description
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 FormatPatch bool
93}
94
95func (e ErrMerge) Error() string {
96 if e.HasConflict {
97 return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts))
98 }
99 if e.OtherError != nil {
100 return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError)
101 }
102 return fmt.Sprintf("merge failed: %s", e.Message)
103}
104
105func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) {
106 tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
107 if err != nil {
108 return "", fmt.Errorf("failed to create temporary patch file: %w", err)
109 }
110
111 if _, err := tmpFile.Write(patchData); err != nil {
112 tmpFile.Close()
113 os.Remove(tmpFile.Name())
114 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
115 }
116
117 if err := tmpFile.Close(); err != nil {
118 os.Remove(tmpFile.Name())
119 return "", fmt.Errorf("failed to close temporary patch file: %w", err)
120 }
121
122 return tmpFile.Name(), nil
123}
124
125func (g *GitRepo) cloneRepository(targetBranch string) (string, error) {
126 tmpDir, err := os.MkdirTemp("", "git-clone-")
127 if err != nil {
128 return "", fmt.Errorf("failed to create temporary directory: %w", err)
129 }
130
131 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{
132 URL: "file://" + g.path,
133 Depth: 1,
134 SingleBranch: true,
135 ReferenceName: plumbing.NewBranchReferenceName(targetBranch),
136 })
137 if err != nil {
138 os.RemoveAll(tmpDir)
139 return "", fmt.Errorf("failed to clone repository: %w", err)
140 }
141
142 return tmpDir, nil
143}
144
145func (g *GitRepo) checkPatch(tmpDir, patchFile string) error {
146 var stderr bytes.Buffer
147
148 cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
149 cmd.Stderr = &stderr
150
151 if err := cmd.Run(); err != nil {
152 conflicts := parseGitApplyErrors(stderr.String())
153 return &ErrMerge{
154 Message: "patch cannot be applied cleanly",
155 Conflicts: conflicts,
156 HasConflict: len(conflicts) > 0,
157 OtherError: err,
158 }
159 }
160 return nil
161}
162
163func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts *MergeOptions) error {
164 var stderr bytes.Buffer
165 var cmd *exec.Cmd
166
167 // if patch is a format-patch, apply using 'git am'
168 if opts.FormatPatch {
169 amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile)
170 amCmd.Stderr = &stderr
171 if err := amCmd.Run(); err != nil {
172 return fmt.Errorf("patch application failed: %s", stderr.String())
173 }
174 return nil
175 }
176
177 // else, apply using 'git apply' and commit it manually
178 exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
179 if opts != nil {
180 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
181 applyCmd.Stderr = &stderr
182 if err := applyCmd.Run(); err != nil {
183 return fmt.Errorf("patch application failed: %s", stderr.String())
184 }
185
186 stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
187 if err := stageCmd.Run(); err != nil {
188 return fmt.Errorf("failed to stage changes: %w", err)
189 }
190
191 commitArgs := []string{"-C", tmpDir, "commit"}
192
193 // Set author if provided
194 authorName := opts.AuthorName
195 authorEmail := opts.AuthorEmail
196
197 if authorEmail == "" {
198 authorEmail = "noreply@tangled.sh"
199 }
200
201 if authorName == "" {
202 authorName = "Tangled"
203 }
204
205 if authorName != "" {
206 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
207 }
208
209 commitArgs = append(commitArgs, "-m", opts.CommitMessage)
210
211 if opts.CommitBody != "" {
212 commitArgs = append(commitArgs, "-m", opts.CommitBody)
213 }
214
215 cmd = exec.Command("git", commitArgs...)
216 } else {
217 // If no commit message specified, use git-am which automatically creates a commit
218 cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
219 }
220
221 cmd.Stderr = &stderr
222
223 if err := cmd.Run(); err != nil {
224 return fmt.Errorf("patch application failed: %s", stderr.String())
225 }
226
227 return nil
228}
229
230func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
231 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
232 return val
233 }
234
235 patchFile, err := g.createTempFileWithPatch(patchData)
236 if err != nil {
237 return &ErrMerge{
238 Message: err.Error(),
239 OtherError: err,
240 }
241 }
242 defer os.Remove(patchFile)
243
244 tmpDir, err := g.cloneRepository(targetBranch)
245 if err != nil {
246 return &ErrMerge{
247 Message: err.Error(),
248 OtherError: err,
249 }
250 }
251 defer os.RemoveAll(tmpDir)
252
253 result := g.checkPatch(tmpDir, patchFile)
254 mergeCheckCache.Set(g, patchData, targetBranch, result)
255 return result
256}
257
258func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
259 return g.MergeWithOptions(patchData, targetBranch, nil)
260}
261
262func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error {
263 patchFile, err := g.createTempFileWithPatch(patchData)
264 if err != nil {
265 return &ErrMerge{
266 Message: err.Error(),
267 OtherError: err,
268 }
269 }
270 defer os.Remove(patchFile)
271
272 tmpDir, err := g.cloneRepository(targetBranch)
273 if err != nil {
274 return &ErrMerge{
275 Message: err.Error(),
276 OtherError: err,
277 }
278 }
279 defer os.RemoveAll(tmpDir)
280
281 if err := g.applyPatch(tmpDir, patchFile, opts); err != nil {
282 return err
283 }
284
285 pushCmd := exec.Command("git", "-C", tmpDir, "push")
286 if err := pushCmd.Run(); err != nil {
287 return &ErrMerge{
288 Message: "failed to push changes to bare repository",
289 OtherError: err,
290 }
291 }
292
293 return nil
294}
295
296func parseGitApplyErrors(errorOutput string) []ConflictInfo {
297 var conflicts []ConflictInfo
298 lines := strings.Split(errorOutput, "\n")
299
300 var currentFile string
301
302 for i := range lines {
303 line := strings.TrimSpace(lines[i])
304
305 if strings.HasPrefix(line, "error: patch failed:") {
306 parts := strings.SplitN(line, ":", 3)
307 if len(parts) >= 3 {
308 currentFile = strings.TrimSpace(parts[2])
309 }
310 continue
311 }
312
313 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 {
314 if currentFile == "" {
315 currentFile = match[1]
316 }
317
318 conflicts = append(conflicts, ConflictInfo{
319 Filename: currentFile,
320 Reason: match[3],
321 })
322 continue
323 }
324
325 if strings.Contains(line, "already exists in working directory") {
326 conflicts = append(conflicts, ConflictInfo{
327 Filename: currentFile,
328 Reason: "file already exists",
329 })
330 } else if strings.Contains(line, "does not exist in working tree") {
331 conflicts = append(conflicts, ConflictInfo{
332 Filename: currentFile,
333 Reason: "file does not exist",
334 })
335 } else if strings.Contains(line, "patch does not apply") {
336 conflicts = append(conflicts, ConflictInfo{
337 Filename: currentFile,
338 Reason: "patch does not apply",
339 })
340 }
341 }
342
343 return conflicts
344}