UserMergeToRef discards the underlying merge error, hiding conflict details from merge trains

Summary

When UserMergeToRef fails to produce a merge commit, it discards the underlying error and replaces it with a generic message:

Failed to create merge commit for source_sha <sha> and target_sha <sha> at <ref>

This is the exact message GitLab surfaces to users when a merge request is removed from a merge train (refs/merge-requests/<iid>/train), e.g.:

<user> removed this merge request from the merge train because 9:Failed to create merge commit for source_sha c8fc16dd and target_sha ad144da9 at refs/merge-requests/XXXX/train.

Users have no way to tell whether this was a real conflict, a race with the target branch, or something else - see gitlab#394901 and internal Slack thread: https://gitlab.slack.com/archives/CPCJ8CCCX/p1785367057331189 (originally raised in https://gitlab.slack.com/archives/C01EMBKS5DW/p1785366666689039).

Root cause

	mergeCommitID, err := s.merge(
		ctx,
		repo,
		authorSignature,
		authorSignature,
		string(request.GetMessage()),
		oid.String(),
		sourceOID.String(),
		false,
		request.GetSign(),
	)
	if err != nil {
		s.logger.WithError(err).WithFields(
			log.Fields{
				"source_sha": sourceOID,
				"target_sha": oid,
				"target_ref": string(request.GetTargetRef()),
			},
		).ErrorContext(ctx, "unable to create merge commit")

		return nil, structerr.NewFailedPrecondition("Failed to create merge commit for source_sha %s and target_sha %s at %s",
			sourceOID, oid, string(request.GetTargetRef()))

UserMergeToRef calls the same internal s.merge() helper as UserMergeBranch, but handles its error very differently.

UserMergeBranch type-asserts the error and, for a *localrepo.MergeTreeConflictError, returns a structured MergeConflictError detail (ConflictingFiles, ConflictingCommitIds): https://gitlab.com/gitlab-org/gitaly/-/blob/cc5fb3de1ff7feb57201284dec36be68f8d6d992/internal/gitaly/service/operations/merge_branch.go#L79-101

UserMergeToRef does neither - it only logs the real error server-side (s.logger.WithError(err)...) and returns a generic FailedPrecondition with no message and no structured detail, so the caller (GitLab/Gitaly client) cannot distinguish a genuine conflict from any other merge failure.

There is also no UserMergeToRefError proto message (unlike UserMergeBranchError), so there's currently nowhere to attach structured detail even if the error were inspected.

Proposal

  • Mirror UserMergeBranch's handling in UserMergeToRef: use errors.As to detect *localrepo.MergeTreeConflictError and return the conflicting file/commit information to the caller instead of swallowing it.
  • Add a UserMergeToRefError proto message (following the UserMergeBranchError pattern) so a MergeConflictError detail can be attached.
  • This would let GitLab (ee/app/services/system_notes/merge_train_service.rb) present the actual conflict reason when a merge request is dropped from a merge train, resolving gitlab#394901 at the root cause.