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