forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
this repo has no description
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 "os"
8 "os/exec"
9 "regexp"
10 "strings"
11
12 "github.com/dgraph-io/ristretto"
13 "github.com/go-git/go-git/v5"
14 "github.com/go-git/go-git/v5/plumbing"
15 "tangled.sh/tangled.sh/core/patchutil"
16)
17
18type MergeCheckCache struct {
19 cache *ristretto.Cache
20}
21
22var (
23 mergeCheckCache MergeCheckCache
24)
25
26func init() {
27 cache, _ := ristretto.NewCache(&ristretto.Config{
28 NumCounters: 1e7,
29 MaxCost: 1 << 30,
30 BufferItems: 64,
31 TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days
32 })
33 mergeCheckCache = MergeCheckCache{cache}
34}
35
36func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string {
37 sep := byte(':')
38 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch))
39 return fmt.Sprintf("%x", hash)
40}
41
42// we can't cache "mergeable" in risetto, nil is not cacheable
43//
44// we use the sentinel value instead
45func (m *MergeCheckCache) cacheVal(check error) any {
46 if check == nil {
47 return struct{}{}
48 } else {
49 return check
50 }
51}
52
53func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) {
54 key := m.cacheKey(g, patch, targetBranch)
55 val := m.cacheVal(mergeCheck)
56 m.cache.Set(key, val, 0)
57}
58
59func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) {
60 key := m.cacheKey(g, patch, targetBranch)
61 if val, ok := m.cache.Get(key); ok {
62 if val == struct{}{} {
63 // cache hit for mergeable
64 return nil, true
65 } else if e, ok := val.(error); ok {
66 // cache hit for merge conflict
67 return e, true
68 }
69 }
70
71 // cache miss
72 return nil, false
73}
74
75type ErrMerge struct {
76 Message string
77 Conflicts []ConflictInfo
78 HasConflict bool
79 OtherError error
80}
81
82type ConflictInfo struct {
83 Filename string
84 Reason string
85}
86
87// MergeOptions specifies the configuration for a merge operation
88type MergeOptions struct {
89 CommitMessage string
90 CommitBody string
91 AuthorName string
92 AuthorEmail string
93 FormatPatch bool
94}
95
96func (e ErrMerge) Error() string {
97 if e.HasConflict {
98 return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts))
99 }
100 if e.OtherError != nil {
101 return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError)
102 }
103 return fmt.Sprintf("merge failed: %s", e.Message)
104}
105
106func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) {
107 tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
108 if err != nil {
109 return "", fmt.Errorf("failed to create temporary patch file: %w", err)
110 }
111
112 if _, err := tmpFile.Write(patchData); err != nil {
113 tmpFile.Close()
114 os.Remove(tmpFile.Name())
115 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
116 }
117
118 if err := tmpFile.Close(); err != nil {
119 os.Remove(tmpFile.Name())
120 return "", fmt.Errorf("failed to close temporary patch file: %w", err)
121 }
122
123 return tmpFile.Name(), nil
124}
125
126func (g *GitRepo) cloneRepository(targetBranch string) (string, error) {
127 tmpDir, err := os.MkdirTemp("", "git-clone-")
128 if err != nil {
129 return "", fmt.Errorf("failed to create temporary directory: %w", err)
130 }
131
132 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{
133 URL: "file://" + g.path,
134 Depth: 1,
135 SingleBranch: true,
136 ReferenceName: plumbing.NewBranchReferenceName(targetBranch),
137 })
138 if err != nil {
139 os.RemoveAll(tmpDir)
140 return "", fmt.Errorf("failed to clone repository: %w", err)
141 }
142
143 return tmpDir, nil
144}
145
146func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error {
147 var stderr bytes.Buffer
148 var cmd *exec.Cmd
149
150 if checkOnly {
151 cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
152 } else {
153 // if patch is a format-patch, apply using 'git am'
154 if opts.FormatPatch {
155 amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile)
156 amCmd.Stderr = &stderr
157 if err := amCmd.Run(); err != nil {
158 return fmt.Errorf("patch application failed: %s", stderr.String())
159 }
160 return nil
161 }
162
163 // else, apply using 'git apply' and commit it manually
164 exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
165 if opts != nil {
166 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
167 applyCmd.Stderr = &stderr
168 if err := applyCmd.Run(); err != nil {
169 return fmt.Errorf("patch application failed: %s", stderr.String())
170 }
171
172 stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
173 if err := stageCmd.Run(); err != nil {
174 return fmt.Errorf("failed to stage changes: %w", err)
175 }
176
177 commitArgs := []string{"-C", tmpDir, "commit"}
178
179 // Set author if provided
180 authorName := opts.AuthorName
181 authorEmail := opts.AuthorEmail
182
183 if authorEmail == "" {
184 authorEmail = "noreply@tangled.sh"
185 }
186
187 if authorName == "" {
188 authorName = "Tangled"
189 }
190
191 if authorName != "" {
192 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
193 }
194
195 commitArgs = append(commitArgs, "-m", opts.CommitMessage)
196
197 if opts.CommitBody != "" {
198 commitArgs = append(commitArgs, "-m", opts.CommitBody)
199 }
200
201 cmd = exec.Command("git", commitArgs...)
202 } else {
203 // If no commit message specified, use git-am which automatically creates a commit
204 cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
205 }
206 }
207
208 cmd.Stderr = &stderr
209
210 if err := cmd.Run(); err != nil {
211 if checkOnly {
212 conflicts := parseGitApplyErrors(stderr.String())
213 return &ErrMerge{
214 Message: "patch cannot be applied cleanly",
215 Conflicts: conflicts,
216 HasConflict: len(conflicts) > 0,
217 OtherError: err,
218 }
219 }
220 return fmt.Errorf("patch application failed: %s", stderr.String())
221 }
222
223 return nil
224}
225
226func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
227 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok {
228 return val
229 }
230
231 var opts MergeOptions
232 opts.FormatPatch = patchutil.IsFormatPatch(string(patchData))
233
234 patchFile, err := g.createTempFileWithPatch(patchData)
235 if err != nil {
236 return &ErrMerge{
237 Message: err.Error(),
238 OtherError: err,
239 }
240 }
241 defer os.Remove(patchFile)
242
243 tmpDir, err := g.cloneRepository(targetBranch)
244 if err != nil {
245 return &ErrMerge{
246 Message: err.Error(),
247 OtherError: err,
248 }
249 }
250 defer os.RemoveAll(tmpDir)
251
252 result := g.applyPatch(tmpDir, patchFile, true, &opts)
253 mergeCheckCache.Set(g, patchData, targetBranch, result)
254 return result
255}
256
257func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
258 return g.MergeWithOptions(patchData, targetBranch, nil)
259}
260
261func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error {
262 patchFile, err := g.createTempFileWithPatch(patchData)
263 if err != nil {
264 return &ErrMerge{
265 Message: err.Error(),
266 OtherError: err,
267 }
268 }
269 defer os.Remove(patchFile)
270
271 tmpDir, err := g.cloneRepository(targetBranch)
272 if err != nil {
273 return &ErrMerge{
274 Message: err.Error(),
275 OtherError: err,
276 }
277 }
278 defer os.RemoveAll(tmpDir)
279
280 if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil {
281 return err
282 }
283
284 pushCmd := exec.Command("git", "-C", tmpDir, "push")
285 if err := pushCmd.Run(); err != nil {
286 return &ErrMerge{
287 Message: "failed to push changes to bare repository",
288 OtherError: err,
289 }
290 }
291
292 return nil
293}
294
295func parseGitApplyErrors(errorOutput string) []ConflictInfo {
296 var conflicts []ConflictInfo
297 lines := strings.Split(errorOutput, "\n")
298
299 var currentFile string
300
301 for i := range lines {
302 line := strings.TrimSpace(lines[i])
303
304 if strings.HasPrefix(line, "error: patch failed:") {
305 parts := strings.SplitN(line, ":", 3)
306 if len(parts) >= 3 {
307 currentFile = strings.TrimSpace(parts[2])
308 }
309 continue
310 }
311
312 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 {
313 if currentFile == "" {
314 currentFile = match[1]
315 }
316
317 conflicts = append(conflicts, ConflictInfo{
318 Filename: currentFile,
319 Reason: match[3],
320 })
321 continue
322 }
323
324 if strings.Contains(line, "already exists in working directory") {
325 conflicts = append(conflicts, ConflictInfo{
326 Filename: currentFile,
327 Reason: "file already exists",
328 })
329 } else if strings.Contains(line, "does not exist in working tree") {
330 conflicts = append(conflicts, ConflictInfo{
331 Filename: currentFile,
332 Reason: "file does not exist",
333 })
334 } else if strings.Contains(line, "patch does not apply") {
335 conflicts = append(conflicts, ConflictInfo{
336 Filename: currentFile,
337 Reason: "patch does not apply",
338 })
339 }
340 }
341
342 return conflicts
343}