forked from
tangled.org/core
Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).
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
111func createTemp(data string) (string, error) {
112 tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
113 if err != nil {
114 return "", fmt.Errorf("failed to create temporary patch file: %w", err)
115 }
116
117 if _, err := tmpFile.Write([]byte(data)); err != nil {
118 tmpFile.Close()
119 os.Remove(tmpFile.Name())
120 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
121 }
122
123 if err := tmpFile.Close(); err != nil {
124 os.Remove(tmpFile.Name())
125 return "", fmt.Errorf("failed to close temporary patch file: %w", err)
126 }
127
128 return tmpFile.Name(), nil
129}
130
131func (g *GitRepo) cloneTemp(targetBranch string) (string, error) {
132 tmpDir, err := os.MkdirTemp("", "git-clone-")
133 if err != nil {
134 return "", fmt.Errorf("failed to create temporary directory: %w", err)
135 }
136
137 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{
138 URL: "file://" + g.path,
139 Depth: 1,
140 SingleBranch: true,
141 ReferenceName: plumbing.NewBranchReferenceName(targetBranch),
142 })
143 if err != nil {
144 os.RemoveAll(tmpDir)
145 return "", fmt.Errorf("failed to clone repository: %w", err)
146 }
147
148 return tmpDir, nil
149}
150
151func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error {
152 var stderr bytes.Buffer
153 var cmd *exec.Cmd
154
155 // configure default git user before merge
156 exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run()
157 exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run()
158 exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run()
159 exec.Command("git", "-C", g.path, "config", "advice.amWorkDir", "false").Run()
160
161 // if patch is a format-patch, apply using 'git am'
162 if opts.FormatPatch {
163 return g.applyMailbox(patchData)
164 }
165
166 // else, apply using 'git apply' and commit it manually
167 applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile)
168 applyCmd.Stderr = &stderr
169 if err := applyCmd.Run(); err != nil {
170 return fmt.Errorf("patch application failed: %s", stderr.String())
171 }
172
173 stageCmd := exec.Command("git", "-C", g.path, "add", ".")
174 if err := stageCmd.Run(); err != nil {
175 return fmt.Errorf("failed to stage changes: %w", err)
176 }
177
178 commitArgs := []string{"-C", g.path, "commit"}
179
180 // Set author if provided
181 authorName := opts.AuthorName
182 authorEmail := opts.AuthorEmail
183
184 if authorName != "" && authorEmail != "" {
185 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
186 }
187 // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
188
189 commitArgs = append(commitArgs, "-m", opts.CommitMessage)
190
191 if opts.CommitBody != "" {
192 commitArgs = append(commitArgs, "-m", opts.CommitBody)
193 }
194
195 cmd = exec.Command("git", commitArgs...)
196
197 cmd.Stderr = &stderr
198
199 if err := cmd.Run(); err != nil {
200 conflicts := parseGitApplyErrors(stderr.String())
201 return &ErrMerge{
202 Message: "patch cannot be applied cleanly",
203 Conflicts: conflicts,
204 HasConflict: len(conflicts) > 0,
205 OtherError: err,
206 }
207 }
208
209 return nil
210}
211
212func (g *GitRepo) applyMailbox(patchData string) error {
213 fps, err := patchutil.ExtractPatches(patchData)
214 if err != nil {
215 return fmt.Errorf("failed to extract patches: %w", err)
216 }
217
218 // apply each patch one by one
219 // update the newly created commit object to add the change-id header
220 total := len(fps)
221 for i, p := range fps {
222 newCommit, err := g.applySingleMailbox(p)
223 if err != nil {
224 return err
225 }
226
227 log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String())
228 }
229
230 return nil
231}
232
233func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) {
234 tmpPatch, err := createTemp(singlePatch.Raw)
235 if err != nil {
236 return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err)
237 }
238
239 var stderr bytes.Buffer
240 cmd := exec.Command("git", "-C", g.path, "am", tmpPatch)
241 cmd.Stderr = &stderr
242
243 head, err := g.r.Head()
244 if err != nil {
245 return plumbing.ZeroHash, err
246 }
247 log.Println("head before apply", head.Hash().String())
248
249 if err := cmd.Run(); err != nil {
250 conflicts := parseGitApplyErrors(stderr.String())
251 return plumbing.ZeroHash, &ErrMerge{
252 Message: "patch cannot be applied cleanly",
253 Conflicts: conflicts,
254 HasConflict: len(conflicts) > 0,
255 OtherError: err,
256 }
257 }
258
259 if err := g.Refresh(); err != nil {
260 return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err)
261 }
262
263 head, err = g.r.Head()
264 if err != nil {
265 return plumbing.ZeroHash, err
266 }
267 log.Println("head after apply", head.Hash().String())
268
269 newHash := head.Hash()
270 if changeId, err := singlePatch.ChangeId(); err != nil {
271 // no change ID
272 } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil {
273 return plumbing.ZeroHash, err
274 } else {
275 newHash = updatedHash
276 }
277
278 return newHash, nil
279}
280
281func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) {
282 log.Printf("updating change ID of %s to %s\n", hash.String(), changeId)
283 obj, err := g.r.CommitObject(hash)
284 if err != nil {
285 return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err)
286 }
287
288 // write the change-id header
289 obj.ExtraHeaders["change-id"] = []byte(changeId)
290
291 // create a new object
292 dest := g.r.Storer.NewEncodedObject()
293 if err := obj.Encode(dest); err != nil {
294 return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err)
295 }
296
297 // store the new object
298 newHash, err := g.r.Storer.SetEncodedObject(dest)
299 if err != nil {
300 return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err)
301 }
302
303 log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String())
304
305 // find the branch that HEAD is pointing to
306 ref, err := g.r.Head()
307 if err != nil {
308 return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err)
309 }
310
311 // and update that branch to point to new commit
312 if ref.Name().IsBranch() {
313 err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash))
314 if err != nil {
315 return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err)
316 }
317 }
318
319 // new hash of commit
320 return newHash, nil
321}
322
323func (g *GitRepo) MergeCheckWithOptions(patchData string, targetBranch string, mo MergeOptions) error {
324 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
325 return val
326 }
327
328 patchFile, err := createTemp(patchData)
329 if err != nil {
330 return &ErrMerge{
331 Message: err.Error(),
332 OtherError: err,
333 }
334 }
335 defer os.Remove(patchFile)
336
337 tmpDir, err := g.cloneTemp(targetBranch)
338 if err != nil {
339 return &ErrMerge{
340 Message: err.Error(),
341 OtherError: err,
342 }
343 }
344 defer os.RemoveAll(tmpDir)
345
346 tmpRepo, err := PlainOpen(tmpDir)
347 if err != nil {
348 return err
349 }
350
351 result := tmpRepo.applyPatch(patchData, patchFile, mo)
352 mergeCheckCache.Set(g, patchData, targetBranch, result)
353 return result
354}
355
356func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error {
357 patchFile, err := createTemp(patchData)
358 if err != nil {
359 return &ErrMerge{
360 Message: err.Error(),
361 OtherError: err,
362 }
363 }
364 defer os.Remove(patchFile)
365
366 tmpDir, err := g.cloneTemp(targetBranch)
367 if err != nil {
368 return &ErrMerge{
369 Message: err.Error(),
370 OtherError: err,
371 }
372 }
373 defer os.RemoveAll(tmpDir)
374
375 tmpRepo, err := PlainOpen(tmpDir)
376 if err != nil {
377 return err
378 }
379
380 if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil {
381 return err
382 }
383
384 pushCmd := exec.Command("git", "-C", tmpDir, "push")
385 if err := pushCmd.Run(); err != nil {
386 return &ErrMerge{
387 Message: "failed to push changes to bare repository",
388 OtherError: err,
389 }
390 }
391
392 return nil
393}
394
395func parseGitApplyErrors(errorOutput string) []ConflictInfo {
396 var conflicts []ConflictInfo
397 lines := strings.Split(errorOutput, "\n")
398
399 var currentFile string
400
401 for i := range lines {
402 line := strings.TrimSpace(lines[i])
403
404 if strings.HasPrefix(line, "error: patch failed:") {
405 parts := strings.SplitN(line, ":", 3)
406 if len(parts) >= 3 {
407 currentFile = strings.TrimSpace(parts[2])
408 }
409 continue
410 }
411
412 if match := conflictErrorRegex.FindStringSubmatch(line); len(match) >= 4 {
413 if currentFile == "" {
414 currentFile = match[1]
415 }
416
417 conflicts = append(conflicts, ConflictInfo{
418 Filename: currentFile,
419 Reason: match[3],
420 })
421 continue
422 }
423
424 if strings.Contains(line, "already exists in working directory") {
425 conflicts = append(conflicts, ConflictInfo{
426 Filename: currentFile,
427 Reason: "file already exists",
428 })
429 } else if strings.Contains(line, "does not exist in working tree") {
430 conflicts = append(conflicts, ConflictInfo{
431 Filename: currentFile,
432 Reason: "file does not exist",
433 })
434 } else if strings.Contains(line, "patch does not apply") {
435 conflicts = append(conflicts, ConflictInfo{
436 Filename: currentFile,
437 Reason: "patch does not apply",
438 })
439 }
440 }
441
442 return conflicts
443}