github.com/dolthub/dolt/go@v0.40.5-0.20240520175717-68db7794bea6/libraries/doltcore/rebase/rebase.go (about)

     1  // Copyright 2023 Dolthub, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package rebase
    16  
    17  import (
    18  	"fmt"
    19  	"io"
    20  
    21  	"github.com/dolthub/go-mysql-server/sql"
    22  	"github.com/shopspring/decimal"
    23  
    24  	"github.com/dolthub/dolt/go/libraries/doltcore/doltdb"
    25  	"github.com/dolthub/dolt/go/libraries/doltcore/env/actions/commitwalk"
    26  	"github.com/dolthub/dolt/go/libraries/doltcore/sqle/dsess"
    27  	"github.com/dolthub/dolt/go/store/hash"
    28  )
    29  
    30  const (
    31  	RebaseActionPick   = "pick"
    32  	RebaseActionSquash = "squash"
    33  	RebaseActionFixup  = "fixup"
    34  	RebaseActionDrop   = "drop"
    35  	RebaseActionReword = "reword"
    36  )
    37  
    38  // ErrInvalidRebasePlanSquashFixupWithoutPick is returned when a rebase plan attempts to squash or
    39  // fixup a commit without first picking or rewording a commit.
    40  var ErrInvalidRebasePlanSquashFixupWithoutPick = fmt.Errorf("invalid rebase plan: squash and fixup actions must appear after a pick or reword action")
    41  
    42  // RebasePlanDatabase is a database that can save and load a rebase plan.
    43  type RebasePlanDatabase interface {
    44  	// SaveRebasePlan saves the given rebase plan to the database.
    45  	SaveRebasePlan(ctx *sql.Context, plan *RebasePlan) error
    46  	// LoadRebasePlan loads the rebase plan from the database.
    47  	LoadRebasePlan(ctx *sql.Context) (*RebasePlan, error)
    48  }
    49  
    50  // RebasePlan describes the plan for a rebase operation, where commits are reordered,
    51  // or adjusted, and then replayed on top of a base commit to form a new commit history.
    52  type RebasePlan struct {
    53  	Steps []RebasePlanStep
    54  }
    55  
    56  // RebasePlanStep describes a single step in a rebase plan, such as dropping a
    57  // commit, squashing a commit into the previous commit, etc.
    58  type RebasePlanStep struct {
    59  	RebaseOrder decimal.Decimal
    60  	Action      string
    61  	CommitHash  string
    62  	CommitMsg   string
    63  }
    64  
    65  // CreateDefaultRebasePlan creates and returns the default rebase plan for the commits between
    66  // |startCommit| and |upstreamCommit|, equivalent to the log of startCommit..upstreamCommit. The
    67  // default plan includes each of those commits, in the same order they were originally applied, and
    68  // each step in the plan will have the default, pick, action. If the plan cannot be generated for
    69  // any reason, such as disconnected or invalid commits specified, then an error is returned.
    70  func CreateDefaultRebasePlan(ctx *sql.Context, startCommit, upstreamCommit *doltdb.Commit) (*RebasePlan, error) {
    71  	commits, err := findRebaseCommits(ctx, startCommit, upstreamCommit)
    72  	if err != nil {
    73  		return nil, err
    74  	}
    75  
    76  	if len(commits) == 0 {
    77  		return nil, fmt.Errorf("didn't identify any commits!")
    78  	}
    79  
    80  	plan := RebasePlan{}
    81  	for idx := len(commits) - 1; idx >= 0; idx-- {
    82  		commit := commits[idx]
    83  		hash, err := commit.HashOf()
    84  		if err != nil {
    85  			return nil, err
    86  		}
    87  		meta, err := commit.GetCommitMeta(ctx)
    88  		if err != nil {
    89  			return nil, err
    90  		}
    91  
    92  		plan.Steps = append(plan.Steps, RebasePlanStep{
    93  			RebaseOrder: decimal.NewFromFloat32(float32(len(commits) - idx)),
    94  			Action:      RebaseActionPick,
    95  			CommitHash:  hash.String(),
    96  			CommitMsg:   meta.Description,
    97  		})
    98  	}
    99  
   100  	return &plan, nil
   101  }
   102  
   103  // ValidateRebasePlan returns a validation error for invalid states in a rebase plan, such as
   104  // squash or fixup actions appearing in the plan before a pick or reword action.
   105  func ValidateRebasePlan(ctx *sql.Context, plan *RebasePlan) error {
   106  	seenPick := false
   107  	seenReword := false
   108  	for i, step := range plan.Steps {
   109  		// As a sanity check, make sure the rebase order is ascending. This shouldn't EVER happen because the
   110  		// results are sorted from the database query, but double check while we're validating the plan.
   111  		if i > 0 && plan.Steps[i-1].RebaseOrder.GreaterThanOrEqual(step.RebaseOrder) {
   112  			return fmt.Errorf("invalid rebase plan: rebase order must be ascending")
   113  		}
   114  
   115  		switch step.Action {
   116  		case RebaseActionPick:
   117  			seenPick = true
   118  
   119  		case RebaseActionReword:
   120  			seenReword = true
   121  
   122  		case RebaseActionFixup, RebaseActionSquash:
   123  			if !seenPick && !seenReword {
   124  				return ErrInvalidRebasePlanSquashFixupWithoutPick
   125  			}
   126  		}
   127  
   128  		if err := validateCommit(ctx, step.CommitHash); err != nil {
   129  			return err
   130  		}
   131  	}
   132  
   133  	return nil
   134  }
   135  
   136  // validateCommit returns an error if the specified |commit| is not able to be resolved.
   137  func validateCommit(ctx *sql.Context, commit string) error {
   138  	doltSession := dsess.DSessFromSess(ctx.Session)
   139  
   140  	ddb, ok := doltSession.GetDoltDB(ctx, ctx.GetCurrentDatabase())
   141  	if !ok {
   142  		return fmt.Errorf("unable to load dolt db")
   143  	}
   144  
   145  	if !doltdb.IsValidCommitHash(commit) {
   146  		return fmt.Errorf("invalid commit hash: %s", commit)
   147  	}
   148  
   149  	commitSpec, err := doltdb.NewCommitSpec(commit)
   150  	if err != nil {
   151  		return err
   152  	}
   153  	_, err = ddb.Resolve(ctx, commitSpec, nil)
   154  	if err != nil {
   155  		return fmt.Errorf("unable to resolve commit hash %s: %w", commit, err)
   156  	}
   157  
   158  	return nil
   159  }
   160  
   161  // findRebaseCommits returns the commits that should be included in the default rebase plan when
   162  // rebasing |upstreamBranchCommit| onto the current branch (specified by commit |currentBranchCommit|).
   163  // This is defined as the log of |currentBranchCommit|..|upstreamBranchCommit|, or in other words, the
   164  // commits that are reachable from the current branch HEAD, but are NOT reachable from
   165  // |upstreamBranchCommit|. Additionally, any merge commits in that range are NOT included.
   166  func findRebaseCommits(ctx *sql.Context, currentBranchCommit, upstreamBranchCommit *doltdb.Commit) (commits []*doltdb.Commit, err error) {
   167  	doltSession := dsess.DSessFromSess(ctx.Session)
   168  
   169  	ddb, ok := doltSession.GetDoltDB(ctx, ctx.GetCurrentDatabase())
   170  	if !ok {
   171  		return nil, fmt.Errorf("unable to load dolt db")
   172  	}
   173  
   174  	currentBranchCommitHash, err := currentBranchCommit.HashOf()
   175  	if err != nil {
   176  		return
   177  	}
   178  
   179  	upstreamBranchCommitHash, err := upstreamBranchCommit.HashOf()
   180  	if err != nil {
   181  		return
   182  	}
   183  
   184  	// We use the dot-dot revision iterator because it gives us the behavior we want for rebase – it finds all
   185  	// commits reachable from |currentBranchCommit| but NOT reachable by |upstreamBranchCommit|.
   186  	commitItr, err := commitwalk.GetDotDotRevisionsIterator(ctx,
   187  		ddb, []hash.Hash{currentBranchCommitHash},
   188  		ddb, []hash.Hash{upstreamBranchCommitHash}, nil)
   189  	if err != nil {
   190  		return nil, err
   191  	}
   192  
   193  	// Drain the iterator into a slice so that we can easily reverse the order of the commits
   194  	// so that the oldest commit is first in the generated rebase plan.
   195  	for {
   196  		_, optCmt, err := commitItr.Next(ctx)
   197  		if err == io.EOF {
   198  			return commits, nil
   199  		} else if err != nil {
   200  			return nil, err
   201  		}
   202  
   203  		commit, ok := optCmt.ToCommit()
   204  		if !ok {
   205  			return nil, doltdb.ErrGhostCommitEncountered // Not sure if we can get this far. commit walk is going to be a bear.
   206  		}
   207  
   208  		// Don't include merge commits in the rebase plan
   209  		if commit.NumParents() == 1 {
   210  			commits = append(commits, commit)
   211  		}
   212  	}
   213  }