1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249
|
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
}
// 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
}
}
// 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"})
}
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")
// When the output is of unexpected length
if len(oidAndConflicts) < 2 {
return "", structerr.NewInternal("couldn't split oid and file info").WithMetadata("stderr", output)
}
oid, err := objectHash.FromHex(oidAndConflicts[0])
if err != nil {
return "", structerr.NewInternal("hex to oid: %w", err)
}
mergeTreeConflictError.ConflictingFileInfo = make([]ConflictingFileInfo, len(oidAndConflicts[1:]))
// From git-merge-tree(1), the information is of the format `<mode> <object> <stage> <filename>`
// 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 oidAndConflicts[1:] {
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]
}
}
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
}
// 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"
}
|