forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
at master 12 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 (g *GitRepo) createTempFileWithPatch(patchData 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(patchData)); 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) cloneRepository(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) checkPatch(tmpDir, patchFile string) error { 151 var stderr bytes.Buffer 152 153 cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 154 cmd.Stderr = &stderr 155 156 if err := cmd.Run(); err != nil { 157 conflicts := parseGitApplyErrors(stderr.String()) 158 return &ErrMerge{ 159 Message: "patch cannot be applied cleanly", 160 Conflicts: conflicts, 161 HasConflict: len(conflicts) > 0, 162 OtherError: err, 163 } 164 } 165 return nil 166} 167 168func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 169 var stderr bytes.Buffer 170 var cmd *exec.Cmd 171 172 // configure default git user before merge 173 exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 174 exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 175 exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 176 177 // if patch is a format-patch, apply using 'git am' 178 if opts.FormatPatch { 179 return g.applyMailbox(patchData) 180 } 181 182 // else, apply using 'git apply' and commit it manually 183 applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) 184 applyCmd.Stderr = &stderr 185 if err := applyCmd.Run(); err != nil { 186 return fmt.Errorf("patch application failed: %s", stderr.String()) 187 } 188 189 stageCmd := exec.Command("git", "-C", g.path, "add", ".") 190 if err := stageCmd.Run(); err != nil { 191 return fmt.Errorf("failed to stage changes: %w", err) 192 } 193 194 commitArgs := []string{"-C", g.path, "commit"} 195 196 // Set author if provided 197 authorName := opts.AuthorName 198 authorEmail := opts.AuthorEmail 199 200 if authorName != "" && authorEmail != "" { 201 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 202 } 203 // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 204 205 commitArgs = append(commitArgs, "-m", opts.CommitMessage) 206 207 if opts.CommitBody != "" { 208 commitArgs = append(commitArgs, "-m", opts.CommitBody) 209 } 210 211 cmd = exec.Command("git", commitArgs...) 212 213 cmd.Stderr = &stderr 214 215 if err := cmd.Run(); err != nil { 216 return fmt.Errorf("patch application failed: %s", stderr.String()) 217 } 218 219 return nil 220} 221 222func (g *GitRepo) applyMailbox(patchData string) error { 223 fps, err := patchutil.ExtractPatches(patchData) 224 if err != nil { 225 return fmt.Errorf("failed to extract patches: %w", err) 226 } 227 228 // apply each patch one by one 229 // update the newly created commit object to add the change-id header 230 total := len(fps) 231 for i, p := range fps { 232 newCommit, err := g.applySingleMailbox(p) 233 if err != nil { 234 return err 235 } 236 237 log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 238 } 239 240 return nil 241} 242 243func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 244 tmpPatch, err := g.createTempFileWithPatch(singlePatch.Raw) 245 if err != nil { 246 return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 247 } 248 249 var stderr bytes.Buffer 250 cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 251 cmd.Stderr = &stderr 252 253 head, err := g.r.Head() 254 if err != nil { 255 return plumbing.ZeroHash, err 256 } 257 log.Println("head before apply", head.Hash().String()) 258 259 if err := cmd.Run(); err != nil { 260 return plumbing.ZeroHash, fmt.Errorf("patch application failed: %s", stderr.String()) 261 } 262 263 if err := g.Refresh(); err != nil { 264 return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 265 } 266 267 head, err = g.r.Head() 268 if err != nil { 269 return plumbing.ZeroHash, err 270 } 271 log.Println("head after apply", head.Hash().String()) 272 273 newHash := head.Hash() 274 if changeId, err := singlePatch.ChangeId(); err != nil { 275 // no change ID 276 } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 277 return plumbing.ZeroHash, err 278 } else { 279 newHash = updatedHash 280 } 281 282 return newHash, nil 283} 284 285func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 286 log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 287 obj, err := g.r.CommitObject(hash) 288 if err != nil { 289 return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 290 } 291 292 // write the change-id header 293 obj.ExtraHeaders["change-id"] = []byte(changeId) 294 295 // create a new object 296 dest := g.r.Storer.NewEncodedObject() 297 if err := obj.Encode(dest); err != nil { 298 return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 299 } 300 301 // store the new object 302 newHash, err := g.r.Storer.SetEncodedObject(dest) 303 if err != nil { 304 return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 305 } 306 307 log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 308 309 // find the branch that HEAD is pointing to 310 ref, err := g.r.Head() 311 if err != nil { 312 return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 313 } 314 315 // and update that branch to point to new commit 316 if ref.Name().IsBranch() { 317 err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 318 if err != nil { 319 return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 320 } 321 } 322 323 // new hash of commit 324 return newHash, nil 325} 326 327func (g *GitRepo) MergeCheck(patchData string, targetBranch string) error { 328 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 329 return val 330 } 331 332 patchFile, err := g.createTempFileWithPatch(patchData) 333 if err != nil { 334 return &ErrMerge{ 335 Message: err.Error(), 336 OtherError: err, 337 } 338 } 339 defer os.Remove(patchFile) 340 341 tmpDir, err := g.cloneRepository(targetBranch) 342 if err != nil { 343 return &ErrMerge{ 344 Message: err.Error(), 345 OtherError: err, 346 } 347 } 348 defer os.RemoveAll(tmpDir) 349 350 result := g.checkPatch(tmpDir, patchFile) 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 := g.createTempFileWithPatch(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.cloneRepository(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}