diff --git a/cmd/state/internal/cmdtree/pull.go b/cmd/state/internal/cmdtree/pull.go index edb9dc7df2..75584db654 100644 --- a/cmd/state/internal/cmdtree/pull.go +++ b/cmd/state/internal/cmdtree/pull.go @@ -10,14 +10,29 @@ import ( func newPullCommand(prime *primer.Values) *captain.Command { runner := pull.New(prime) + params := &pull.PullParams{} + return captain.NewCommand( "pull", locale.Tl("pull_title", "Pulling Remote Project"), locale.Tl("pull_description", "Pull in the latest version of your project from the ActiveState Platform"), prime.Output(), - []*captain.Flag{}, + []*captain.Flag{ + { + Name: "force", + Shorthand: "", + Description: locale.Tl("flag_state_pull_force_description", "Force pulling specified project even if it is unrelated to checked out one"), + Value: ¶ms.Force, + }, + { + Name: "set-project", + Shorthand: "", + Description: locale.Tl("flag_state_pull_set_project_description", "project even if it is unrelated to checked out one"), + Value: ¶ms.SetProject, + }, + }, []*captain.Argument{}, func(cmd *captain.Command, args []string) error { - return runner.Run() + return runner.Run(params) }).SetGroup(VCSGroup) } diff --git a/internal/runners/pull/pull.go b/internal/runners/pull/pull.go index 8bdb51b147..8e66d7aa95 100644 --- a/internal/runners/pull/pull.go +++ b/internal/runners/pull/pull.go @@ -6,27 +6,38 @@ import ( "github.com/ActiveState/cli/internal/config" "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/hail" "github.com/ActiveState/cli/internal/locale" "github.com/ActiveState/cli/internal/logging" "github.com/ActiveState/cli/internal/output" "github.com/ActiveState/cli/internal/primer" + "github.com/ActiveState/cli/internal/prompt" "github.com/ActiveState/cli/pkg/platform/model" "github.com/ActiveState/cli/pkg/project" + "github.com/go-openapi/strfmt" ) type Pull struct { + prompt prompt.Prompter project *project.Project out output.Outputer } +type PullParams struct { + Force bool + SetProject string +} + type primeable interface { + primer.Prompter primer.Projecter primer.Outputer } func New(prime primeable) *Pull { return &Pull{ + prime.Prompt(), prime.Project(), prime.Output(), } @@ -48,30 +59,48 @@ func (f *outputFormat) MarshalOutput(format output.Format) interface{} { return f } -func (p *Pull) Run() error { +func (p *Pull) Run(params *PullParams) error { if p.project == nil { return locale.NewInputError("err_no_project") } - if p.project.IsHeadless() { + if p.project.IsHeadless() && params.SetProject == "" { return locale.NewInputError("err_pull_headless", "You must first create a project. Please visit {{.V0}} to create your project.", p.project.URL()) } - // Retrieve latest commit ID on platform - latestID, err := model.LatestCommitID(p.project.Owner(), p.project.Name()) + // Determine the project to pull from + target, err := targetProject(p.project, params.SetProject) if err != nil { - return locale.WrapInputError(err, "err_pull_commit", "Could not retrieve the latest commit for your project.") + return errs.Wrap(err, "Unable to determine target project") + } + + if params.SetProject != "" { + related, err := areCommitsRelated(*target.CommitID, p.project.CommitUUID()) + if !related && !params.Force { + confirmed, err := p.prompt.Confirm(locale.T("confirm"), locale.Tl("confirm_unrelated_pull_set_project", "If you switch to {{.V0}}, you may lose changes to your project. Are you sure you want to do this?", target.String()), new(bool)) + if err != nil { + return locale.WrapError(err, "err_pull_confirm", "Failed to get user confirmation to update project") + } + if !confirmed { + return locale.NewInputError("err_pull_aborted", "Pull aborted by user") + } + } + + err = p.project.Source().SetNamespace(target.Owner, target.Project) + if err != nil { + return locale.WrapError(err, "err_pull_update_namespace", "Cannot update the namespace in your project file.") + } } // Update the commit ID in the activestate.yaml - if p.project.CommitID() != latestID.String() { - err := p.project.Source().SetCommit(latestID.String(), false) + if p.project.CommitID() != target.CommitID.String() { + err := p.project.Source().SetCommit(target.CommitID.String(), false) if err != nil { return locale.WrapError(err, "err_pull_update", "Cannot update the commit in your project file.") } p.out.Print(&outputFormat{ - locale.T("pull_updated"), + locale.Tr("pull_updated", target.String(), target.CommitID.String()), true, }) } else { @@ -96,3 +125,39 @@ func (p *Pull) Run() error { return nil } + +func targetProject(prj *project.Project, overwrite string) (*project.Namespaced, error) { + ns := prj.Namespace() + if overwrite != "" { + var err error + ns, err = project.ParseNamespace(overwrite) + if err != nil { + return nil, locale.WrapInputError(err, "pull_set_project_parse_err", "Failed to parse namespace {{.V0}}", overwrite) + } + } + + // Retrieve commit ID to set the project to (if unset) + if ns.CommitID == nil || *ns.CommitID == "" { + var err error + ns.CommitID, err = model.LatestCommitID(ns.Owner, ns.Project) + if err != nil { + return nil, locale.WrapError(err, "err_pull_commit", "Could not retrieve the latest commit for your project.") + } + } + + return ns, nil +} + +func areCommitsRelated(targetCommit strfmt.UUID, sourceCommmit strfmt.UUID) (bool, error) { + history, err := model.CommitHistoryFromID(targetCommit) + if err != nil { + return false, locale.WrapError(err, "err_pull_commit_history", "Could not fetch commit history for target project.") + } + + for _, c := range history { + if sourceCommmit.String() == c.CommitID.String() { + return true, nil + } + } + return false, nil +} diff --git a/locale/en-us.yaml b/locale/en-us.yaml index 84bccbdf3e..eac7707e67 100644 --- a/locale/en-us.yaml +++ b/locale/en-us.yaml @@ -639,7 +639,7 @@ secrets_description_unset: pull_not_updated: other: Your activestate.yaml is already up to date! pull_updated: - other: Your activestate.yaml has been updated to the latest version available. + other: Your project in the activestate.yaml has been updated to {{.V0}}@{{.V1}}. keypair_cmd_description: other: Manage Your Keypair keypair_generate_cmd_description: @@ -1630,4 +1630,4 @@ package_ingredient_alternatives_more: bundle_ingredient_alternatives_more: other: " - .. (to see more results run `state bundles search {{.V0}}`)" err_lock_version_invalid: - other: "The locked version '{{.V0}}' could not be verified, are you sure it's valid?" \ No newline at end of file + other: "The locked version '{{.V0}}' could not be verified, are you sure it's valid?" diff --git a/test/integration/activate_int_test.go b/test/integration/activate_int_test.go index 31f5e1808c..c4f9d58d6a 100644 --- a/test/integration/activate_int_test.go +++ b/test/integration/activate_int_test.go @@ -244,7 +244,7 @@ version: %s e2e.WithArgs("pull"), e2e.AppendEnv("ACTIVESTATE_CLI_DISABLE_RUNTIME=false"), ) - cp.Expect("Your activestate.yaml has been updated to the latest version available") + cp.Expect("activestate.yaml has been updated to") cp.ExpectExitCode(0) c2 := ts.Spawn("activate") @@ -363,7 +363,7 @@ version: %s // Pull to ensure we have an up to date config file cp := ts.Spawn("pull") - cp.Expect("Your activestate.yaml has been updated to the latest version available") + cp.Expect("activestate.yaml has been updated to") cp.ExpectExitCode(0) // Activate in the subdirectory @@ -398,7 +398,7 @@ project: "https://platform.activestate.com/ActiveState-CLI/Python3" // Pull to ensure we have an up to date config file cp := ts.Spawn("pull") - cp.Expect("Your activestate.yaml has been updated to the latest version available") + cp.Expect("activestate.yaml has been updated to") cp.ExpectExitCode(0) // Activate in the subdirectory diff --git a/test/integration/pull_int_test.go b/test/integration/pull_int_test.go index dcc3db5f32..3a48908dbc 100644 --- a/test/integration/pull_int_test.go +++ b/test/integration/pull_int_test.go @@ -29,6 +29,37 @@ func (suite *PullIntegrationTestSuite) TestPull() { cp.ExpectExitCode(0) } +func (suite *PullIntegrationTestSuite) TestPullSetProject() { + suite.OnlyRunForTags(tagsuite.Pull) + ts := e2e.New(suite.T(), false) + defer ts.Close() + + ts.PrepareActiveStateYAML(`project: https://platform.activestate.com/ActiveState-CLI/small-python?commitID=9733d11a-dfb3-41de-a37a-843b7c421db4`) + + // update to related project + cp := ts.Spawn("pull", "--set-project", "ActiveState-CLI/small-python-fork") + cp.Expect("activestate.yaml has been updated") + cp.ExpectExitCode(0) +} + +func (suite *PullIntegrationTestSuite) TestPullSetProjectUnrelated() { + suite.OnlyRunForTags(tagsuite.Pull) + ts := e2e.New(suite.T(), false) + defer ts.Close() + + ts.PrepareActiveStateYAML(`project: "https://platform.activestate.com/ActiveState-CLI/small-python?commitID=9733d11a-dfb3-41de-a37a-843b7c421db4"`) + + cp := ts.Spawn("pull", "--set-project", "ActiveState-CLI/Python3") + cp.ExpectLongString("you may lose changes to your project") + cp.SendLine("n") + cp.Expect("Pull aborted by user") + cp.ExpectNotExitCode(0) + + cp = ts.Spawn("pull", "--force", "--set-project", "ActiveState-CLI/Python3") + cp.Expect("activestate.yaml has been updated") + cp.ExpectExitCode(0) +} + func TestPullIntegrationTestSuite(t *testing.T) { suite.Run(t, new(PullIntegrationTestSuite)) }