forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
1package git
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "fmt"
7 "log"
8 "os"
9 "os/exec"
10 "regexp"
11 "strings"
12
13 "github.com/dgraph-io/ristretto"
14 "github.com/go-git/go-git/v5"
15 "github.com/go-git/go-git/v5/plumbing"
16 "tangled.org/core/patchutil"
17 "tangled.org/core/types"
18)
19
20type MergeCheckCache struct {
21 cache *ristretto.Cache
22}
23
24var (
25 mergeCheckCache MergeCheckCache
26)
27
28func init() {
29 cache, _ := ristretto.NewCache(&ristretto.Config{
30 NumCounters: 1e7,
31 MaxCost: 1 << 30,
32 BufferItems: 64,
33 TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days
34 })
35 mergeCheckCache = MergeCheckCache{cache}
36}
37
38func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string {
39 sep := byte(':')
40 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch))
41 return fmt.Sprintf("%x", hash)
42}
43
44// we can't cache "mergeable" in risetto, nil is not cacheable
45//
46// we use the sentinel value instead
47func (m *MergeCheckCache) cacheVal(check error) any {
48 if check == nil {
49 return struct{}{}
50 } else {
51 return check
52 }
53}
54
55func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) {
56 key := m.cacheKey(g, patch, targetBranch)
57 val := m.cacheVal(mergeCheck)
58 m.cache.Set(key, val, 0)
59}
60
61func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) {
62 key := m.cacheKey(g, patch, targetBranch)
63 if val, ok := m.cache.Get(key); ok {
64 if val == struct{}{} {
65 // cache hit for mergeable
66 return nil, true
67 } else if e, ok := val.(error); ok {
68 // cache hit for merge conflict
69 return e, true
70 }
71 }
72
73 // cache miss
74 return nil, false
75}
76
77type ErrMerge struct {
78 Message string
79 Conflicts []ConflictInfo
80 HasConflict bool
81 OtherError error
82}
83
84type ConflictInfo struct {
85 Filename string
86 Reason string
87}
88
89// MergeOptions specifies the configuration for a merge operation
90type MergeOptions struct {
91 CommitMessage string
92 CommitBody string
93 AuthorName string
94 AuthorEmail string
95 CommitterName string
96 CommitterEmail string
97 FormatPatch bool
98}
99
100func (e ErrMerge) Error() string {
101 if e.HasConflict {
102 return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts))
103 }
104 if e.OtherError != nil {
105 return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError)
106 }
107 return fmt.Sprintf("merge failed: %s", e.Message)
108}
109
110func (g *GitRepo) createTempFileWithPatch(patchData string) (string, error) {
111 tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
112 if err != nil {
113 return "", fmt.Errorf("failed to create temporary patch file: %w", err)
114 }
115
116 if _, err := tmpFile.Write([]byte(patchData)); err != nil {
117 tmpFile.Close()
118 os.Remove(tmpFile.Name())
119 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
120 }
121
122 if err := tmpFile.Close(); err != nil {
123 os.Remove(tmpFile.Name())
124 return "", fmt.Errorf("failed to close temporary patch file: %w", err)
125 }
126
127 return tmpFile.Name(), nil
128}
129
130func (g *GitRepo) cloneRepository(targetBranch string) (string, error) {
131 tmpDir, err := os.MkdirTemp("", "git-clone-")
132 if err != nil {
133 return "", fmt.Errorf("failed to create temporary directory: %w", err)
134 }
135
136 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{
137 URL: "file://" + g.path,
138 Depth: 1,
139 SingleBranch: true,
140 ReferenceName: plumbing.NewBranchReferenceName(targetBranch),
141 })
142 if err != nil {
143 os.RemoveAll(tmpDir)
144 return "", fmt.Errorf("failed to clone repository: %w", err)
145 }
146
147 return tmpDir, nil
148}
149
150func (g *GitRepo) checkPatch(tmpDir, patchFile string) error {
151 var stderr bytes.Buffer
152
153 cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
154 cmd.Stderr = &stderr
155
156 if err := cmd.Run(); err != nil {
157 conflicts := parseGitApplyErrors(stderr.String())
158 return &ErrMerge{
159 Message: "patch cannot be applied cleanly",
160 Conflicts: conflicts,
161 HasConflict: len(conflicts) > 0,
162 OtherError: err,
163 }
164 }
165 return nil
166}
167
168func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error {
169 var stderr bytes.Buffer
170 var cmd *exec.Cmd
171
172 // configure default git user before merge
173 exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run()
174 exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run()
175 exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run()
176
177 // if patch is a format-patch, apply using 'git am'
178 if opts.FormatPatch {
179 return g.applyMailbox(patchData)
180 }
181
182 // else, apply using 'git apply' and commit it manually
183 applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile)
184 applyCmd.Stderr = &stderr
185 if err := applyCmd.Run(); err != nil {
186 return fmt.Errorf("patch application failed: %s", stderr.String())
187 }
188
189 stageCmd := exec.Command("git", "-C", g.path, "add", ".")
190 if err := stageCmd.Run(); err != nil {
191 return fmt.Errorf("failed to stage changes: %w", err)
192 }
193
194 commitArgs := []string{"-C", g.path, "commit"}
195
196 // Set author if provided
197 authorName := opts.AuthorName
198 authorEmail := opts.AuthorEmail
199
200 if authorName != "" && authorEmail != "" {
201 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
202 }
203 // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
204
205 commitArgs = append(commitArgs, "-m", opts.CommitMessage)
206
207 if opts.CommitBody != "" {
208 commitArgs = append(commitArgs, "-m", opts.CommitBody)
209 }
210
211 cmd = exec.Command("git", commitArgs...)
212
213 cmd.Stderr = &stderr
214
215 if err := cmd.Run(); err != nil {
216 return fmt.Errorf("patch application failed: %s", stderr.String())
217 }
218
219 return nil
220}
221
222func (g *GitRepo) applyMailbox(patchData string) error {
223 fps, err := patchutil.ExtractPatches(patchData)
224 if err != nil {
225 return fmt.Errorf("failed to extract patches: %w", err)
226 }
227
228 // apply each patch one by one
229 // update the newly created commit object to add the change-id header
230 total := len(fps)
231 for i, p := range fps {
232 newCommit, err := g.applySingleMailbox(p)
233 if err != nil {
234 return err
235 }
236
237 log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String())
238 }
239
240 return nil
241}
242
243func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) {
244 tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw)
245 if err != nil {
246 return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err)
247 }
248
249 var stderr bytes.Buffer
250 cmd := exec.Command("git", "-C", g.path, "am", tmpPatch)
251 cmd.Stderr = &stderr
252
253 head, err := g.r.Head()
254 if err != nil {
255 return plumbing.ZeroHash, err
256 }
257 log.Println("head before apply", head.Hash().String())
258
259 if err := cmd.Run(); err != nil {
260 return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String())
261 }
262
263 if err := g.Refresh(); err != nil {
264 return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err)
265 }
266
267 head, err = g.r.Head()
268 if err != nil {
269 return plumbing.ZeroHash, err
270 }
271 log.Println("head after apply", head.Hash().String())
272
273 newHash := head.Hash()
274 if changeId, err := singlePatch.ChangeId(); err != nil {
275 // no change ID
276 } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil {
277 return plumbing.ZeroHash, err
278 } else {
279 newHash = updatedHash
280 }
281
282 return newHash, nil
283}
284
285func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) {
286 log.Printf("updating change ID of %s to %s\n", hash.String(), changeId)
287 obj, err := g.r.CommitObject(hash)
288 if err != nil {
289 return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err)
290 }
291
292 // write the change-id header
293 obj.ExtraHeaders["change-id"] = []byte(changeId)
294
295 // create a new object
296 dest := g.r.Storer.NewEncodedObject()
297 if err := obj.Encode(dest); err != nil {
298 return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err)
299 }
300
301 // store the new object
302 newHash, err := g.r.Storer.SetEncodedObject(dest)
303 if err != nil {
304 return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err)
305 }
306
307 log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String())
308
309 // find the branch that HEAD is pointing to
310 ref, err := g.r.Head()
311 if err != nil {
312 return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err)
313 }
314
315 // and update that branch to point to new commit
316 if ref.Name().IsBranch() {
317 err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash))
318 if err != nil {
319 return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err)
320 }
321 }
322
323 // new hash of commit
324 return newHash, nil
325}
326
327func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error {
328 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
329 return val
330 }
331
332 patchFile, err := g.createTempFileWithPatch(patchData)
333 if err != nil {
334 return &ErrMerge{
335 Message: err.Error(),
336 OtherError: err,
337 }
338 }
339 defer os.Remove(patchFile)
340
341 tmpDir, err := g.cloneRepository(targetBranch)
342 if err != nil {
343 return &ErrMerge{
344 Message: err.Error(),
345 OtherError: err,
346 }
347 }
348 defer os.RemoveAll(tmpDir)
349
350 result := g.checkPatch(tmpDir, patchFile)
351 mergeCheckCache.Set(g, patchData, targetBranch, result)
352 return result
353}
354
355func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error {
356 patchFile, err := g.createTempFileWithPatch(patchData)
357 if err != nil {
358 return &ErrMerge{
359 Message: err.Error(),
360 OtherError: err,
361 }
362 }
363 defer os.Remove(patchFile)
364
365 tmpDir, err := g.cloneRepository(targetBranch)
366 if err != nil {
367 return &ErrMerge{
368 Message: err.Error(),
369 OtherError: err,
370 }
371 }
372 defer os.RemoveAll(tmpDir)
373
374 tmpRepo, err := PlainOpen(tmpDir)
375 if err != nil {
376 return err
377 }
378
379 if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil {
380 return err
381 }
382
383 pushCmd := exec.Command("git", "-C", tmpDir, "push")
384 if err := pushCmd.Run(); err != nil {
385 return &ErrMerge{
386 Message: "failed to push changes to bare repository",
387 OtherError: err,
388 }
389 }
390
391 return nil
392}
393
394func parseGitApplyErrors(errorOutput string) []ConflictInfo {
395 var conflicts []ConflictInfo
396 lines := strings.Split(errorOutput, "\n")
397
398 var currentFile string
399
400 for i := range lines {
401 line := strings.TrimSpace(lines[i])
402
403 if strings.HasPrefix(line, "error: patch failed:") {
404 parts := strings.SplitN(line, ":", 3)
405 if len(parts) >= 3 {
406 currentFile = strings.TrimSpace(parts[2])
407 }
408 continue
409 }
410
411 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 {
412 if currentFile == "" {
413 currentFile = match[1]
414 }
415
416 conflicts = append(conflicts, ConflictInfo{
417 Filename: currentFile,
418 Reason: match[3],
419 })
420 continue
421 }
422
423 if strings.Contains(line, "already exists in working directory") {
424 conflicts = append(conflicts, ConflictInfo{
425 Filename: currentFile,
426 Reason: "file already exists",
427 })
428 } else if strings.Contains(line, "does not exist in working tree") {
429 conflicts = append(conflicts, ConflictInfo{
430 Filename: currentFile,
431 Reason: "file does not exist",
432 })
433 } else if strings.Contains(line, "patch does not apply") {
434 conflicts = append(conflicts, ConflictInfo{
435 Filename: currentFile,
436 Reason: "patch does not apply",
437 })
438 }
439 }
440
441 return conflicts
442}