package localrepo import ( "bytes" "context" "errors" "strconv" "strings" "gitlab.com/gitlab-org/gitaly/v16/internal/command" "gitlab.com/gitlab-org/gitaly/v16/internal/git" "gitlab.com/gitlab-org/gitaly/v16/internal/helper/text" "gitlab.com/gitlab-org/gitaly/v16/internal/structerr" ) // MergeStage denotes the stage indicated by git-merge-tree(1) in the conflicting // files information section. The man page for git-merge(1) holds more information // regarding the values of the stages and what they indicate. type MergeStage uint const ( // MergeStageAncestor denotes a conflicting file version from the common ancestor. MergeStageAncestor = MergeStage(1) // MergeStageOurs denotes a conflicting file version from our commit. MergeStageOurs = MergeStage(2) // MergeStageTheirs denotes a conflicting file version from their commit. MergeStageTheirs = MergeStage(3) ) // ErrMergeTreeUnrelatedHistory is used to denote the error when trying to merge two // trees without unrelated history. This occurs when we don't use set the // `allowUnrelatedHistories` option in the config. var ErrMergeTreeUnrelatedHistory = errors.New("unrelated histories") type mergeTreeConfig struct { allowUnrelatedHistories bool conflictingFileNamesOnly bool mergeBase git.Revision } // MergeTreeOption is a function that sets a config in mergeTreeConfig. type MergeTreeOption func(*mergeTreeConfig) // WithAllowUnrelatedHistories lets MergeTree accept two commits that do not // share a common ancestor. func WithAllowUnrelatedHistories() MergeTreeOption { return func(options *mergeTreeConfig) { options.allowUnrelatedHistories = true } } // WithConflictingFileNamesOnly lets MergeTree only parse the conflicting filenames and // not the additional information. func WithConflictingFileNamesOnly() MergeTreeOption { return func(options *mergeTreeConfig) { options.conflictingFileNamesOnly = true } } // WithMergeBase lets the caller pass a revision which will be passed with the // --merge-base argument. func WithMergeBase(base git.Revision) MergeTreeOption { return func(options *mergeTreeConfig) { options.mergeBase = base } } // MergeTree calls git-merge-tree(1) with arguments, and parses the results from // stdout. func (repo *Repo) MergeTree( ctx context.Context, ours, theirs string, mergeTreeOptions ...MergeTreeOption, ) (git.ObjectID, error) { var config mergeTreeConfig for _, option := range mergeTreeOptions { option(&config) } flags := []git.Option{ git.Flag{Name: "-z"}, git.Flag{Name: "--write-tree"}, } if config.allowUnrelatedHistories { flags = append(flags, git.Flag{Name: "--allow-unrelated-histories"}) } if config.conflictingFileNamesOnly { flags = append(flags, git.Flag{Name: "--name-only"}) } if config.mergeBase != "" { flags = append(flags, git.ValueFlag{ Name: "--merge-base", Value: config.mergeBase.String(), }) } objectHash, err := repo.ObjectHash(ctx) if err != nil { return "", structerr.NewInternal("getting object hash %w", err) } var stdout, stderr bytes.Buffer err = repo.ExecAndWait( ctx, git.Command{ Name: "merge-tree", Flags: flags, Args: []string{ours, theirs}, }, git.WithStderr(&stderr), git.WithStdout(&stdout), ) if err != nil { exitCode, success := command.ExitStatus(err) if !success { return "", structerr.NewInternal("could not parse exit status of merge-tree(1)") } if exitCode == 1 { return parseMergeTreeError(objectHash, config, stdout.String()) } if text.ChompBytes(stderr.Bytes()) == "fatal: refusing to merge unrelated histories" { return "", ErrMergeTreeUnrelatedHistory } return "", structerr.NewInternal("merge-tree: %w", err).WithMetadata("exit_status", exitCode) } oid, err := objectHash.FromHex(strings.Split(stdout.String(), "\x00")[0]) if err != nil { return "", structerr.NewInternal("hex to oid: %w", err) } return oid, nil } // parseMergeTreeError parses the output from git-merge-tree(1)'s stdout into // a MergeTreeResult struct. The format for the output can be found at // https://git-scm.com/docs/git-merge-tree#OUTPUT. func parseMergeTreeError(objectHash git.ObjectHash, cfg mergeTreeConfig, output string) (git.ObjectID, error) { var mergeTreeConflictError MergeTreeConflictError oidAndConflictsBuf, infoMsg, ok := strings.Cut(output, "\x00\x00") if !ok { return "", structerr.NewInternal("couldn't parse merge tree output: %s", output).WithMetadata("stderr", output) } oidAndConflicts := strings.Split(oidAndConflictsBuf, "\x00") oid, err := objectHash.FromHex(oidAndConflicts[0]) if err != nil { return "", structerr.NewInternal("hex to oid: %w", err) } // If there are directory conflicts with unclear distinction, git-merge-tree(1) // doesn't output any filenames in the conflicted file info section if len(oidAndConflicts) > 1 { err := parseConflictingFileInfo(objectHash, cfg, &mergeTreeConflictError, oidAndConflicts[1:]) if err != nil { return "", err } } fields := strings.Split(infoMsg, "\x00") // The git output contains a null characted at the end, which creates a stray empty field. fields = fields[:len(fields)-1] for i := 0; i < len(fields); { c := ConflictInfoMessage{} numOfPaths, err := strconv.Atoi(fields[i]) if err != nil { return "", structerr.NewInternal("converting stage to int: %w", err) } if i+numOfPaths+2 >= len(fields) { return "", structerr.NewInternal("incorrect number of fields: %s", infoMsg) } c.Paths = fields[i+1 : i+numOfPaths+1] c.Type = fields[i+numOfPaths+1] c.Message = fields[i+numOfPaths+2] mergeTreeConflictError.ConflictInfoMessage = append(mergeTreeConflictError.ConflictInfoMessage, c) i = i + numOfPaths + 3 } return oid, &mergeTreeConflictError } func parseConflictingFileInfo(objectHash git.ObjectHash, cfg mergeTreeConfig, mergeTreeConflictError *MergeTreeConflictError, conflicts []string) error { mergeTreeConflictError.ConflictingFileInfo = make([]ConflictingFileInfo, len(conflicts)) // From git-merge-tree(1), the information is of the format ` ` // unless the `--name-only` option is used, in which case only the filename is output. // Note: that there is \t before the filename (https://gitlab.com/gitlab-org/git/blob/v2.40.0/builtin/merge-tree.c#L481) for i, infoLine := range conflicts { if cfg.conflictingFileNamesOnly { mergeTreeConflictError.ConflictingFileInfo[i].FileName = infoLine } else { infoAndFilename := strings.Split(infoLine, "\t") if len(infoAndFilename) != 2 { return structerr.NewInternal("parsing conflicting file info: %s", infoLine) } info := strings.Fields(infoAndFilename[0]) if len(info) != 3 { return structerr.NewInternal("parsing conflicting file info: %s", infoLine) } mode, err := strconv.ParseInt(info[0], 8, 32) if err != nil { return structerr.NewInternal("parsing mode: %w", err) } mergeTreeConflictError.ConflictingFileInfo[i].OID, err = objectHash.FromHex(info[1]) if err != nil { return structerr.NewInternal("hex to oid: %w", err) } stage, err := strconv.Atoi(info[2]) if err != nil { return structerr.NewInternal("converting stage to int: %w", err) } if stage < 1 || stage > 3 { return structerr.NewInternal("invalid value for stage: %d", stage) } mergeTreeConflictError.ConflictingFileInfo[i].Mode = int32(mode) mergeTreeConflictError.ConflictingFileInfo[i].Stage = MergeStage(stage) mergeTreeConflictError.ConflictingFileInfo[i].FileName = infoAndFilename[1] } } return nil } // ConflictingFileInfo holds the conflicting file info output from git-merge-tree(1). type ConflictingFileInfo struct { FileName string Mode int32 OID git.ObjectID Stage MergeStage } // ConflictInfoMessage holds the information message output from git-merge-tree(1). type ConflictInfoMessage struct { Paths []string Type string Message string } // MergeTreeConflictError encapsulates any conflicting file info and messages that occur // when a merge-tree(1) command fails. type MergeTreeConflictError struct { ConflictingFileInfo []ConflictingFileInfo ConflictInfoMessage []ConflictInfoMessage } // Error returns the error string for a conflict error. func (c *MergeTreeConflictError) Error() string { // TODO: for now, it's better that this error matches the git2go // error but once we deprecate the git2go code path in // merges, we can change this error to print out the conflicting files // and the InfoMessage. return "merge: there are conflicting files" } // ConflictedFiles is used to get the list of the names of the conflicted files from the // MergeTreeConflictError. func (c *MergeTreeConflictError) ConflictedFiles() []string { // We use a map for quick access to understand which files were already // accounted for. m := make(map[string]struct{}) var files []string for _, fileInfo := range c.ConflictingFileInfo { if _, ok := m[fileInfo.FileName]; ok { continue } m[fileInfo.FileName] = struct{}{} files = append(files, fileInfo.FileName) } return files }