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  }