code.gitea.io/gitea@v1.21.7/services/pull/patch.go (about) 1 // Copyright 2019 The Gitea Authors. 2 // All rights reserved. 3 // SPDX-License-Identifier: MIT 4 5 package pull 6 7 import ( 8 "bufio" 9 "context" 10 "fmt" 11 "io" 12 "os" 13 "path/filepath" 14 "strings" 15 16 "code.gitea.io/gitea/models" 17 git_model "code.gitea.io/gitea/models/git" 18 issues_model "code.gitea.io/gitea/models/issues" 19 "code.gitea.io/gitea/models/unit" 20 "code.gitea.io/gitea/modules/container" 21 "code.gitea.io/gitea/modules/git" 22 "code.gitea.io/gitea/modules/graceful" 23 "code.gitea.io/gitea/modules/log" 24 "code.gitea.io/gitea/modules/process" 25 "code.gitea.io/gitea/modules/setting" 26 "code.gitea.io/gitea/modules/util" 27 28 "github.com/gobwas/glob" 29 ) 30 31 // DownloadDiffOrPatch will write the patch for the pr to the writer 32 func DownloadDiffOrPatch(ctx context.Context, pr *issues_model.PullRequest, w io.Writer, patch, binary bool) error { 33 if err := pr.LoadBaseRepo(ctx); err != nil { 34 log.Error("Unable to load base repository ID %d for pr #%d [%d]", pr.BaseRepoID, pr.Index, pr.ID) 35 return err 36 } 37 38 gitRepo, closer, err := git.RepositoryFromContextOrOpen(ctx, pr.BaseRepo.RepoPath()) 39 if err != nil { 40 return fmt.Errorf("OpenRepository: %w", err) 41 } 42 defer closer.Close() 43 44 if err := gitRepo.GetDiffOrPatch(pr.MergeBase, pr.GetGitRefName(), w, patch, binary); err != nil { 45 log.Error("Unable to get patch file from %s to %s in %s Error: %v", pr.MergeBase, pr.HeadBranch, pr.BaseRepo.FullName(), err) 46 return fmt.Errorf("Unable to get patch file from %s to %s in %s Error: %w", pr.MergeBase, pr.HeadBranch, pr.BaseRepo.FullName(), err) 47 } 48 return nil 49 } 50 51 var patchErrorSuffices = []string{ 52 ": already exists in index", 53 ": patch does not apply", 54 ": already exists in working directory", 55 "unrecognized input", 56 ": No such file or directory", 57 ": does not exist in index", 58 } 59 60 // TestPatch will test whether a simple patch will apply 61 func TestPatch(pr *issues_model.PullRequest) error { 62 ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("TestPatch: %s", pr)) 63 defer finished() 64 65 prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) 66 if err != nil { 67 if !git_model.IsErrBranchNotExist(err) { 68 log.Error("CreateTemporaryRepoForPR %-v: %v", pr, err) 69 } 70 return err 71 } 72 defer cancel() 73 74 return testPatch(ctx, prCtx, pr) 75 } 76 77 func testPatch(ctx context.Context, prCtx *prContext, pr *issues_model.PullRequest) error { 78 gitRepo, err := git.OpenRepository(ctx, prCtx.tmpBasePath) 79 if err != nil { 80 return fmt.Errorf("OpenRepository: %w", err) 81 } 82 defer gitRepo.Close() 83 84 // 1. update merge base 85 pr.MergeBase, _, err = git.NewCommand(ctx, "merge-base", "--", "base", "tracking").RunStdString(&git.RunOpts{Dir: prCtx.tmpBasePath}) 86 if err != nil { 87 var err2 error 88 pr.MergeBase, err2 = gitRepo.GetRefCommitID(git.BranchPrefix + "base") 89 if err2 != nil { 90 return fmt.Errorf("GetMergeBase: %v and can't find commit ID for base: %w", err, err2) 91 } 92 } 93 pr.MergeBase = strings.TrimSpace(pr.MergeBase) 94 if pr.HeadCommitID, err = gitRepo.GetRefCommitID(git.BranchPrefix + "tracking"); err != nil { 95 return fmt.Errorf("GetBranchCommitID: can't find commit ID for head: %w", err) 96 } 97 98 if pr.HeadCommitID == pr.MergeBase { 99 pr.Status = issues_model.PullRequestStatusAncestor 100 return nil 101 } 102 103 // 2. Check for conflicts 104 if conflicts, err := checkConflicts(ctx, pr, gitRepo, prCtx.tmpBasePath); err != nil || conflicts || pr.Status == issues_model.PullRequestStatusEmpty { 105 return err 106 } 107 108 // 3. Check for protected files changes 109 if err = checkPullFilesProtection(ctx, pr, gitRepo); err != nil { 110 return fmt.Errorf("pr.CheckPullFilesProtection(): %v", err) 111 } 112 113 if len(pr.ChangedProtectedFiles) > 0 { 114 log.Trace("Found %d protected files changed", len(pr.ChangedProtectedFiles)) 115 } 116 117 pr.Status = issues_model.PullRequestStatusMergeable 118 119 return nil 120 } 121 122 type errMergeConflict struct { 123 filename string 124 } 125 126 func (e *errMergeConflict) Error() string { 127 return fmt.Sprintf("conflict detected at: %s", e.filename) 128 } 129 130 func attemptMerge(ctx context.Context, file *unmergedFile, tmpBasePath string, gitRepo *git.Repository) error { 131 log.Trace("Attempt to merge:\n%v", file) 132 switch { 133 case file.stage1 != nil && (file.stage2 == nil || file.stage3 == nil): 134 // 1. Deleted in one or both: 135 // 136 // Conflict <==> the stage1 !SameAs to the undeleted one 137 if (file.stage2 != nil && !file.stage1.SameAs(file.stage2)) || (file.stage3 != nil && !file.stage1.SameAs(file.stage3)) { 138 // Conflict! 139 return &errMergeConflict{file.stage1.path} 140 } 141 142 // Not a genuine conflict and we can simply remove the file from the index 143 return gitRepo.RemoveFilesFromIndex(file.stage1.path) 144 case file.stage1 == nil && file.stage2 != nil && (file.stage3 == nil || file.stage2.SameAs(file.stage3)): 145 // 2. Added in ours but not in theirs or identical in both 146 // 147 // Not a genuine conflict just add to the index 148 if err := gitRepo.AddObjectToIndex(file.stage2.mode, git.MustIDFromString(file.stage2.sha), file.stage2.path); err != nil { 149 return err 150 } 151 return nil 152 case file.stage1 == nil && file.stage2 != nil && file.stage3 != nil && file.stage2.sha == file.stage3.sha && file.stage2.mode != file.stage3.mode: 153 // 3. Added in both with the same sha but the modes are different 154 // 155 // Conflict! (Not sure that this can actually happen but we should handle) 156 return &errMergeConflict{file.stage2.path} 157 case file.stage1 == nil && file.stage2 == nil && file.stage3 != nil: 158 // 4. Added in theirs but not ours: 159 // 160 // Not a genuine conflict just add to the index 161 return gitRepo.AddObjectToIndex(file.stage3.mode, git.MustIDFromString(file.stage3.sha), file.stage3.path) 162 case file.stage1 == nil: 163 // 5. Created by new in both 164 // 165 // Conflict! 166 return &errMergeConflict{file.stage2.path} 167 case file.stage2 != nil && file.stage3 != nil: 168 // 5. Modified in both - we should try to merge in the changes but first: 169 // 170 if file.stage2.mode == "120000" || file.stage3.mode == "120000" { 171 // 5a. Conflicting symbolic link change 172 return &errMergeConflict{file.stage2.path} 173 } 174 if file.stage2.mode == "160000" || file.stage3.mode == "160000" { 175 // 5b. Conflicting submodule change 176 return &errMergeConflict{file.stage2.path} 177 } 178 if file.stage2.mode != file.stage3.mode { 179 // 5c. Conflicting mode change 180 return &errMergeConflict{file.stage2.path} 181 } 182 183 // Need to get the objects from the object db to attempt to merge 184 root, _, err := git.NewCommand(ctx, "unpack-file").AddDynamicArguments(file.stage1.sha).RunStdString(&git.RunOpts{Dir: tmpBasePath}) 185 if err != nil { 186 return fmt.Errorf("unable to get root object: %s at path: %s for merging. Error: %w", file.stage1.sha, file.stage1.path, err) 187 } 188 root = strings.TrimSpace(root) 189 defer func() { 190 _ = util.Remove(filepath.Join(tmpBasePath, root)) 191 }() 192 193 base, _, err := git.NewCommand(ctx, "unpack-file").AddDynamicArguments(file.stage2.sha).RunStdString(&git.RunOpts{Dir: tmpBasePath}) 194 if err != nil { 195 return fmt.Errorf("unable to get base object: %s at path: %s for merging. Error: %w", file.stage2.sha, file.stage2.path, err) 196 } 197 base = strings.TrimSpace(filepath.Join(tmpBasePath, base)) 198 defer func() { 199 _ = util.Remove(base) 200 }() 201 head, _, err := git.NewCommand(ctx, "unpack-file").AddDynamicArguments(file.stage3.sha).RunStdString(&git.RunOpts{Dir: tmpBasePath}) 202 if err != nil { 203 return fmt.Errorf("unable to get head object:%s at path: %s for merging. Error: %w", file.stage3.sha, file.stage3.path, err) 204 } 205 head = strings.TrimSpace(head) 206 defer func() { 207 _ = util.Remove(filepath.Join(tmpBasePath, head)) 208 }() 209 210 // now git merge-file annoyingly takes a different order to the merge-tree ... 211 _, _, conflictErr := git.NewCommand(ctx, "merge-file").AddDynamicArguments(base, root, head).RunStdString(&git.RunOpts{Dir: tmpBasePath}) 212 if conflictErr != nil { 213 return &errMergeConflict{file.stage2.path} 214 } 215 216 // base now contains the merged data 217 hash, _, err := git.NewCommand(ctx, "hash-object", "-w", "--path").AddDynamicArguments(file.stage2.path, base).RunStdString(&git.RunOpts{Dir: tmpBasePath}) 218 if err != nil { 219 return err 220 } 221 hash = strings.TrimSpace(hash) 222 return gitRepo.AddObjectToIndex(file.stage2.mode, git.MustIDFromString(hash), file.stage2.path) 223 default: 224 if file.stage1 != nil { 225 return &errMergeConflict{file.stage1.path} 226 } else if file.stage2 != nil { 227 return &errMergeConflict{file.stage2.path} 228 } else if file.stage3 != nil { 229 return &errMergeConflict{file.stage3.path} 230 } 231 } 232 return nil 233 } 234 235 // AttemptThreeWayMerge will attempt to three way merge using git read-tree and then follow the git merge-one-file algorithm to attempt to resolve basic conflicts 236 func AttemptThreeWayMerge(ctx context.Context, gitPath string, gitRepo *git.Repository, base, ours, theirs, description string) (bool, []string, error) { 237 ctx, cancel := context.WithCancel(ctx) 238 defer cancel() 239 240 // First we use read-tree to do a simple three-way merge 241 if _, _, err := git.NewCommand(ctx, "read-tree", "-m").AddDynamicArguments(base, ours, theirs).RunStdString(&git.RunOpts{Dir: gitPath}); err != nil { 242 log.Error("Unable to run read-tree -m! Error: %v", err) 243 return false, nil, fmt.Errorf("unable to run read-tree -m! Error: %w", err) 244 } 245 246 // Then we use git ls-files -u to list the unmerged files and collate the triples in unmergedfiles 247 unmerged := make(chan *unmergedFile) 248 go unmergedFiles(ctx, gitPath, unmerged) 249 250 defer func() { 251 cancel() 252 for range unmerged { 253 // empty the unmerged channel 254 } 255 }() 256 257 numberOfConflicts := 0 258 conflict := false 259 conflictedFiles := make([]string, 0, 5) 260 261 for file := range unmerged { 262 if file == nil { 263 break 264 } 265 if file.err != nil { 266 cancel() 267 return false, nil, file.err 268 } 269 270 // OK now we have the unmerged file triplet attempt to merge it 271 if err := attemptMerge(ctx, file, gitPath, gitRepo); err != nil { 272 if conflictErr, ok := err.(*errMergeConflict); ok { 273 log.Trace("Conflict: %s in %s", conflictErr.filename, description) 274 conflict = true 275 if numberOfConflicts < 10 { 276 conflictedFiles = append(conflictedFiles, conflictErr.filename) 277 } 278 numberOfConflicts++ 279 continue 280 } 281 return false, nil, err 282 } 283 } 284 return conflict, conflictedFiles, nil 285 } 286 287 func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository, tmpBasePath string) (bool, error) { 288 // 1. checkConflicts resets the conflict status - therefore - reset the conflict status 289 pr.ConflictedFiles = nil 290 291 // 2. AttemptThreeWayMerge first - this is much quicker than plain patch to base 292 description := fmt.Sprintf("PR[%d] %s/%s#%d", pr.ID, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Index) 293 conflict, conflictFiles, err := AttemptThreeWayMerge(ctx, 294 tmpBasePath, gitRepo, pr.MergeBase, "base", "tracking", description) 295 if err != nil { 296 return false, err 297 } 298 299 if !conflict { 300 // No conflicts detected so we need to check if the patch is empty... 301 // a. Write the newly merged tree and check the new tree-hash 302 var treeHash string 303 treeHash, _, err = git.NewCommand(ctx, "write-tree").RunStdString(&git.RunOpts{Dir: tmpBasePath}) 304 if err != nil { 305 lsfiles, _, _ := git.NewCommand(ctx, "ls-files", "-u").RunStdString(&git.RunOpts{Dir: tmpBasePath}) 306 return false, fmt.Errorf("unable to write unconflicted tree: %w\n`git ls-files -u`:\n%s", err, lsfiles) 307 } 308 treeHash = strings.TrimSpace(treeHash) 309 baseTree, err := gitRepo.GetTree("base") 310 if err != nil { 311 return false, err 312 } 313 314 // b. compare the new tree-hash with the base tree hash 315 if treeHash == baseTree.ID.String() { 316 log.Debug("PullRequest[%d]: Patch is empty - ignoring", pr.ID) 317 pr.Status = issues_model.PullRequestStatusEmpty 318 } 319 320 return false, nil 321 } 322 323 // 3. OK the three-way merge method has detected conflicts 324 // 3a. Are still testing with GitApply? If not set the conflict status and move on 325 if !setting.Repository.PullRequest.TestConflictingPatchesWithGitApply { 326 pr.Status = issues_model.PullRequestStatusConflict 327 pr.ConflictedFiles = conflictFiles 328 329 log.Trace("Found %d files conflicted: %v", len(pr.ConflictedFiles), pr.ConflictedFiles) 330 return true, nil 331 } 332 333 // 3b. Create a plain patch from head to base 334 tmpPatchFile, err := os.CreateTemp("", "patch") 335 if err != nil { 336 log.Error("Unable to create temporary patch file! Error: %v", err) 337 return false, fmt.Errorf("unable to create temporary patch file! Error: %w", err) 338 } 339 defer func() { 340 _ = util.Remove(tmpPatchFile.Name()) 341 }() 342 343 if err := gitRepo.GetDiffBinary(pr.MergeBase, "tracking", tmpPatchFile); err != nil { 344 tmpPatchFile.Close() 345 log.Error("Unable to get patch file from %s to %s in %s Error: %v", pr.MergeBase, pr.HeadBranch, pr.BaseRepo.FullName(), err) 346 return false, fmt.Errorf("unable to get patch file from %s to %s in %s Error: %w", pr.MergeBase, pr.HeadBranch, pr.BaseRepo.FullName(), err) 347 } 348 stat, err := tmpPatchFile.Stat() 349 if err != nil { 350 tmpPatchFile.Close() 351 return false, fmt.Errorf("unable to stat patch file: %w", err) 352 } 353 patchPath := tmpPatchFile.Name() 354 tmpPatchFile.Close() 355 356 // 3c. if the size of that patch is 0 - there can be no conflicts! 357 if stat.Size() == 0 { 358 log.Debug("PullRequest[%d]: Patch is empty - ignoring", pr.ID) 359 pr.Status = issues_model.PullRequestStatusEmpty 360 return false, nil 361 } 362 363 log.Trace("PullRequest[%d].testPatch (patchPath): %s", pr.ID, patchPath) 364 365 // 4. Read the base branch in to the index of the temporary repository 366 _, _, err = git.NewCommand(gitRepo.Ctx, "read-tree", "base").RunStdString(&git.RunOpts{Dir: tmpBasePath}) 367 if err != nil { 368 return false, fmt.Errorf("git read-tree %s: %w", pr.BaseBranch, err) 369 } 370 371 // 5. Now get the pull request configuration to check if we need to ignore whitespace 372 prUnit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests) 373 if err != nil { 374 return false, err 375 } 376 prConfig := prUnit.PullRequestsConfig() 377 378 // 6. Prepare the arguments to apply the patch against the index 379 cmdApply := git.NewCommand(gitRepo.Ctx, "apply", "--check", "--cached") 380 if prConfig.IgnoreWhitespaceConflicts { 381 cmdApply.AddArguments("--ignore-whitespace") 382 } 383 is3way := false 384 if git.CheckGitVersionAtLeast("2.32.0") == nil { 385 cmdApply.AddArguments("--3way") 386 is3way = true 387 } 388 cmdApply.AddDynamicArguments(patchPath) 389 390 // 7. Prep the pipe: 391 // - Here we could do the equivalent of: 392 // `git apply --check --cached patch_file > conflicts` 393 // Then iterate through the conflicts. However, that means storing all the conflicts 394 // in memory - which is very wasteful. 395 // - alternatively we can do the equivalent of: 396 // `git apply --check ... | grep ...` 397 // meaning we don't store all of the conflicts unnecessarily. 398 stderrReader, stderrWriter, err := os.Pipe() 399 if err != nil { 400 log.Error("Unable to open stderr pipe: %v", err) 401 return false, fmt.Errorf("unable to open stderr pipe: %w", err) 402 } 403 defer func() { 404 _ = stderrReader.Close() 405 _ = stderrWriter.Close() 406 }() 407 408 // 8. Run the check command 409 conflict = false 410 err = cmdApply.Run(&git.RunOpts{ 411 Dir: tmpBasePath, 412 Stderr: stderrWriter, 413 PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { 414 // Close the writer end of the pipe to begin processing 415 _ = stderrWriter.Close() 416 defer func() { 417 // Close the reader on return to terminate the git command if necessary 418 _ = stderrReader.Close() 419 }() 420 421 const prefix = "error: patch failed:" 422 const errorPrefix = "error: " 423 const threewayFailed = "Failed to perform three-way merge..." 424 const appliedPatchPrefix = "Applied patch to '" 425 const withConflicts = "' with conflicts." 426 427 conflicts := make(container.Set[string]) 428 429 // Now scan the output from the command 430 scanner := bufio.NewScanner(stderrReader) 431 for scanner.Scan() { 432 line := scanner.Text() 433 log.Trace("PullRequest[%d].testPatch: stderr: %s", pr.ID, line) 434 if strings.HasPrefix(line, prefix) { 435 conflict = true 436 filepath := strings.TrimSpace(strings.Split(line[len(prefix):], ":")[0]) 437 conflicts.Add(filepath) 438 } else if is3way && line == threewayFailed { 439 conflict = true 440 } else if strings.HasPrefix(line, errorPrefix) { 441 conflict = true 442 for _, suffix := range patchErrorSuffices { 443 if strings.HasSuffix(line, suffix) { 444 filepath := strings.TrimSpace(strings.TrimSuffix(line[len(errorPrefix):], suffix)) 445 if filepath != "" { 446 conflicts.Add(filepath) 447 } 448 break 449 } 450 } 451 } else if is3way && strings.HasPrefix(line, appliedPatchPrefix) && strings.HasSuffix(line, withConflicts) { 452 conflict = true 453 filepath := strings.TrimPrefix(strings.TrimSuffix(line, withConflicts), appliedPatchPrefix) 454 if filepath != "" { 455 conflicts.Add(filepath) 456 } 457 } 458 // only list 10 conflicted files 459 if len(conflicts) >= 10 { 460 break 461 } 462 } 463 464 if len(conflicts) > 0 { 465 pr.ConflictedFiles = make([]string, 0, len(conflicts)) 466 for key := range conflicts { 467 pr.ConflictedFiles = append(pr.ConflictedFiles, key) 468 } 469 } 470 471 return nil 472 }, 473 }) 474 475 // 9. Check if the found conflictedfiles is non-zero, "err" could be non-nil, so we should ignore it if we found conflicts. 476 // Note: `"err" could be non-nil` is due that if enable 3-way merge, it doesn't return any error on found conflicts. 477 if len(pr.ConflictedFiles) > 0 { 478 if conflict { 479 pr.Status = issues_model.PullRequestStatusConflict 480 log.Trace("Found %d files conflicted: %v", len(pr.ConflictedFiles), pr.ConflictedFiles) 481 482 return true, nil 483 } 484 } else if err != nil { 485 return false, fmt.Errorf("git apply --check: %w", err) 486 } 487 return false, nil 488 } 489 490 // CheckFileProtection check file Protection 491 func CheckFileProtection(repo *git.Repository, oldCommitID, newCommitID string, patterns []glob.Glob, limit int, env []string) ([]string, error) { 492 if len(patterns) == 0 { 493 return nil, nil 494 } 495 affectedFiles, err := git.GetAffectedFiles(repo, oldCommitID, newCommitID, env) 496 if err != nil { 497 return nil, err 498 } 499 changedProtectedFiles := make([]string, 0, limit) 500 for _, affectedFile := range affectedFiles { 501 lpath := strings.ToLower(affectedFile) 502 for _, pat := range patterns { 503 if pat.Match(lpath) { 504 changedProtectedFiles = append(changedProtectedFiles, lpath) 505 break 506 } 507 } 508 if len(changedProtectedFiles) >= limit { 509 break 510 } 511 } 512 if len(changedProtectedFiles) > 0 { 513 err = models.ErrFilePathProtected{ 514 Path: changedProtectedFiles[0], 515 } 516 } 517 return changedProtectedFiles, err 518 } 519 520 // CheckUnprotectedFiles check if the commit only touches unprotected files 521 func CheckUnprotectedFiles(repo *git.Repository, oldCommitID, newCommitID string, patterns []glob.Glob, env []string) (bool, error) { 522 if len(patterns) == 0 { 523 return false, nil 524 } 525 affectedFiles, err := git.GetAffectedFiles(repo, oldCommitID, newCommitID, env) 526 if err != nil { 527 return false, err 528 } 529 for _, affectedFile := range affectedFiles { 530 lpath := strings.ToLower(affectedFile) 531 unprotected := false 532 for _, pat := range patterns { 533 if pat.Match(lpath) { 534 unprotected = true 535 break 536 } 537 } 538 if !unprotected { 539 return false, nil 540 } 541 } 542 return true, nil 543 } 544 545 // checkPullFilesProtection check if pr changed protected files and save results 546 func checkPullFilesProtection(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) error { 547 if pr.Status == issues_model.PullRequestStatusEmpty { 548 pr.ChangedProtectedFiles = nil 549 return nil 550 } 551 552 pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch) 553 if err != nil { 554 return err 555 } 556 557 if pb == nil { 558 pr.ChangedProtectedFiles = nil 559 return nil 560 } 561 562 pr.ChangedProtectedFiles, err = CheckFileProtection(gitRepo, pr.MergeBase, "tracking", pb.GetProtectedFilePatterns(), 10, os.Environ()) 563 if err != nil && !models.IsErrFilePathProtected(err) { 564 return err 565 } 566 return nil 567 }