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