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 var cfgStderr bytes.Buffer
177 cfgCmd, _ := wrapCmd(exec.Command("git", cfgArgs...))
178 cfgCmd.Stderr = &cfgStderr
179 if err := cfgCmd.Run(); err != nil {
180 log.Printf("git config %v failed (non-fatal): err=%v stderr=%q", cfgArgs, err, cfgStderr.String())
181 }
182 }
183
184 // if patch is a format-patch, apply using 'git am'
185 if opts.FormatPatch {
186 return g.applyMailbox(patchData)
187 }
188
189 // else, apply using 'git apply' and commit it manually
190 applyCmd, err := wrapCmd(exec.Command("git", "-C", g.path, "apply", patchFile))
191 if err != nil {
192 return fmt.Errorf("sandbox wrap for git apply: %w", err)
193 }
194 applyCmd.Stderr = &stderr
195 if err := applyCmd.Run(); err != nil {
196 return fmt.Errorf("patch application failed: %s", stderr.String())
197 }
198
199 stderr.Reset()
200 stageCmd, err := wrapCmd(exec.Command("git", "-C", g.path, "add", "."))
201 if err != nil {
202 return fmt.Errorf("sandbox wrap for git add: %w", err)
203 }
204 if err := stageCmd.Run(); err != nil {
205 return fmt.Errorf("failed to stage changes: %w", err)
206 }
207
208 commitArgs := []string{"-C", g.path, "commit", "--allow-empty"}
209
210 // Set author if provided
211 authorName := opts.AuthorName
212 authorEmail := opts.AuthorEmail
213
214 if authorName != "" && authorEmail != "" {
215 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
216 }
217 // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
218
219 commitArgs = append(commitArgs, "-m", opts.CommitMessage)
220
221 if opts.CommitBody != "" {
222 commitArgs = append(commitArgs, "-m", opts.CommitBody)
223 }
224
225 cmd, err := wrapCmd(exec.Command("git", commitArgs...))
226 if err != nil {
227 return fmt.Errorf("sandbox wrap for git commit: %w", err)
228 }
229 stderr.Reset()
230 cmd.Stderr = &stderr
231
232 if err := cmd.Run(); err != nil {
233 conflicts := parseGitApplyErrors(stderr.String())
234 log.Printf("git commit failed: err=%v stderr=%q", err, stderr.String())
235 return &ErrMerge{
236 Message: "patch cannot be applied cleanly",
237 Conflicts: conflicts,
238 HasConflict: len(conflicts) > 0,
239 OtherError: err,
240 }
241 }
242
243 return nil
244}
245
246func (g *GitRepo) applyMailbox(patchData string) error {
247 fps, err := patchutil.ExtractPatches(patchData)
248 if err != nil {
249 return fmt.Errorf("failed to extract patches: %w", err)
250 }
251
252 // apply each patch one by one
253 // update the newly created commit object to add the change-id header
254 total := len(fps)
255 for i, p := range fps {
256 newCommit, err := g.applySingleMailbox(p)
257 if err != nil {
258 return err
259 }
260
261 log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String())
262 }
263
264 return nil
265}
266
267func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) {
268 // when sandboxed, create the patch file inside g.path so it is
269 // within the bound directory and visible to the git subprocess.
270 patchDir := ""
271 if g.sandbox != nil {
272 patchDir = g.path
273 }
274 tmpPatch, err := createTempIn(patchDir, singlePatch.Raw)
275 if err != nil {
276 return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singular mailbox patch: %w", err)
277 }
278
279 var stderr bytes.Buffer
280 rawCmd := exec.Command("git", "-C", g.path, "am", tmpPatch)
281 var cmd *exec.Cmd
282 if g.sandbox != nil {
283 cmd, err = g.sandbox.Wrap(g.path, rawCmd)
284 if err != nil {
285 return plumbing.ZeroHash, fmt.Errorf("sandbox wrap for git am: %w", err)
286 }
287 } else {
288 rawCmd.Dir = g.path
289 cmd = rawCmd
290 }
291 cmd.Stderr = &stderr
292
293 head, err := g.r.Head()
294 if err != nil {
295 return plumbing.ZeroHash, err
296 }
297 log.Println("head before apply", head.Hash().String())
298
299 if err := cmd.Run(); err != nil {
300 conflicts := parseGitApplyErrors(stderr.String())
301 log.Printf("git am failed: err=%v stderr=%q", err, stderr.String())
302 return plumbing.ZeroHash, &ErrMerge{
303 Message: "patch cannot be applied cleanly",
304 Conflicts: conflicts,
305 HasConflict: len(conflicts) > 0,
306 OtherError: err,
307 }
308 }
309
310 refreshed, err := PlainOpen(g.path)
311 if err != nil {
312 return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err)
313 }
314 *g = *refreshed
315
316 head, err = g.r.Head()
317 if err != nil {
318 return plumbing.ZeroHash, err
319 }
320 log.Println("head after apply", head.Hash().String())
321
322 newHash := head.Hash()
323 if changeId, err := singlePatch.ChangeId(); err != nil {
324 // no change ID
325 } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil {
326 return plumbing.ZeroHash, err
327 } else {
328 newHash = updatedHash
329 }
330
331 return newHash, nil
332}
333
334func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) {
335 log.Printf("updating change ID of %s to %s\n", hash.String(), changeId)
336 obj, err := g.r.CommitObject(hash)
337 if err != nil {
338 return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err)
339 }
340
341 // write the change-id header
342 obj.ExtraHeaders["change-id"] = []byte(changeId)
343
344 // create a new object
345 dest := g.r.Storer.NewEncodedObject()
346 if err := obj.Encode(dest); err != nil {
347 return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err)
348 }
349
350 // store the new object
351 newHash, err := g.r.Storer.SetEncodedObject(dest)
352 if err != nil {
353 return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err)
354 }
355
356 log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String())
357
358 // find the branch that HEAD is pointing to
359 ref, err := g.r.Head()
360 if err != nil {
361 return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err)
362 }
363
364 // and update that branch to point to new commit
365 if ref.Name().IsBranch() {
366 err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash))
367 if err != nil {
368 return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err)
369 }
370 }
371
372 // new hash of commit
373 return newHash, nil
374}
375
376func (g *GitRepo) MergeCheckWithOptions(patchData string, targetBranch string, mo MergeOptions) error {
377 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
378 return val
379 }
380
381 tmpDir, err := g.cloneTemp(targetBranch)
382 if err != nil {
383 return &ErrMerge{
384 Message: err.Error(),
385 OtherError: err,
386 }
387 }
388 defer os.RemoveAll(tmpDir)
389
390 // when sandboxed, create the patch file inside tmpDir so it is
391 // visible to the git subprocess.
392 patchDir := ""
393 if g.sandbox != nil {
394 patchDir = tmpDir
395 }
396 patchFile, err := createTempIn(patchDir, patchData)
397 if err != nil {
398 return &ErrMerge{
399 Message: err.Error(),
400 OtherError: err,
401 }
402 }
403 defer os.Remove(patchFile)
404
405 tmpRepo, err := PlainOpen(tmpDir)
406 if err != nil {
407 return err
408 }
409 if g.sandbox != nil {
410 tmpRepo = tmpRepo.WithSandbox(g.sandbox)
411 }
412
413 result := tmpRepo.applyPatch(patchData, patchFile, mo)
414 mergeCheckCache.Set(g, patchData, targetBranch, result)
415 return result
416}
417
418func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error {
419 tmpDir, err := g.cloneTemp(targetBranch)
420 if err != nil {
421 return &ErrMerge{
422 Message: err.Error(),
423 OtherError: err,
424 }
425 }
426 defer os.RemoveAll(tmpDir)
427
428 // when sandboxed, create the patch file inside tmpDir so it is
429 // visible to the git subprocess.
430 patchDir := ""
431 if g.sandbox != nil {
432 patchDir = tmpDir
433 }
434 patchFile, err := createTempIn(patchDir, patchData)
435 if err != nil {
436 return &ErrMerge{
437 Message: err.Error(),
438 OtherError: err,
439 }
440 }
441 defer os.Remove(patchFile)
442
443 tmpRepo, err := PlainOpen(tmpDir)
444 if err != nil {
445 return err
446 }
447 if g.sandbox != nil {
448 tmpRepo = tmpRepo.WithSandbox(g.sandbox)
449 }
450
451 if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil {
452 return err
453 }
454
455 pushCmd := exec.Command("git", "-C", tmpDir, "push")
456 if g.sandbox != nil {
457 // the push needs access to both tmpDir (source) and g.path (target bare repo).
458 pushCmd, err = g.sandbox.WrapMulti([]string{tmpDir, g.path}, pushCmd)
459 if err != nil {
460 return &ErrMerge{
461 Message: "sandbox wrap for git push failed",
462 OtherError: err,
463 }
464 }
465 } else {
466 pushCmd.Dir = tmpDir
467 }
468 if err := pushCmd.Run(); err != nil {
469 return &ErrMerge{
470 Message: "failed to push changes to bare repository",
471 OtherError: err,
472 }
473 }
474
475 return nil
476}
477
478func parseGitApplyErrors(errorOutput string) []ConflictInfo {
479 var conflicts []ConflictInfo
480 lines := strings.Split(errorOutput, "\n")
481
482 var currentFile string
483
484 for i := range lines {
485 line := strings.TrimSpace(lines[i])
486
487 if strings.HasPrefix(line, "error: patch failed:") {
488 parts := strings.SplitN(line, ":", 3)
489 if len(parts) >= 3 {
490 currentFile = strings.TrimSpace(parts[2])
491 }
492 continue
493 }
494
495 if match := conflictErrorRegex.FindStringSubmatch(line); len(match) >= 4 {
496 if currentFile == "" {
497 currentFile = match[1]
498 }
499
500 conflicts = append(conflicts, ConflictInfo{
501 Filename: currentFile,
502 Reason: match[3],
503 })
504 continue
505 }
506
507 if strings.Contains(line, "already exists in working directory") {
508 conflicts = append(conflicts, ConflictInfo{
509 Filename: currentFile,
510 Reason: "file already exists",
511 })
512 } else if strings.Contains(line, "does not exist in working tree") {
513 conflicts = append(conflicts, ConflictInfo{
514 Filename: currentFile,
515 Reason: "file does not exist",
516 })
517 } else if strings.Contains(line, "patch does not apply") {
518 conflicts = append(conflicts, ConflictInfo{
519 Filename: currentFile,
520 Reason: "patch does not apply",
521 })
522 }
523 }
524
525 return conflicts
526}