internal/gitaly/service/operations/squash.go (159 lines of code) (raw):

package operations import ( "context" "errors" "strings" "time" "gitlab.com/gitlab-org/gitaly/v16/internal/git" "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/gitaly/transaction" "gitlab.com/gitlab-org/gitaly/v16/internal/structerr" "gitlab.com/gitlab-org/gitaly/v16/internal/transaction/voting" "gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb" ) // UserSquash collapses a range of commits identified via a start and end revision into a single // commit whose single parent is the start revision. func (s *Server) UserSquash(ctx context.Context, req *gitalypb.UserSquashRequest) (*gitalypb.UserSquashResponse, error) { if err := validateUserSquashRequest(ctx, s.locator, req); err != nil { return nil, structerr.NewInvalidArgument("%w", err) } sha, err := s.userSquash(ctx, req) if err != nil { return nil, structerr.NewInternal("%w", err) } return &gitalypb.UserSquashResponse{SquashSha: sha}, nil } func validateUserSquashRequest(ctx context.Context, locator storage.Locator, req *gitalypb.UserSquashRequest) error { if err := locator.ValidateRepository(ctx, req.GetRepository()); err != nil { return err } if req.GetUser() == nil { return errors.New("empty User") } if len(req.GetUser().GetName()) == 0 { return errors.New("empty user name") } if len(req.GetUser().GetEmail()) == 0 { return errors.New("empty user email") } if req.GetStartSha() == "" { return errors.New("empty StartSha") } if req.GetEndSha() == "" { return errors.New("empty EndSha") } if len(req.GetCommitMessage()) == 0 { return errors.New("empty CommitMessage") } if req.GetAuthor() == nil { return errors.New("empty Author") } if len(req.GetAuthor().GetName()) == 0 { return errors.New("empty author name") } if len(req.GetAuthor().GetEmail()) == 0 { return errors.New("empty author email") } return nil } func (s *Server) userSquash(ctx context.Context, req *gitalypb.UserSquashRequest) (string, error) { // All new objects are staged into a quarantine directory first so that we can do // transactional voting before we commit data to disk. quarantineDir, quarantineRepo, err := s.quarantinedRepo(ctx, req.GetRepository()) if err != nil { return "", structerr.NewInternal("creating quarantine: %w", err) } // We need to retrieve the start commit such that we can create the new commit with // all parents of the start commit. startCommit, err := quarantineRepo.ResolveRevision(ctx, git.Revision(req.GetStartSha()+"^{commit}")) if err != nil { return "", structerr.NewInvalidArgument("resolving start revision: %w", err).WithDetail( &gitalypb.UserSquashError{ Error: &gitalypb.UserSquashError_ResolveRevision{ ResolveRevision: &gitalypb.ResolveRevisionError{ Revision: []byte(req.GetStartSha()), }, }, }, ) } // And we need to take the tree of the end commit. This tree already is the result endCommit, err := quarantineRepo.ResolveRevision(ctx, git.Revision(req.GetEndSha()+"^{commit}")) if err != nil { return "", structerr.NewInvalidArgument("resolving end revision: %w", err).WithDetail( &gitalypb.UserSquashError{ Error: &gitalypb.UserSquashError_ResolveRevision{ ResolveRevision: &gitalypb.ResolveRevisionError{ Revision: []byte(req.GetEndSha()), }, }, }, ) } committerSignature, err := git.SignatureFromRequest(req) if err != nil { return "", structerr.NewInvalidArgument("%w", err) } authorLocation, err := time.LoadLocation(req.GetAuthor().GetTimezone()) if err != nil { return "", structerr.NewInvalidArgument("%w", err) } authorSignature := git.NewSignature( string(req.GetAuthor().GetName()), string(req.GetAuthor().GetEmail()), committerSignature.When.In(authorLocation), ) message := string(req.GetCommitMessage()) // In previous implementation, we've used git commit-tree to create commit. // When message wasn't empty and didn't end in a new line, // git commit-tree would add a trailing new line to the commit message. // Let's keep that behaviour for compatibility. if len(message) > 0 && !strings.HasSuffix(message, "\n") { message += "\n" } commitID, err := s.merge( ctx, quarantineRepo, authorSignature, committerSignature, message, startCommit.String(), endCommit.String(), true, ) if err != nil { var mergeConflictErr *localrepo.MergeTreeConflictError if errors.As(err, &mergeConflictErr) { conflictingFiles := make([][]byte, 0, len(mergeConflictErr.ConflictingFileInfo)) for _, conflictingFileInfo := range mergeConflictErr.ConflictingFileInfo { conflictingFiles = append(conflictingFiles, []byte(conflictingFileInfo.FileName)) } return "", structerr.NewFailedPrecondition("squashing commits: %w", err).WithDetail( &gitalypb.UserSquashError{ // Note: this is actually a merge conflict, but we've kept // the old "rebase" name for compatibility reasons. Error: &gitalypb.UserSquashError_RebaseConflict{ RebaseConflict: &gitalypb.MergeConflictError{ ConflictingFiles: conflictingFiles, ConflictingCommitIds: []string{ startCommit.String(), endCommit.String(), }, }, }, }, ) } } // The RPC is badly designed in that it never updates any references, but only creates the // objects and writes them to disk. We still use a quarantine directory to stage the new // objects, vote on them and migrate them into the main directory if quorum was reached so // that we don't pollute the object directory with objects we don't want to have in the // first place. if err := transaction.VoteOnContext( ctx, s.txManager, voting.VoteFromData([]byte(commitID)), voting.Prepared, ); err != nil { return "", structerr.NewAborted("preparatory vote on squashed commit: %w", err) } if err := quarantineDir.Migrate(ctx); err != nil { return "", structerr.NewInternal("migrating quarantine directory: %w", err) } if err := transaction.VoteOnContext( ctx, s.txManager, voting.VoteFromData([]byte(commitID)), voting.Committed, ); err != nil { return "", structerr.NewAborted("committing vote on squashed commit: %w", err) } if tx := storage.ExtractTransaction(ctx); tx != nil { // Only referenced objects get committed as part of a transaction. Since this // RPC doesn't reference the object, we have to manually mark it to be included // in the final committed pack. tx.IncludeObject(git.ObjectID(commitID)) } return commitID, nil }