forked from gitea/gitea
		
	Improve TestPatch to use git read-tree -m and implement git-merge-one-file functionality (#18004)
The current TestPatch conflict code uses a plain git apply which does not properly account for 3-way merging. However, we can improve things using `git read-tree -m` to do a three-way merge then follow the algorithm used in merge-one-file. We can also use `--patience` and/or `--histogram` to generate a nicer diff for applying patches too. Fix #13679 Fix #6417 Signed-off-by: Andrew Thornton <art27@cantab.net>
This commit is contained in:
		
							parent
							
								
									487ce3b49e
								
							
						
					
					
						commit
						f1e85622da
					
				| @ -237,7 +237,11 @@ func (repo *Repository) GetDiff(base, head string, w io.Writer) error { | ||||
| 
 | ||||
| // GetDiffBinary generates and returns patch data between given revisions, including binary diffs. | ||||
| func (repo *Repository) GetDiffBinary(base, head string, w io.Writer) error { | ||||
| 	return NewCommandContext(repo.Ctx, "diff", "-p", "--binary", base, head). | ||||
| 	if CheckGitVersionAtLeast("1.7.7") == nil { | ||||
| 		return NewCommandContext(repo.Ctx, "diff", "-p", "--binary", "--histogram", base, head). | ||||
| 			RunInDirPipeline(repo.Path, w, nil) | ||||
| 	} | ||||
| 	return NewCommandContext(repo.Ctx, "diff", "-p", "--binary", "--patience", base, head). | ||||
| 		RunInDirPipeline(repo.Path, w, nil) | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -11,12 +11,15 @@ import ( | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"code.gitea.io/gitea/models" | ||||
| 	"code.gitea.io/gitea/models/unit" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	"code.gitea.io/gitea/modules/graceful" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/process" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 
 | ||||
| 	"github.com/gobwas/glob" | ||||
| @ -98,12 +101,193 @@ func TestPatch(pr *models.PullRequest) error { | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type errMergeConflict struct { | ||||
| 	filename string | ||||
| } | ||||
| 
 | ||||
| func (e *errMergeConflict) Error() string { | ||||
| 	return fmt.Sprintf("conflict detected at: %s", e.filename) | ||||
| } | ||||
| 
 | ||||
| func attemptMerge(ctx context.Context, file *unmergedFile, tmpBasePath string, gitRepo *git.Repository) error { | ||||
| 	switch { | ||||
| 	case file.stage1 != nil && (file.stage2 == nil || file.stage3 == nil): | ||||
| 		// 1. Deleted in one or both: | ||||
| 		// | ||||
| 		// Conflict <==> the stage1 !SameAs to the undeleted one | ||||
| 		if (file.stage2 != nil && !file.stage1.SameAs(file.stage2)) || (file.stage3 != nil && !file.stage1.SameAs(file.stage3)) { | ||||
| 			// Conflict! | ||||
| 			return &errMergeConflict{file.stage1.path} | ||||
| 		} | ||||
| 
 | ||||
| 		// Not a genuine conflict and we can simply remove the file from the index | ||||
| 		return gitRepo.RemoveFilesFromIndex(file.stage1.path) | ||||
| 	case file.stage1 == nil && file.stage2 != nil && (file.stage3 == nil || file.stage2.SameAs(file.stage3)): | ||||
| 		// 2. Added in ours but not in theirs or identical in both | ||||
| 		// | ||||
| 		// Not a genuine conflict just add to the index | ||||
| 		if err := gitRepo.AddObjectToIndex(file.stage2.mode, git.MustIDFromString(file.stage2.sha), file.stage2.path); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		return nil | ||||
| 	case file.stage1 == nil && file.stage2 != nil && file.stage3 != nil && file.stage2.sha == file.stage3.sha && file.stage2.mode != file.stage3.mode: | ||||
| 		// 3. Added in both with the same sha but the modes are different | ||||
| 		// | ||||
| 		// Conflict! (Not sure that this can actually happen but we should handle) | ||||
| 		return &errMergeConflict{file.stage2.path} | ||||
| 	case file.stage1 == nil && file.stage2 == nil && file.stage3 != nil: | ||||
| 		// 4. Added in theirs but not ours: | ||||
| 		// | ||||
| 		// Not a genuine conflict just add to the index | ||||
| 		return gitRepo.AddObjectToIndex(file.stage3.mode, git.MustIDFromString(file.stage3.sha), file.stage3.path) | ||||
| 	case file.stage1 == nil: | ||||
| 		// 5. Created by new in both | ||||
| 		// | ||||
| 		// Conflict! | ||||
| 		return &errMergeConflict{file.stage2.path} | ||||
| 	case file.stage2 != nil && file.stage3 != nil: | ||||
| 		// 5. Modified in both - we should try to merge in the changes but first: | ||||
| 		// | ||||
| 		if file.stage2.mode == "120000" || file.stage3.mode == "120000" { | ||||
| 			// 5a. Conflicting symbolic link change | ||||
| 			return &errMergeConflict{file.stage2.path} | ||||
| 		} | ||||
| 		if file.stage2.mode == "160000" || file.stage3.mode == "160000" { | ||||
| 			// 5b. Conflicting submodule change | ||||
| 			return &errMergeConflict{file.stage2.path} | ||||
| 		} | ||||
| 		if file.stage2.mode != file.stage3.mode { | ||||
| 			// 5c. Conflicting mode change | ||||
| 			return &errMergeConflict{file.stage2.path} | ||||
| 		} | ||||
| 
 | ||||
| 		// Need to get the objects from the object db to attempt to merge | ||||
| 		root, err := git.NewCommandContext(ctx, "unpack-file", file.stage1.sha).RunInDir(tmpBasePath) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("unable to get root object: %s at path: %s for merging. Error: %w", file.stage1.sha, file.stage1.path, err) | ||||
| 		} | ||||
| 		root = strings.TrimSpace(root) | ||||
| 		defer func() { | ||||
| 			_ = util.Remove(filepath.Join(tmpBasePath, root)) | ||||
| 		}() | ||||
| 
 | ||||
| 		base, err := git.NewCommandContext(ctx, "unpack-file", file.stage2.sha).RunInDir(tmpBasePath) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("unable to get base object: %s at path: %s for merging. Error: %w", file.stage2.sha, file.stage2.path, err) | ||||
| 		} | ||||
| 		base = strings.TrimSpace(filepath.Join(tmpBasePath, base)) | ||||
| 		defer func() { | ||||
| 			_ = util.Remove(base) | ||||
| 		}() | ||||
| 		head, err := git.NewCommandContext(ctx, "unpack-file", file.stage3.sha).RunInDir(tmpBasePath) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("unable to get head object:%s at path: %s for merging. Error: %w", file.stage3.sha, file.stage3.path, err) | ||||
| 		} | ||||
| 		head = strings.TrimSpace(head) | ||||
| 		defer func() { | ||||
| 			_ = util.Remove(filepath.Join(tmpBasePath, head)) | ||||
| 		}() | ||||
| 
 | ||||
| 		// now git merge-file annoyingly takes a different order to the merge-tree ... | ||||
| 		_, conflictErr := git.NewCommandContext(ctx, "merge-file", base, root, head).RunInDir(tmpBasePath) | ||||
| 		if conflictErr != nil { | ||||
| 			return &errMergeConflict{file.stage2.path} | ||||
| 		} | ||||
| 
 | ||||
| 		// base now contains the merged data | ||||
| 		hash, err := git.NewCommandContext(ctx, "hash-object", "-w", "--path", file.stage2.path, base).RunInDir(tmpBasePath) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		hash = strings.TrimSpace(hash) | ||||
| 		return gitRepo.AddObjectToIndex(file.stage2.mode, git.MustIDFromString(hash), file.stage2.path) | ||||
| 	default: | ||||
| 		if file.stage1 != nil { | ||||
| 			return &errMergeConflict{file.stage1.path} | ||||
| 		} else if file.stage2 != nil { | ||||
| 			return &errMergeConflict{file.stage2.path} | ||||
| 		} else if file.stage3 != nil { | ||||
| 			return &errMergeConflict{file.stage3.path} | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func checkConflicts(pr *models.PullRequest, gitRepo *git.Repository, tmpBasePath string) (bool, error) { | ||||
| 	ctx, cancel, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("checkConflicts: pr[%d] %s/%s#%d", pr.ID, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Index)) | ||||
| 	defer finished() | ||||
| 
 | ||||
| 	// First we use read-tree to do a simple three-way merge | ||||
| 	if _, err := git.NewCommandContext(ctx, "read-tree", "-m", pr.MergeBase, "base", "tracking").RunInDir(tmpBasePath); err != nil { | ||||
| 		log.Error("Unable to run read-tree -m! Error: %v", err) | ||||
| 		return false, fmt.Errorf("unable to run read-tree -m! Error: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Then we use git ls-files -u to list the unmerged files and collate the triples in unmergedfiles | ||||
| 	unmerged := make(chan *unmergedFile) | ||||
| 	go unmergedFiles(ctx, tmpBasePath, unmerged) | ||||
| 
 | ||||
| 	defer func() { | ||||
| 		cancel() | ||||
| 		for range unmerged { | ||||
| 			// empty the unmerged channel | ||||
| 		} | ||||
| 	}() | ||||
| 
 | ||||
| 	numberOfConflicts := 0 | ||||
| 	conflict := false | ||||
| 
 | ||||
| 	for file := range unmerged { | ||||
| 		if file == nil { | ||||
| 			break | ||||
| 		} | ||||
| 		if file.err != nil { | ||||
| 			cancel() | ||||
| 			return false, file.err | ||||
| 		} | ||||
| 
 | ||||
| 		// OK now we have the unmerged file triplet attempt to merge it | ||||
| 		if err := attemptMerge(ctx, file, tmpBasePath, gitRepo); err != nil { | ||||
| 			if conflictErr, ok := err.(*errMergeConflict); ok { | ||||
| 				log.Trace("Conflict: %s in PR[%d] %s/%s#%d", conflictErr.filename, pr.ID, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Index) | ||||
| 				conflict = true | ||||
| 				if numberOfConflicts < 10 { | ||||
| 					pr.ConflictedFiles = append(pr.ConflictedFiles, conflictErr.filename) | ||||
| 				} | ||||
| 				numberOfConflicts++ | ||||
| 				continue | ||||
| 			} | ||||
| 			return false, err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if !conflict { | ||||
| 		treeHash, err := git.NewCommandContext(ctx, "write-tree").RunInDir(tmpBasePath) | ||||
| 		if err != nil { | ||||
| 			return false, err | ||||
| 		} | ||||
| 		treeHash = strings.TrimSpace(treeHash) | ||||
| 		baseTree, err := gitRepo.GetTree("base") | ||||
| 		if err != nil { | ||||
| 			return false, err | ||||
| 		} | ||||
| 		if treeHash == baseTree.ID.String() { | ||||
| 			log.Debug("PullRequest[%d]: Patch is empty - ignoring", pr.ID) | ||||
| 			pr.Status = models.PullRequestStatusEmpty | ||||
| 			pr.ConflictedFiles = []string{} | ||||
| 			pr.ChangedProtectedFiles = []string{} | ||||
| 		} | ||||
| 
 | ||||
| 		return false, nil | ||||
| 	} | ||||
| 
 | ||||
| 	// OK read-tree has failed so we need to try a different thing - this might actually succeed where the above fails due to whitespace handling. | ||||
| 
 | ||||
| 	// 1. Create a plain patch from head to base | ||||
| 	tmpPatchFile, err := os.CreateTemp("", "patch") | ||||
| 	if err != nil { | ||||
| 		log.Error("Unable to create temporary patch file! Error: %v", err) | ||||
| 		return false, fmt.Errorf("Unable to create temporary patch file! Error: %v", err) | ||||
| 		return false, fmt.Errorf("unable to create temporary patch file! Error: %v", err) | ||||
| 	} | ||||
| 	defer func() { | ||||
| 		_ = util.Remove(tmpPatchFile.Name()) | ||||
| @ -112,12 +296,12 @@ func checkConflicts(pr *models.PullRequest, gitRepo *git.Repository, tmpBasePath | ||||
| 	if err := gitRepo.GetDiffBinary(pr.MergeBase, "tracking", tmpPatchFile); err != nil { | ||||
| 		tmpPatchFile.Close() | ||||
| 		log.Error("Unable to get patch file from %s to %s in %s Error: %v", pr.MergeBase, pr.HeadBranch, pr.BaseRepo.FullName(), err) | ||||
| 		return false, fmt.Errorf("Unable to get patch file from %s to %s in %s Error: %v", pr.MergeBase, pr.HeadBranch, pr.BaseRepo.FullName(), err) | ||||
| 		return false, fmt.Errorf("unable to get patch file from %s to %s in %s Error: %v", pr.MergeBase, pr.HeadBranch, pr.BaseRepo.FullName(), err) | ||||
| 	} | ||||
| 	stat, err := tmpPatchFile.Stat() | ||||
| 	if err != nil { | ||||
| 		tmpPatchFile.Close() | ||||
| 		return false, fmt.Errorf("Unable to stat patch file: %v", err) | ||||
| 		return false, fmt.Errorf("unable to stat patch file: %v", err) | ||||
| 	} | ||||
| 	patchPath := tmpPatchFile.Name() | ||||
| 	tmpPatchFile.Close() | ||||
| @ -154,6 +338,9 @@ func checkConflicts(pr *models.PullRequest, gitRepo *git.Repository, tmpBasePath | ||||
| 	if prConfig.IgnoreWhitespaceConflicts { | ||||
| 		args = append(args, "--ignore-whitespace") | ||||
| 	} | ||||
| 	if git.CheckGitVersionAtLeast("2.32.0") == nil { | ||||
| 		args = append(args, "--3way") | ||||
| 	} | ||||
| 	args = append(args, patchPath) | ||||
| 	pr.ConflictedFiles = make([]string, 0, 5) | ||||
| 
 | ||||
| @ -168,7 +355,7 @@ func checkConflicts(pr *models.PullRequest, gitRepo *git.Repository, tmpBasePath | ||||
| 	stderrReader, stderrWriter, err := os.Pipe() | ||||
| 	if err != nil { | ||||
| 		log.Error("Unable to open stderr pipe: %v", err) | ||||
| 		return false, fmt.Errorf("Unable to open stderr pipe: %v", err) | ||||
| 		return false, fmt.Errorf("unable to open stderr pipe: %v", err) | ||||
| 	} | ||||
| 	defer func() { | ||||
| 		_ = stderrReader.Close() | ||||
| @ -176,7 +363,7 @@ func checkConflicts(pr *models.PullRequest, gitRepo *git.Repository, tmpBasePath | ||||
| 	}() | ||||
| 
 | ||||
| 	// 7. Run the check command | ||||
| 	conflict := false | ||||
| 	conflict = false | ||||
| 	err = git.NewCommand(args...). | ||||
| 		RunInDirTimeoutEnvFullPipelineFunc( | ||||
| 			nil, -1, tmpBasePath, | ||||
|  | ||||
							
								
								
									
										180
									
								
								services/pull/patch_unmerged.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										180
									
								
								services/pull/patch_unmerged.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,180 @@ | ||||
| // Copyright 2021 The Gitea Authors. | ||||
| // All rights reserved. | ||||
| // Use of this source code is governed by a MIT-style | ||||
| // license that can be found in the LICENSE file. | ||||
| 
 | ||||
| package pull | ||||
| 
 | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"os" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| ) | ||||
| 
 | ||||
| // lsFileLine is a Quadruplet struct (+error) representing a partially parsed line from ls-files | ||||
| type lsFileLine struct { | ||||
| 	mode  string | ||||
| 	sha   string | ||||
| 	stage int | ||||
| 	path  string | ||||
| 	err   error | ||||
| } | ||||
| 
 | ||||
| // SameAs checks if two lsFileLines are referring to the same path, sha and mode (ignoring stage) | ||||
| func (line *lsFileLine) SameAs(other *lsFileLine) bool { | ||||
| 	if line == nil || other == nil { | ||||
| 		return false | ||||
| 	} | ||||
| 
 | ||||
| 	if line.err != nil || other.err != nil { | ||||
| 		return false | ||||
| 	} | ||||
| 
 | ||||
| 	return line.mode == other.mode && | ||||
| 		line.sha == other.sha && | ||||
| 		line.path == other.path | ||||
| } | ||||
| 
 | ||||
| // readUnmergedLsFileLines calls git ls-files -u -z and parses the lines into mode-sha-stage-path quadruplets | ||||
| // it will push these to the provided channel closing it at the end | ||||
| func readUnmergedLsFileLines(ctx context.Context, tmpBasePath string, outputChan chan *lsFileLine) { | ||||
| 	defer func() { | ||||
| 		// Always close the outputChan at the end of this function | ||||
| 		close(outputChan) | ||||
| 	}() | ||||
| 
 | ||||
| 	lsFilesReader, lsFilesWriter, err := os.Pipe() | ||||
| 	if err != nil { | ||||
| 		log.Error("Unable to open stderr pipe: %v", err) | ||||
| 		outputChan <- &lsFileLine{err: fmt.Errorf("unable to open stderr pipe: %v", err)} | ||||
| 		return | ||||
| 	} | ||||
| 	defer func() { | ||||
| 		_ = lsFilesWriter.Close() | ||||
| 		_ = lsFilesReader.Close() | ||||
| 	}() | ||||
| 
 | ||||
| 	stderr := &strings.Builder{} | ||||
| 	err = git.NewCommandContext(ctx, "ls-files", "-u", "-z"). | ||||
| 		RunInDirTimeoutEnvFullPipelineFunc( | ||||
| 			nil, -1, tmpBasePath, | ||||
| 			lsFilesWriter, stderr, nil, | ||||
| 			func(_ context.Context, _ context.CancelFunc) error { | ||||
| 				_ = lsFilesWriter.Close() | ||||
| 				defer func() { | ||||
| 					_ = lsFilesReader.Close() | ||||
| 				}() | ||||
| 				bufferedReader := bufio.NewReader(lsFilesReader) | ||||
| 
 | ||||
| 				for { | ||||
| 					line, err := bufferedReader.ReadString('\000') | ||||
| 					if err != nil { | ||||
| 						if err == io.EOF { | ||||
| 							return nil | ||||
| 						} | ||||
| 						return err | ||||
| 					} | ||||
| 					toemit := &lsFileLine{} | ||||
| 
 | ||||
| 					split := strings.SplitN(line, " ", 3) | ||||
| 					if len(split) < 3 { | ||||
| 						return fmt.Errorf("malformed line: %s", line) | ||||
| 					} | ||||
| 					toemit.mode = split[0] | ||||
| 					toemit.sha = split[1] | ||||
| 
 | ||||
| 					if len(split[2]) < 4 { | ||||
| 						return fmt.Errorf("malformed line: %s", line) | ||||
| 					} | ||||
| 
 | ||||
| 					toemit.stage, err = strconv.Atoi(split[2][0:1]) | ||||
| 					if err != nil { | ||||
| 						return fmt.Errorf("malformed line: %s", line) | ||||
| 					} | ||||
| 
 | ||||
| 					toemit.path = split[2][2 : len(split[2])-1] | ||||
| 					outputChan <- toemit | ||||
| 				} | ||||
| 			}) | ||||
| 
 | ||||
| 	if err != nil { | ||||
| 		outputChan <- &lsFileLine{err: fmt.Errorf("git ls-files -u -z: %v", git.ConcatenateError(err, stderr.String()))} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // unmergedFile is triple (+error) of lsFileLines split into stages 1,2 & 3. | ||||
| type unmergedFile struct { | ||||
| 	stage1 *lsFileLine | ||||
| 	stage2 *lsFileLine | ||||
| 	stage3 *lsFileLine | ||||
| 	err    error | ||||
| } | ||||
| 
 | ||||
| // unmergedFiles will collate the output from readUnstagedLsFileLines in to file triplets and send them | ||||
| // to the provided channel, closing at the end. | ||||
| func unmergedFiles(ctx context.Context, tmpBasePath string, unmerged chan *unmergedFile) { | ||||
| 	defer func() { | ||||
| 		// Always close the channel | ||||
| 		close(unmerged) | ||||
| 	}() | ||||
| 
 | ||||
| 	ctx, cancel := context.WithCancel(ctx) | ||||
| 	lsFileLineChan := make(chan *lsFileLine, 10) // give lsFileLineChan a buffer | ||||
| 	go readUnmergedLsFileLines(ctx, tmpBasePath, lsFileLineChan) | ||||
| 	defer func() { | ||||
| 		cancel() | ||||
| 		for range lsFileLineChan { | ||||
| 			// empty channel | ||||
| 		} | ||||
| 	}() | ||||
| 
 | ||||
| 	next := &unmergedFile{} | ||||
| 	for line := range lsFileLineChan { | ||||
| 		if line.err != nil { | ||||
| 			log.Error("Unable to run ls-files -u -z! Error: %v", line.err) | ||||
| 			unmerged <- &unmergedFile{err: fmt.Errorf("unable to run ls-files -u -z! Error: %v", line.err)} | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		// stages are always emitted 1,2,3 but sometimes 1, 2 or 3 are dropped | ||||
| 		switch line.stage { | ||||
| 		case 0: | ||||
| 			// Should not happen as this represents successfully merged file - we will tolerate and ignore though | ||||
| 		case 1: | ||||
| 			if next.stage1 != nil { | ||||
| 				// We need to handle the unstaged file stage1,stage2,stage3 | ||||
| 				unmerged <- next | ||||
| 			} | ||||
| 			next = &unmergedFile{stage1: line} | ||||
| 		case 2: | ||||
| 			if next.stage3 != nil || next.stage2 != nil || (next.stage1 != nil && next.stage1.path != line.path) { | ||||
| 				// We need to handle the unstaged file stage1,stage2,stage3 | ||||
| 				unmerged <- next | ||||
| 				next = &unmergedFile{} | ||||
| 			} | ||||
| 			next.stage2 = line | ||||
| 		case 3: | ||||
| 			if next.stage3 != nil || (next.stage1 != nil && next.stage1.path != line.path) || (next.stage2 != nil && next.stage2.path != line.path) { | ||||
| 				// We need to handle the unstaged file stage1,stage2,stage3 | ||||
| 				unmerged <- next | ||||
| 				next = &unmergedFile{} | ||||
| 			} | ||||
| 			next.stage3 = line | ||||
| 		default: | ||||
| 			log.Error("Unexpected stage %d for path %s in run ls-files -u -z!", line.stage, line.path) | ||||
| 			unmerged <- &unmergedFile{err: fmt.Errorf("unexpected stage %d for path %s in git ls-files -u -z", line.stage, line.path)} | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| 	// We need to handle the unstaged file stage1,stage2,stage3 | ||||
| 	if next.stage1 != nil || next.stage2 != nil || next.stage3 != nil { | ||||
| 		unmerged <- next | ||||
| 	} | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 zeripath
						zeripath