forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}