github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/runners/pull/pull.go (about) 1 package pull 2 3 import ( 4 "errors" 5 "path/filepath" 6 "strings" 7 "time" 8 9 "github.com/ActiveState/cli/internal/analytics" 10 anaConst "github.com/ActiveState/cli/internal/analytics/constants" 11 "github.com/ActiveState/cli/internal/analytics/dimensions" 12 "github.com/ActiveState/cli/internal/config" 13 "github.com/ActiveState/cli/internal/constants" 14 "github.com/ActiveState/cli/internal/errs" 15 "github.com/ActiveState/cli/internal/locale" 16 "github.com/ActiveState/cli/internal/logging" 17 "github.com/ActiveState/cli/internal/output" 18 "github.com/ActiveState/cli/internal/primer" 19 "github.com/ActiveState/cli/internal/prompt" 20 "github.com/ActiveState/cli/internal/rtutils/ptr" 21 buildscriptRunbits "github.com/ActiveState/cli/internal/runbits/buildscript" 22 "github.com/ActiveState/cli/internal/runbits/commit" 23 "github.com/ActiveState/cli/internal/runbits/rationalize" 24 "github.com/ActiveState/cli/internal/runbits/runtime" 25 "github.com/ActiveState/cli/pkg/localcommit" 26 "github.com/ActiveState/cli/pkg/platform/api/buildplanner/types" 27 "github.com/ActiveState/cli/pkg/platform/authentication" 28 "github.com/ActiveState/cli/pkg/platform/model" 29 "github.com/ActiveState/cli/pkg/platform/model/buildplanner" 30 "github.com/ActiveState/cli/pkg/platform/runtime/buildexpression/merge" 31 "github.com/ActiveState/cli/pkg/platform/runtime/buildscript" 32 "github.com/ActiveState/cli/pkg/platform/runtime/target" 33 "github.com/ActiveState/cli/pkg/project" 34 "github.com/go-openapi/strfmt" 35 ) 36 37 type Pull struct { 38 prompt prompt.Prompter 39 project *project.Project 40 auth *authentication.Auth 41 out output.Outputer 42 analytics analytics.Dispatcher 43 cfg *config.Instance 44 svcModel *model.SvcModel 45 } 46 47 type errNoCommonParent struct { 48 error 49 localCommitID strfmt.UUID 50 remoteCommitID strfmt.UUID 51 } 52 53 type PullParams struct { 54 Force bool 55 } 56 57 type primeable interface { 58 primer.Prompter 59 primer.Projecter 60 primer.Auther 61 primer.Outputer 62 primer.Analyticer 63 primer.Configurer 64 primer.SvcModeler 65 } 66 67 func New(prime primeable) *Pull { 68 return &Pull{ 69 prime.Prompt(), 70 prime.Project(), 71 prime.Auth(), 72 prime.Output(), 73 prime.Analytics(), 74 prime.Config(), 75 prime.SvcModel(), 76 } 77 } 78 79 type pullOutput struct { 80 Message string `locale:"message,Message" json:"message"` 81 Success bool `locale:"success,Success" json:"success"` 82 } 83 84 func (o *pullOutput) MarshalOutput(format output.Format) interface{} { 85 return o.Message 86 } 87 88 func (o *pullOutput) MarshalStructured(format output.Format) interface{} { 89 return o 90 } 91 92 func (p *Pull) Run(params *PullParams) (rerr error) { 93 defer rationalizeError(&rerr) 94 95 if p.project == nil { 96 return rationalize.ErrNoProject 97 } 98 p.out.Notice(locale.Tr("operating_message", p.project.NamespaceString(), p.project.Dir())) 99 100 if p.project.IsHeadless() { 101 return locale.NewInputError("err_pull_headless", "You must first create a project. Please visit {{.V0}} to create your project.", p.project.URL()) 102 } 103 104 if p.project.BranchName() == "" { 105 return locale.NewError("err_pull_branch", "Your [NOTICE]activestate.yaml[/RESET] project field does not contain a branch. Please ensure you are using the latest version of the State Tool by running '[ACTIONABLE]state update[/RESET]' and then trying again.") 106 } 107 108 // Determine the project to pull from 109 remoteProject, err := resolveRemoteProject(p.project) 110 if err != nil { 111 return errs.Wrap(err, "Unable to determine target project") 112 } 113 114 var localCommit *strfmt.UUID 115 localCommitID, err := localcommit.Get(p.project.Dir()) 116 if err != nil { 117 return errs.Wrap(err, "Unable to get local commit") 118 } 119 if localCommitID != "" { 120 localCommit = &localCommitID 121 } 122 123 remoteCommit := remoteProject.CommitID 124 resultingCommit := remoteCommit // resultingCommit is the commit we want to update the local project file with 125 126 if localCommit != nil { 127 commonParent, err := model.CommonParent(localCommit, remoteCommit, p.auth) 128 if err != nil { 129 return errs.Wrap(err, "Unable to determine common parent") 130 } 131 132 if commonParent == nil { 133 return &errNoCommonParent{ 134 errs.New("no common parent"), 135 *localCommit, 136 *remoteCommit, 137 } 138 } 139 140 // Attempt to fast-forward merge. This will succeed if the commits are 141 // compatible, meaning that we can simply update the local commit ID to 142 // the remoteCommit ID. The commitID returned from MergeCommit with this 143 // strategy should just be the remote commit ID. 144 // If this call fails then we will try a recursive merge. 145 strategy := types.MergeCommitStrategyFastForward 146 147 bp := buildplanner.NewBuildPlannerModel(p.auth) 148 params := &buildplanner.MergeCommitParams{ 149 Owner: remoteProject.Owner, 150 Project: remoteProject.Project, 151 TargetRef: localCommit.String(), 152 OtherRef: remoteCommit.String(), 153 Strategy: strategy, 154 } 155 156 resultCommit, mergeErr := bp.MergeCommit(params) 157 if mergeErr != nil { 158 logging.Debug("Merge with fast-forward failed with error: %s, trying recursive overwrite", mergeErr.Error()) 159 strategy = types.MergeCommitStrategyRecursiveKeepOnConflict 160 c, err := p.performMerge(*remoteCommit, *localCommit, remoteProject, p.project.BranchName(), strategy) 161 if err != nil { 162 p.notifyMergeStrategy(anaConst.LabelVcsConflictMergeStrategyFailed, *localCommit, remoteProject) 163 return errs.Wrap(err, "performing merge commit failed") 164 } 165 resultingCommit = &c 166 } else { 167 logging.Debug("Fast-forward merge succeeded, setting commit ID to %s", resultCommit.String()) 168 resultingCommit = &resultCommit 169 } 170 171 p.notifyMergeStrategy(string(strategy), *localCommit, remoteProject) 172 } 173 174 commitID, err := localcommit.Get(p.project.Dir()) 175 if err != nil { 176 return errs.Wrap(err, "Unable to get local commit") 177 } 178 179 if commitID != *resultingCommit { 180 err := localcommit.Set(p.project.Dir(), resultingCommit.String()) 181 if err != nil { 182 return errs.Wrap(err, "Unable to set local commit") 183 } 184 185 if p.cfg.GetBool(constants.OptinBuildscriptsConfig) { 186 err := p.mergeBuildScript(*remoteCommit, *localCommit) 187 if err != nil { 188 return errs.Wrap(err, "Could not merge local build script with remote changes") 189 } 190 } 191 192 p.out.Print(&pullOutput{ 193 locale.Tr("pull_updated", remoteProject.String(), resultingCommit.String()), 194 true, 195 }) 196 } else { 197 p.out.Print(&pullOutput{ 198 locale.Tl("pull_not_updated", "Your project is already up to date."), 199 false, 200 }) 201 } 202 203 _, err = runtime.SolveAndUpdate(p.auth, p.out, p.analytics, p.project, resultingCommit, target.TriggerPull, p.svcModel, p.cfg, runtime.OptOrderChanged) 204 if err != nil { 205 return locale.WrapError(err, "err_pull_refresh", "Could not refresh runtime after pull") 206 } 207 208 return nil 209 } 210 211 func (p *Pull) performMerge(remoteCommit, localCommit strfmt.UUID, namespace *project.Namespaced, branchName string, strategy types.MergeStrategy) (strfmt.UUID, error) { 212 p.out.Notice(output.Title(locale.Tl("pull_diverged", "Merging history"))) 213 p.out.Notice(locale.Tr( 214 "pull_diverged_message", 215 namespace.String(), branchName, localCommit.String(), remoteCommit.String()), 216 ) 217 218 bp := buildplanner.NewBuildPlannerModel(p.auth) 219 params := &buildplanner.MergeCommitParams{ 220 Owner: namespace.Owner, 221 Project: namespace.Project, 222 TargetRef: localCommit.String(), 223 OtherRef: remoteCommit.String(), 224 Strategy: strategy, 225 } 226 resultCommit, err := bp.MergeCommit(params) 227 if err != nil { 228 return "", locale.WrapError(err, "err_pull_merge_commit", "Could not create merge commit.") 229 } 230 231 cmit, err := model.GetCommit(resultCommit, p.auth) 232 if err != nil { 233 return "", locale.WrapError(err, "err_pull_getcommit", "Could not inspect resulting commit.") 234 } 235 if changes, _ := commit.FormatChanges(cmit); len(changes) > 0 { 236 p.out.Notice(locale.Tl( 237 "pull_diverged_changes", 238 "The following changes will be merged:\n{{.V0}}\n", strings.Join(changes, "\n")), 239 ) 240 } 241 242 return resultCommit, nil 243 } 244 245 // mergeBuildScript merges the local build script with the remote buildexpression (not script). 246 func (p *Pull) mergeBuildScript(remoteCommit, localCommit strfmt.UUID) error { 247 // Get the build script to merge. 248 scriptA, err := buildscript.ScriptFromProject(p.project) 249 if err != nil { 250 return errs.Wrap(err, "Could not get local build script") 251 } 252 253 // Get the local and remote build expressions to merge. 254 exprA := scriptA.Expr 255 bp := buildplanner.NewBuildPlannerModel(p.auth) 256 exprB, atTimeB, err := bp.GetBuildExpressionAndTime(remoteCommit.String()) 257 if err != nil { 258 return errs.Wrap(err, "Unable to get buildexpression and time for remote commit") 259 } 260 scriptB, err := buildscript.NewFromBuildExpression(atTimeB, exprB) 261 if err != nil { 262 return errs.Wrap(err, "Could not convert build expression to build script") 263 } 264 265 // Compute the merge strategy. 266 strategies, err := model.MergeCommit(remoteCommit, localCommit) 267 if err != nil { 268 switch { 269 case errors.Is(err, model.ErrMergeFastForward): 270 return buildscript.Update(p.project, atTimeB, exprB) 271 case !errors.Is(err, model.ErrMergeCommitInHistory): 272 return locale.WrapError(err, "err_mergecommit", "Could not detect if merge is necessary.") 273 } 274 } 275 276 // Attempt the merge. 277 mergedExpr, err := merge.Merge(exprA, exprB, strategies) 278 if err != nil { 279 err := buildscriptRunbits.GenerateAndWriteDiff(p.project, scriptA, scriptB) 280 if err != nil { 281 return locale.WrapError(err, "err_diff_build_script", "Unable to generate differences between local and remote build script") 282 } 283 return locale.NewInputError( 284 "err_build_script_merge", 285 "Unable to automatically merge build scripts. Please resolve conflicts manually in '{{.V0}}' and then run '[ACTIONABLE]state commit[/RESET]'", 286 filepath.Join(p.project.Dir(), constants.BuildScriptFileName)) 287 } 288 289 // For now, pick the later of the script AtTimes. 290 atTime := scriptA.AtTime 291 atTimeA := time.Time(*scriptA.AtTime) 292 if atTimeB := time.Time(*scriptB.AtTime); atTimeA.Before(atTimeB) { 293 atTime = scriptB.AtTime 294 } 295 296 // Write the merged build expression as a local build script. 297 return buildscript.Update(p.project, atTime, mergedExpr) 298 } 299 300 func resolveRemoteProject(prj *project.Project) (*project.Namespaced, error) { 301 ns := prj.Namespace() 302 var err error 303 ns.CommitID, err = model.BranchCommitID(ns.Owner, ns.Project, prj.BranchName()) 304 if err != nil { 305 return nil, locale.WrapError(err, "err_pull_commit_branch", "Could not retrieve the latest commit for your project and branch.") 306 } 307 308 return ns, nil 309 } 310 311 func (p *Pull) notifyMergeStrategy(strategy string, commitID strfmt.UUID, namespace *project.Namespaced) { 312 p.analytics.EventWithLabel(anaConst.CatInteractions, anaConst.ActVcsConflict, strategy, &dimensions.Values{ 313 CommitID: ptr.To(commitID.String()), 314 ProjectNameSpace: ptr.To(namespace.String()), 315 }) 316 }