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