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 }