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 exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
168
169 // if patch is a format-patch, apply using 'git am'
170 if opts.FormatPatch {
171 cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
172 } else {
173 // else, apply using 'git apply' and commit it manually
174 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
175 applyCmd.Stderr = &stderr
176 if err := applyCmd.Run(); err != nil {
177 return fmt.Errorf("patch application failed: %s", stderr.String())
178 }
179
180 stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
181 if err := stageCmd.Run(); err != nil {
182 return fmt.Errorf("failed to stage changes: %w", err)
183 }
184
185 commitArgs := []string{"-C", tmpDir, "commit"}
186
187 // Set author if provided
188 authorName := opts.AuthorName
189 authorEmail := opts.AuthorEmail
190
191 if authorEmail == "" {
192 authorEmail = "noreply@tangled.sh"
193 }
194
195 if authorName == "" {
196 authorName = "Tangled"
197 }
198
199 if authorName != "" {
200 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
201 }
202
203 commitArgs = append(commitArgs, "-m", opts.CommitMessage)
204
205 if opts.CommitBody != "" {
206 commitArgs = append(commitArgs, "-m", opts.CommitBody)
207 }
208
209 cmd = exec.Command("git", commitArgs...)
210 }
211
212 cmd.Stderr = &stderr
213
214 if err := cmd.Run(); err != nil {
215 return fmt.Errorf("patch application failed: %s", stderr.String())
216 }
217
218 return nil
219}
220
221func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
222 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
223 return val
224 }
225
226 patchFile, err := g.createTempFileWithPatch(patchData)
227 if err != nil {
228 return &ErrMerge{
229 Message: err.Error(),
230 OtherError: err,
231 }
232 }
233 defer os.Remove(patchFile)
234
235 tmpDir, err := g.cloneRepository(targetBranch)
236 if err != nil {
237 return &ErrMerge{
238 Message: err.Error(),
239 OtherError: err,
240 }
241 }
242 defer os.RemoveAll(tmpDir)
243
244 result := g.checkPatch(tmpDir, patchFile)
245 mergeCheckCache.Set(g, patchData, targetBranch, result)
246 return result
247}
248
249func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error {
250 patchFile, err := g.createTempFileWithPatch(patchData)
251 if err != nil {
252 return &ErrMerge{
253 Message: err.Error(),
254 OtherError: err,
255 }
256 }
257 defer os.Remove(patchFile)
258
259 tmpDir, err := g.cloneRepository(targetBranch)
260 if err != nil {
261 return &ErrMerge{
262 Message: err.Error(),
263 OtherError: err,
264 }
265 }
266 defer os.RemoveAll(tmpDir)
267
268 if err := g.applyPatch(tmpDir, patchFile, opts); err != nil {
269 return err
270 }
271
272 pushCmd := exec.Command("git", "-C", tmpDir, "push")
273 if err := pushCmd.Run(); err != nil {
274 return &ErrMerge{
275 Message: "failed to push changes to bare repository",
276 OtherError: err,
277 }
278 }
279
280 return nil
281}
282
283func parseGitApplyErrors(errorOutput string) []ConflictInfo {
284 var conflicts []ConflictInfo
285 lines := strings.Split(errorOutput, "\n")
286
287 var currentFile string
288
289 for i := range lines {
290 line := strings.TrimSpace(lines[i])
291
292 if strings.HasPrefix(line, "error: patch failed:") {
293 parts := strings.SplitN(line, ":", 3)
294 if len(parts) >= 3 {
295 currentFile = strings.TrimSpace(parts[2])
296 }
297 continue
298 }
299
300 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 {
301 if currentFile == "" {
302 currentFile = match[1]
303 }
304
305 conflicts = append(conflicts, ConflictInfo{
306 Filename: currentFile,
307 Reason: match[3],
308 })
309 continue
310 }
311
312 if strings.Contains(line, "already exists in working directory") {
313 conflicts = append(conflicts, ConflictInfo{
314 Filename: currentFile,
315 Reason: "file already exists",
316 })
317 } else if strings.Contains(line, "does not exist in working tree") {
318 conflicts = append(conflicts, ConflictInfo{
319 Filename: currentFile,
320 Reason: "file does not exist",
321 })
322 } else if strings.Contains(line, "patch does not apply") {
323 conflicts = append(conflicts, ConflictInfo{
324 Filename: currentFile,
325 Reason: "patch does not apply",
326 })
327 }
328 }
329
330 return conflicts
331}