internal/gitaly/service/operations/apply_patch.go (234 lines of code) (raw):
package operations
import (
"bytes"
"context"
"errors"
"fmt"
"math/rand"
"path/filepath"
"time"
"gitlab.com/gitlab-org/gitaly/v16/internal/git"
"gitlab.com/gitlab-org/gitaly/v16/internal/git/gitcmd"
"gitlab.com/gitlab-org/gitaly/v16/internal/git/housekeeping"
"gitlab.com/gitlab-org/gitaly/v16/internal/git/localrepo"
"gitlab.com/gitlab-org/gitaly/v16/internal/gitaly/storage"
"gitlab.com/gitlab-org/gitaly/v16/internal/helper/text"
"gitlab.com/gitlab-org/gitaly/v16/internal/structerr"
"gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb"
"gitlab.com/gitlab-org/gitaly/v16/streamio"
)
var errNoDefaultBranch = errors.New("no default branch")
type gitError struct {
// ErrMsg error message from 'git' executable if any.
ErrMsg string
// Err is an error that happened during rebase process.
Err error
}
func (er gitError) Error() string {
return er.ErrMsg + ": " + er.Err.Error()
}
// UserApplyPatch applies patches to a given branch.
func (s *Server) UserApplyPatch(stream gitalypb.OperationService_UserApplyPatchServer) error {
firstRequest, err := stream.Recv()
if err != nil {
return err
}
header := firstRequest.GetHeader()
if header == nil {
return structerr.NewInvalidArgument("empty UserApplyPatch_Header")
}
if err := validateUserApplyPatchHeader(stream.Context(), s.locator, header); err != nil {
return structerr.NewInvalidArgument("%w", err)
}
if err := s.userApplyPatch(stream.Context(), header, stream); err != nil {
return structerr.NewInternal("%w", err)
}
return nil
}
func (s *Server) userApplyPatch(ctx context.Context, header *gitalypb.UserApplyPatchRequest_Header, stream gitalypb.OperationService_UserApplyPatchServer) (returnedErr error) {
path, err := s.locator.GetRepoPath(ctx, header.GetRepository())
if err != nil {
return err
}
branchCreated := false
targetBranch := git.NewReferenceNameFromBranchName(string(header.GetTargetBranch()))
repo := s.localRepoFactory.Build(header.GetRepository())
objectHash, err := repo.ObjectHash(ctx)
if err != nil {
return fmt.Errorf("detecting object hash: %w", err)
}
parentCommitID, err := repo.ResolveRevision(ctx, targetBranch.Revision()+"^{commit}")
if err != nil {
if !errors.Is(err, git.ErrReferenceNotFound) {
return fmt.Errorf("resolve target branch: %w", err)
}
defaultBranch, err := repo.GetDefaultBranch(ctx)
if err != nil {
return fmt.Errorf("default branch name: %w", err)
}
parentCommitID, err = repo.ResolveRevision(ctx, defaultBranch.Revision()+"^{commit}")
if errors.Is(err, git.ErrReferenceNotFound) {
return errNoDefaultBranch
} else if err != nil {
return fmt.Errorf("resolve default branch commit: %w", err)
}
branchCreated = true
}
committerSignature, err := git.SignatureFromRequest(header)
if err != nil {
return structerr.NewInvalidArgument("%w", err)
}
worktreePath := newWorktreePath(path, "am-")
if err := s.addWorktree(ctx, repo, worktreePath, parentCommitID.String()); err != nil {
return fmt.Errorf("add worktree: %w", err)
}
// When transactions are not used, the worktree is added to the actual repository and needs to be removed.
// When transaction are used, the worktree ends up in the snapshot, and is removed with it. The snapshot
// is removed before this removal operations runs. Don't remove the tree here with transactions.
if storage.ExtractTransaction(ctx) == nil {
defer func() {
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
worktreeName := filepath.Base(worktreePath)
if err := s.removeWorktree(ctx, repo, worktreeName); err != nil {
returnedErr = errors.Join(returnedErr,
structerr.NewInternal("failed to remove worktree: %w", err).WithMetadata("worktree_name", worktreeName),
)
}
}()
}
var stdout, stderr bytes.Buffer
if err := repo.ExecAndWait(ctx,
gitcmd.Command{
Name: "am",
Flags: []gitcmd.Option{
gitcmd.Flag{Name: "--quiet"},
gitcmd.Flag{Name: "--3way"},
},
},
gitcmd.WithEnv(
"GIT_COMMITTER_NAME="+committerSignature.Name,
"GIT_COMMITTER_EMAIL="+committerSignature.Email,
"GIT_COMMITTER_DATE="+git.FormatTime(committerSignature.When),
),
gitcmd.WithStdin(streamio.NewReader(func() ([]byte, error) {
req, err := stream.Recv()
return req.GetPatches(), err
})),
gitcmd.WithStdout(&stdout),
gitcmd.WithStderr(&stderr),
gitcmd.WithRefTxHook(objectHash, repo),
gitcmd.WithWorktree(worktreePath),
); err != nil {
// The Ruby implementation doesn't include stderr in errors, which makes
// it difficult to determine the cause of an error. This special cases the
// user facing patching error which is returned usually to maintain test
// compatibility but returns the error and stderr otherwise. Once the Ruby
// implementation is removed, this should probably be dropped.
if bytes.HasPrefix(stdout.Bytes(), []byte("Patch failed at")) {
return structerr.NewFailedPrecondition("%s", stdout.String())
}
return fmt.Errorf("apply patch: %w, stderr: %q", err, &stderr)
}
var revParseStdout, revParseStderr bytes.Buffer
if err := repo.ExecAndWait(ctx,
gitcmd.Command{
Name: "rev-parse",
Flags: []gitcmd.Option{
gitcmd.Flag{Name: "--quiet"},
gitcmd.Flag{Name: "--verify"},
},
Args: []string{"HEAD^{commit}"},
},
gitcmd.WithStdout(&revParseStdout),
gitcmd.WithStderr(&revParseStderr),
gitcmd.WithWorktree(worktreePath),
); err != nil {
return fmt.Errorf("get patched commit: %w", gitError{ErrMsg: revParseStderr.String(), Err: err})
}
patchedCommit, err := objectHash.FromHex(text.ChompBytes(revParseStdout.Bytes()))
if err != nil {
return fmt.Errorf("parse patched commit oid: %w", err)
}
currentCommit := parentCommitID
if branchCreated {
currentCommit = objectHash.ZeroOID
}
// If the client provides an expected old object ID, we should use that to prevent any race
// conditions wherein the ref was concurrently updated by different processes.
if expectedOldOID := header.GetExpectedOldOid(); expectedOldOID != "" {
objectHash, err := repo.ObjectHash(ctx)
if err != nil {
return fmt.Errorf("detecting object hash: %w", err)
}
currentCommit, err = objectHash.FromHex(expectedOldOID)
if err != nil {
return fmt.Errorf("expected old object id not expected SHA format: %w", err)
}
currentCommit, err = resolveRevision(ctx, repo, currentCommit)
if err != nil {
return fmt.Errorf("expected old object cannot be resolved: %w", err)
}
}
if err := s.updateReferenceWithHooks(ctx, header.GetRepository(), header.GetUser(), nil, targetBranch, patchedCommit, currentCommit); err != nil {
return fmt.Errorf("update reference: %w", err)
}
if err := stream.SendAndClose(&gitalypb.UserApplyPatchResponse{
BranchUpdate: &gitalypb.OperationBranchUpdate{
CommitId: patchedCommit.String(),
BranchCreated: branchCreated,
},
}); err != nil {
return fmt.Errorf("send and close: %w", err)
}
return nil
}
func validateUserApplyPatchHeader(ctx context.Context, locator storage.Locator, header *gitalypb.UserApplyPatchRequest_Header) error {
if err := locator.ValidateRepository(ctx, header.GetRepository()); err != nil {
return err
}
if header.GetUser() == nil {
return errors.New("missing User")
}
if len(header.GetTargetBranch()) == 0 {
return errors.New("missing Branch")
}
return nil
}
func (s *Server) addWorktree(ctx context.Context, repo *localrepo.Repo, worktreePath string, committish string) error {
args := []string{worktreePath}
flags := []gitcmd.Option{gitcmd.Flag{Name: "--detach"}}
if committish != "" {
args = append(args, committish)
} else {
flags = append(flags, gitcmd.Flag{Name: "--no-checkout"})
}
objectHash, err := repo.ObjectHash(ctx)
if err != nil {
return fmt.Errorf("detecting object hash: %w", err)
}
var stderr bytes.Buffer
if err := repo.ExecAndWait(ctx, gitcmd.Command{
Name: "worktree",
Action: "add",
Flags: flags,
Args: args,
}, gitcmd.WithStderr(&stderr), gitcmd.WithRefTxHook(objectHash, repo)); err != nil {
return fmt.Errorf("adding worktree: %w", gitError{ErrMsg: stderr.String(), Err: err})
}
return nil
}
func (s *Server) removeWorktree(ctx context.Context, repo gitcmd.RepositoryExecutor, worktreeName string) error {
objectHash, err := repo.ObjectHash(ctx)
if err != nil {
return fmt.Errorf("detecting object hash: %w", err)
}
cmd, err := repo.Exec(ctx,
gitcmd.Command{
Name: "worktree",
Action: "remove",
Flags: []gitcmd.Option{gitcmd.Flag{Name: "--force"}},
Args: []string{worktreeName},
},
gitcmd.WithRefTxHook(objectHash, repo),
)
if err != nil {
return fmt.Errorf("creation of 'worktree remove': %w", err)
}
if err := cmd.Wait(); err != nil {
return fmt.Errorf("wait for 'worktree remove': %w", err)
}
return nil
}
func newWorktreePath(repoPath, prefix string) string {
chars := []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
rand.Shuffle(len(chars), func(i, j int) { chars[i], chars[j] = chars[j], chars[i] })
return filepath.Join(repoPath, housekeeping.GitlabWorktreePrefix, prefix+string(chars[:32]))
}