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 exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 168 169 // if patch is a format-patch, apply using 'git am' 170 if opts.FormatPatch { 171 cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 172 } else { 173 // else, apply using 'git apply' and commit it manually 174 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 175 applyCmd.Stderr = &stderr 176 if err := applyCmd.Run(); err != nil { 177 return fmt.Errorf("patch application failed: %s", stderr.String()) 178 } 179 180 stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 181 if err := stageCmd.Run(); err != nil { 182 return fmt.Errorf("failed to stage changes: %w", err) 183 } 184 185 commitArgs := []string{"-C", tmpDir, "commit"} 186 187 // Set author if provided 188 authorName := opts.AuthorName 189 authorEmail := opts.AuthorEmail 190 191 if authorEmail == "" { 192 authorEmail = "noreply@tangled.sh" 193 } 194 195 if authorName == "" { 196 authorName = "Tangled" 197 } 198 199 if authorName != "" { 200 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 201 } 202 203 commitArgs = append(commitArgs, "-m", opts.CommitMessage) 204 205 if opts.CommitBody != "" { 206 commitArgs = append(commitArgs, "-m", opts.CommitBody) 207 } 208 209 cmd = exec.Command("git", commitArgs...) 210 } 211 212 cmd.Stderr = &stderr 213 214 if err := cmd.Run(); err != nil { 215 return fmt.Errorf("patch application failed: %s", stderr.String()) 216 } 217 218 return nil 219} 220 221func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 222 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 223 return val 224 } 225 226 patchFile, err := g.createTempFileWithPatch(patchData) 227 if err != nil { 228 return &ErrMerge{ 229 Message: err.Error(), 230 OtherError: err, 231 } 232 } 233 defer os.Remove(patchFile) 234 235 tmpDir, err := g.cloneRepository(targetBranch) 236 if err != nil { 237 return &ErrMerge{ 238 Message: err.Error(), 239 OtherError: err, 240 } 241 } 242 defer os.RemoveAll(tmpDir) 243 244 result := g.checkPatch(tmpDir, patchFile) 245 mergeCheckCache.Set(g, patchData, targetBranch, result) 246 return result 247} 248 249func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 250 patchFile, err := g.createTempFileWithPatch(patchData) 251 if err != nil { 252 return &ErrMerge{ 253 Message: err.Error(), 254 OtherError: err, 255 } 256 } 257 defer os.Remove(patchFile) 258 259 tmpDir, err := g.cloneRepository(targetBranch) 260 if err != nil { 261 return &ErrMerge{ 262 Message: err.Error(), 263 OtherError: err, 264 } 265 } 266 defer os.RemoveAll(tmpDir) 267 268 if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 269 return err 270 } 271 272 pushCmd := exec.Command("git", "-C", tmpDir, "push") 273 if err := pushCmd.Run(); err != nil { 274 return &ErrMerge{ 275 Message: "failed to push changes to bare repository", 276 OtherError: err, 277 } 278 } 279 280 return nil 281} 282 283func parseGitApplyErrors(errorOutput string) []ConflictInfo { 284 var conflicts []ConflictInfo 285 lines := strings.Split(errorOutput, "\n") 286 287 var currentFile string 288 289 for i := range lines { 290 line := strings.TrimSpace(lines[i]) 291 292 if strings.HasPrefix(line, "error: patch failed:") { 293 parts := strings.SplitN(line, ":", 3) 294 if len(parts) >= 3 { 295 currentFile = strings.TrimSpace(parts[2]) 296 } 297 continue 298 } 299 300 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 { 301 if currentFile == "" { 302 currentFile = match[1] 303 } 304 305 conflicts = append(conflicts, ConflictInfo{ 306 Filename: currentFile, 307 Reason: match[3], 308 }) 309 continue 310 } 311 312 if strings.Contains(line, "already exists in working directory") { 313 conflicts = append(conflicts, ConflictInfo{ 314 Filename: currentFile, 315 Reason: "file already exists", 316 }) 317 } else if strings.Contains(line, "does not exist in working tree") { 318 conflicts = append(conflicts, ConflictInfo{ 319 Filename: currentFile, 320 Reason: "file does not exist", 321 }) 322 } else if strings.Contains(line, "patch does not apply") { 323 conflicts = append(conflicts, ConflictInfo{ 324 Filename: currentFile, 325 Reason: "patch does not apply", 326 }) 327 } 328 } 329 330 return conflicts 331}