forked from tangled.org/core
this repo has no description
at master 8.4 kB view raw
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 CommitterName string 93 CommitterEmail string 94 FormatPatch bool 95} 96 97func (e ErrMerge) Error() string { 98 if e.HasConflict { 99 return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts)) 100 } 101 if e.OtherError != nil { 102 return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError) 103 } 104 return fmt.Sprintf("merge failed: %s", e.Message) 105} 106 107func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 108 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 109 if err != nil { 110 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 111 } 112 113 if _, err := tmpFile.Write(patchData); err != nil { 114 tmpFile.Close() 115 os.Remove(tmpFile.Name()) 116 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) 117 } 118 119 if err := tmpFile.Close(); err != nil { 120 os.Remove(tmpFile.Name()) 121 return "", fmt.Errorf("failed to close temporary patch file: %w", err) 122 } 123 124 return tmpFile.Name(), nil 125} 126 127func (g *GitRepo) cloneRepository(targetBranch string) (string, error) { 128 tmpDir, err := os.MkdirTemp("", "git-clone-") 129 if err != nil { 130 return "", fmt.Errorf("failed to create temporary directory: %w", err) 131 } 132 133 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{ 134 URL: "file://" + g.path, 135 Depth: 1, 136 SingleBranch: true, 137 ReferenceName: plumbing.NewBranchReferenceName(targetBranch), 138 }) 139 if err != nil { 140 os.RemoveAll(tmpDir) 141 return "", fmt.Errorf("failed to clone repository: %w", err) 142 } 143 144 return tmpDir, nil 145} 146 147func (g *GitRepo) checkPatch(tmpDir, patchFile string) error { 148 var stderr bytes.Buffer 149 150 cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 151 cmd.Stderr = &stderr 152 153 if err := cmd.Run(); err != nil { 154 conflicts := parseGitApplyErrors(stderr.String()) 155 return &ErrMerge{ 156 Message: "patch cannot be applied cleanly", 157 Conflicts: conflicts, 158 HasConflict: len(conflicts) > 0, 159 OtherError: err, 160 } 161 } 162 return nil 163} 164 165func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 166 var stderr bytes.Buffer 167 var cmd *exec.Cmd 168 169 // configure default git user before merge 170 exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 173 174 // if patch is a format-patch, apply using 'git am' 175 if opts.FormatPatch { 176 cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 } else { 178 // else, apply using 'git apply' and commit it manually 179 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 applyCmd.Stderr = &stderr 181 if err := applyCmd.Run(); err != nil { 182 return fmt.Errorf("patch application failed: %s", stderr.String()) 183 } 184 185 stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 if err := stageCmd.Run(); err != nil { 187 return fmt.Errorf("failed to stage changes: %w", err) 188 } 189 190 commitArgs := []string{"-C", tmpDir, "commit"} 191 192 // Set author if provided 193 authorName := opts.AuthorName 194 authorEmail := opts.AuthorEmail 195 196 if authorName != "" && authorEmail != "" { 197 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 } 199 // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 200 201 commitArgs = append(commitArgs, "-m", opts.CommitMessage) 202 203 if opts.CommitBody != "" { 204 commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 } 206 207 cmd = exec.Command("git", commitArgs...) 208 } 209 210 cmd.Stderr = &stderr 211 212 if err := cmd.Run(); err != nil { 213 return fmt.Errorf("patch application failed: %s", stderr.String()) 214 } 215 216 return nil 217} 218 219func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 221 return val 222 } 223 224 patchFile, err := g.createTempFileWithPatch(patchData) 225 if err != nil { 226 return &ErrMerge{ 227 Message: err.Error(), 228 OtherError: err, 229 } 230 } 231 defer os.Remove(patchFile) 232 233 tmpDir, err := g.cloneRepository(targetBranch) 234 if err != nil { 235 return &ErrMerge{ 236 Message: err.Error(), 237 OtherError: err, 238 } 239 } 240 defer os.RemoveAll(tmpDir) 241 242 result := g.checkPatch(tmpDir, patchFile) 243 mergeCheckCache.Set(g, patchData, targetBranch, result) 244 return result 245} 246 247func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 248 patchFile, err := g.createTempFileWithPatch(patchData) 249 if err != nil { 250 return &ErrMerge{ 251 Message: err.Error(), 252 OtherError: err, 253 } 254 } 255 defer os.Remove(patchFile) 256 257 tmpDir, err := g.cloneRepository(targetBranch) 258 if err != nil { 259 return &ErrMerge{ 260 Message: err.Error(), 261 OtherError: err, 262 } 263 } 264 defer os.RemoveAll(tmpDir) 265 266 if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 267 return err 268 } 269 270 pushCmd := exec.Command("git", "-C", tmpDir, "push") 271 if err := pushCmd.Run(); err != nil { 272 return &ErrMerge{ 273 Message: "failed to push changes to bare repository", 274 OtherError: err, 275 } 276 } 277 278 return nil 279} 280 281func parseGitApplyErrors(errorOutput string) []ConflictInfo { 282 var conflicts []ConflictInfo 283 lines := strings.Split(errorOutput, "\n") 284 285 var currentFile string 286 287 for i := range lines { 288 line := strings.TrimSpace(lines[i]) 289 290 if strings.HasPrefix(line, "error: patch failed:") { 291 parts := strings.SplitN(line, ":", 3) 292 if len(parts) >= 3 { 293 currentFile = strings.TrimSpace(parts[2]) 294 } 295 continue 296 } 297 298 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 { 299 if currentFile == "" { 300 currentFile = match[1] 301 } 302 303 conflicts = append(conflicts, ConflictInfo{ 304 Filename: currentFile, 305 Reason: match[3], 306 }) 307 continue 308 } 309 310 if strings.Contains(line, "already exists in working directory") { 311 conflicts = append(conflicts, ConflictInfo{ 312 Filename: currentFile, 313 Reason: "file already exists", 314 }) 315 } else if strings.Contains(line, "does not exist in working tree") { 316 conflicts = append(conflicts, ConflictInfo{ 317 Filename: currentFile, 318 Reason: "file does not exist", 319 }) 320 } else if strings.Contains(line, "patch does not apply") { 321 conflicts = append(conflicts, ConflictInfo{ 322 Filename: currentFile, 323 Reason: "patch does not apply", 324 }) 325 } 326 } 327 328 return conflicts 329}