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