at master 442 lines 11 kB view raw
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) 27 28func init() { 29 cache, _ := ristretto.NewCache(&ristretto.Config{ 30 NumCounters: 1e7, 31 MaxCost: 1 << 30, 32 BufferItems: 64, 33 TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days 34 }) 35 mergeCheckCache = MergeCheckCache{cache} 36} 37 38func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { 39 sep := byte(':') 40 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 41 return fmt.Sprintf("%x", hash) 42} 43 44// we can't cache "mergeable" in risetto, nil is not cacheable 45// 46// we use the sentinel value instead 47func (m *MergeCheckCache) cacheVal(check error) any { 48 if check == nil { 49 return struct{}{} 50 } else { 51 return check 52 } 53} 54 55func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { 56 key := m.cacheKey(g, patch, targetBranch) 57 val := m.cacheVal(mergeCheck) 58 m.cache.Set(key, val, 0) 59} 60 61func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { 62 key := m.cacheKey(g, patch, targetBranch) 63 if val, ok := m.cache.Get(key); ok { 64 if val == struct{}{} { 65 // cache hit for mergeable 66 return nil, true 67 } else if e, ok := val.(error); ok { 68 // cache hit for merge conflict 69 return e, true 70 } 71 } 72 73 // cache miss 74 return nil, false 75} 76 77type ErrMerge struct { 78 Message string 79 Conflicts []ConflictInfo 80 HasConflict bool 81 OtherError error 82} 83 84type ConflictInfo struct { 85 Filename string 86 Reason string 87} 88 89// MergeOptions specifies the configuration for a merge operation 90type MergeOptions struct { 91 CommitMessage string 92 CommitBody string 93 AuthorName string 94 AuthorEmail string 95 CommitterName string 96 CommitterEmail string 97 FormatPatch bool 98} 99 100func (e ErrMerge) Error() string { 101 if e.HasConflict { 102 return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts)) 103 } 104 if e.OtherError != nil { 105 return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError) 106 } 107 return fmt.Sprintf("merge failed: %s", e.Message) 108} 109 110func createTemp(data string) (string, error) { 111 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 112 if err != nil { 113 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 114 } 115 116 if _, err := tmpFile.Write([]byte(data)); err != nil { 117 tmpFile.Close() 118 os.Remove(tmpFile.Name()) 119 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) 120 } 121 122 if err := tmpFile.Close(); err != nil { 123 os.Remove(tmpFile.Name()) 124 return "", fmt.Errorf("failed to close temporary patch file: %w", err) 125 } 126 127 return tmpFile.Name(), nil 128} 129 130func (g *GitRepo) cloneTemp(targetBranch string) (string, error) { 131 tmpDir, err := os.MkdirTemp("", "git-clone-") 132 if err != nil { 133 return "", fmt.Errorf("failed to create temporary directory: %w", err) 134 } 135 136 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{ 137 URL: "file://" + g.path, 138 Depth: 1, 139 SingleBranch: true, 140 ReferenceName: plumbing.NewBranchReferenceName(targetBranch), 141 }) 142 if err != nil { 143 os.RemoveAll(tmpDir) 144 return "", fmt.Errorf("failed to clone repository: %w", err) 145 } 146 147 return tmpDir, nil 148} 149 150func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 151 var stderr bytes.Buffer 152 var cmd *exec.Cmd 153 154 // configure default git user before merge 155 exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 156 exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 157 exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 158 exec.Command("git", "-C", g.path, "config", "advice.amWorkDir", "false").Run() 159 160 // if patch is a format-patch, apply using 'git am' 161 if opts.FormatPatch { 162 return g.applyMailbox(patchData) 163 } 164 165 // else, apply using 'git apply' and commit it manually 166 applyCmd := exec.Command("git", "-C", g.path, "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", g.path, "add", ".") 173 if err := stageCmd.Run(); err != nil { 174 return fmt.Errorf("failed to stage changes: %w", err) 175 } 176 177 commitArgs := []string{"-C", g.path, "commit"} 178 179 // Set author if provided 180 authorName := opts.AuthorName 181 authorEmail := opts.AuthorEmail 182 183 if authorName != "" && authorEmail != "" { 184 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 185 } 186 // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 187 188 commitArgs = append(commitArgs, "-m", opts.CommitMessage) 189 190 if opts.CommitBody != "" { 191 commitArgs = append(commitArgs, "-m", opts.CommitBody) 192 } 193 194 cmd = exec.Command("git", commitArgs...) 195 196 cmd.Stderr = &stderr 197 198 if err := cmd.Run(); err != nil { 199 conflicts := parseGitApplyErrors(stderr.String()) 200 return &ErrMerge{ 201 Message: "patch cannot be applied cleanly", 202 Conflicts: conflicts, 203 HasConflict: len(conflicts) > 0, 204 OtherError: err, 205 } 206 } 207 208 return nil 209} 210 211func (g *GitRepo) applyMailbox(patchData string) error { 212 fps, err := patchutil.ExtractPatches(patchData) 213 if err != nil { 214 return fmt.Errorf("failed to extract patches: %w", err) 215 } 216 217 // apply each patch one by one 218 // update the newly created commit object to add the change-id header 219 total := len(fps) 220 for i, p := range fps { 221 newCommit, err := g.applySingleMailbox(p) 222 if err != nil { 223 return err 224 } 225 226 log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 227 } 228 229 return nil 230} 231 232func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 233 tmpPatch, err := createTemp(singlePatch.Raw) 234 if err != nil { 235 return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 236 } 237 238 var stderr bytes.Buffer 239 cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 240 cmd.Stderr = &stderr 241 242 head, err := g.r.Head() 243 if err != nil { 244 return plumbing.ZeroHash, err 245 } 246 log.Println("head before apply", head.Hash().String()) 247 248 if err := cmd.Run(); err != nil { 249 conflicts := parseGitApplyErrors(stderr.String()) 250 return plumbing.ZeroHash, &ErrMerge{ 251 Message: "patch cannot be applied cleanly", 252 Conflicts: conflicts, 253 HasConflict: len(conflicts) > 0, 254 OtherError: err, 255 } 256 } 257 258 if err := g.Refresh(); err != nil { 259 return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 260 } 261 262 head, err = g.r.Head() 263 if err != nil { 264 return plumbing.ZeroHash, err 265 } 266 log.Println("head after apply", head.Hash().String()) 267 268 newHash := head.Hash() 269 if changeId, err := singlePatch.ChangeId(); err != nil { 270 // no change ID 271 } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 272 return plumbing.ZeroHash, err 273 } else { 274 newHash = updatedHash 275 } 276 277 return newHash, nil 278} 279 280func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 281 log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 282 obj, err := g.r.CommitObject(hash) 283 if err != nil { 284 return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 285 } 286 287 // write the change-id header 288 obj.ExtraHeaders["change-id"] = []byte(changeId) 289 290 // create a new object 291 dest := g.r.Storer.NewEncodedObject() 292 if err := obj.Encode(dest); err != nil { 293 return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 294 } 295 296 // store the new object 297 newHash, err := g.r.Storer.SetEncodedObject(dest) 298 if err != nil { 299 return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 300 } 301 302 log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 303 304 // find the branch that HEAD is pointing to 305 ref, err := g.r.Head() 306 if err != nil { 307 return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 308 } 309 310 // and update that branch to point to new commit 311 if ref.Name().IsBranch() { 312 err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 313 if err != nil { 314 return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 315 } 316 } 317 318 // new hash of commit 319 return newHash, nil 320} 321 322func (g *GitRepo) MergeCheckWithOptions(patchData string, targetBranch string, mo MergeOptions) error { 323 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 324 return val 325 } 326 327 patchFile, err := createTemp(patchData) 328 if err != nil { 329 return &ErrMerge{ 330 Message: err.Error(), 331 OtherError: err, 332 } 333 } 334 defer os.Remove(patchFile) 335 336 tmpDir, err := g.cloneTemp(targetBranch) 337 if err != nil { 338 return &ErrMerge{ 339 Message: err.Error(), 340 OtherError: err, 341 } 342 } 343 defer os.RemoveAll(tmpDir) 344 345 tmpRepo, err := PlainOpen(tmpDir) 346 if err != nil { 347 return err 348 } 349 350 result := tmpRepo.applyPatch(patchData, patchFile, mo) 351 mergeCheckCache.Set(g, patchData, targetBranch, result) 352 return result 353} 354 355func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 356 patchFile, err := createTemp(patchData) 357 if err != nil { 358 return &ErrMerge{ 359 Message: err.Error(), 360 OtherError: err, 361 } 362 } 363 defer os.Remove(patchFile) 364 365 tmpDir, err := g.cloneTemp(targetBranch) 366 if err != nil { 367 return &ErrMerge{ 368 Message: err.Error(), 369 OtherError: err, 370 } 371 } 372 defer os.RemoveAll(tmpDir) 373 374 tmpRepo, err := PlainOpen(tmpDir) 375 if err != nil { 376 return err 377 } 378 379 if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 380 return err 381 } 382 383 pushCmd := exec.Command("git", "-C", tmpDir, "push") 384 if err := pushCmd.Run(); err != nil { 385 return &ErrMerge{ 386 Message: "failed to push changes to bare repository", 387 OtherError: err, 388 } 389 } 390 391 return nil 392} 393 394func parseGitApplyErrors(errorOutput string) []ConflictInfo { 395 var conflicts []ConflictInfo 396 lines := strings.Split(errorOutput, "\n") 397 398 var currentFile string 399 400 for i := range lines { 401 line := strings.TrimSpace(lines[i]) 402 403 if strings.HasPrefix(line, "error: patch failed:") { 404 parts := strings.SplitN(line, ":", 3) 405 if len(parts) >= 3 { 406 currentFile = strings.TrimSpace(parts[2]) 407 } 408 continue 409 } 410 411 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 { 412 if currentFile == "" { 413 currentFile = match[1] 414 } 415 416 conflicts = append(conflicts, ConflictInfo{ 417 Filename: currentFile, 418 Reason: match[3], 419 }) 420 continue 421 } 422 423 if strings.Contains(line, "already exists in working directory") { 424 conflicts = append(conflicts, ConflictInfo{ 425 Filename: currentFile, 426 Reason: "file already exists", 427 }) 428 } else if strings.Contains(line, "does not exist in working tree") { 429 conflicts = append(conflicts, ConflictInfo{ 430 Filename: currentFile, 431 Reason: "file does not exist", 432 }) 433 } else if strings.Contains(line, "patch does not apply") { 434 conflicts = append(conflicts, ConflictInfo{ 435 Filename: currentFile, 436 Reason: "patch does not apply", 437 }) 438 } 439 } 440 441 return conflicts 442}