diff --git a/cli/cmd/deploy/deploy.go b/cli/cmd/deploy/deploy.go index 0fa74e9ba26a..81ed7bf65891 100644 --- a/cli/cmd/deploy/deploy.go +++ b/cli/cmd/deploy/deploy.go @@ -5,7 +5,9 @@ import ( "github.com/rilldata/rill/cli/cmd/auth" "github.com/rilldata/rill/cli/cmd/project" + "github.com/rilldata/rill/cli/cmd/project/deployment" "github.com/rilldata/rill/cli/pkg/cmdutil" + "github.com/rilldata/rill/cli/pkg/gitutil" "github.com/rilldata/rill/cli/pkg/local" "github.com/spf13/cobra" ) @@ -35,6 +37,14 @@ func DeployCmd(ch *cmdutil.Helper) *cobra.Command { return err } + if opts.CreateDeployment { + currentBranch, err := gitutil.CurrentBranch(opts.GitPath) + if err != nil { + return fmt.Errorf("failed to get current git branch: %w", err) + } + return deployment.CreateDeployment(cmd.Context(), ch, opts.PushToProject.Name, currentBranch, "", false) + } + if !opts.Managed && !opts.ArchiveUpload && !opts.Github { if !ch.Interactive { return fmt.Errorf("must specify --managed or --github in non-interactive mode") diff --git a/cli/cmd/project/connect_github.go b/cli/cmd/project/connect_github.go index 468007b5df53..f278582066da 100644 --- a/cli/cmd/project/connect_github.go +++ b/cli/cmd/project/connect_github.go @@ -90,7 +90,7 @@ func ConnectGithubFlow(ctx context.Context, ch *cmdutil.Helper, opts *DeployOpts localGitPath := opts.GitPath localProjectPath := opts.LocalProjectPath() - if opts.pushToProject != nil { + if opts.PushToProject != nil { return redeployProject(ctx, ch, opts) } diff --git a/cli/cmd/project/deploy.go b/cli/cmd/project/deploy.go index 5ad3f6468b38..c5d3d6d75912 100644 --- a/cli/cmd/project/deploy.go +++ b/cli/cmd/project/deploy.go @@ -48,10 +48,12 @@ type DeployOpts struct { // SkipDeploy skips the runtime deployment step. Used for testing. SkipDeploy bool + // CreateDeployment is set if a new deployment should be created. Set internally. + CreateDeployment bool + // PushToProject is set if the deploy should push current changes to this existing project. Set internally. + PushToProject *adminv1.Project // remoteURL is the git remote url of the repository if detected. Set internally. remoteURL string - // pushToProject is set if the deploy should push current changes to this existing project. Set internally. - pushToProject *adminv1.Project } func (o *DeployOpts) LocalProjectPath() string { @@ -90,15 +92,13 @@ func (o *DeployOpts) ValidateAndApplyDefaults(ctx context.Context, ch *cmdutil.H return err } if exists { - if ch.Interactive { - if err := cmdutil.ConfirmPrompt(fmt.Sprintf("Project with name %q already exists. Do you want to push current changes to the existing project?", o.Name), true); err != nil { - return err - } + o.PushToProject = p + if err := o.shouldCreateDeployment(ch); err != nil { + return err } - o.pushToProject = p - o.Managed = o.pushToProject.ManagedGitId != "" - o.Github = o.pushToProject.ManagedGitId == "" && o.pushToProject.GitRemote != "" - o.ArchiveUpload = o.pushToProject.ArchiveAssetId != "" + o.Managed = o.PushToProject.ManagedGitId != "" + o.Github = o.PushToProject.ManagedGitId == "" && o.PushToProject.GitRemote != "" + o.ArchiveUpload = o.PushToProject.ArchiveAssetId != "" return nil } } @@ -127,35 +127,31 @@ func (o *DeployOpts) ValidateAndApplyDefaults(ctx context.Context, ch *cmdutil.H } // if there is a project already connected to this repo+subpath offer to push changes to it - if o.pushToProject != nil { - if o.pushToProject.ManagedGitId == "" && o.Managed { - ch.PrintfError("Project %s/%s is already connected to this GitHub repository. Cannot use --managed flag.\n", o.pushToProject.OrgName, o.pushToProject.Name) + if o.PushToProject != nil { + if o.PushToProject.ManagedGitId == "" && o.Managed { + ch.PrintfError("Project %s/%s is already connected to this GitHub repository. Cannot use --managed flag.\n", o.PushToProject.OrgName, o.PushToProject.Name) return fmt.Errorf("aborting deploy") } - if o.pushToProject.ManagedGitId != "" && o.Github { - ch.Printf("Found another rill managed project %s/%s connected to this folder\n", o.pushToProject.OrgName, o.pushToProject.Name) + if o.PushToProject.ManagedGitId != "" && o.Github { + ch.Printf("Found another rill managed project %s/%s connected to this folder\n", o.PushToProject.OrgName, o.PushToProject.Name) ch.PrintfBold("Run `rill project edit --remote-url ` to tranfer the project to GitHub.\n") return fmt.Errorf("aborting deploy") } - if o.pushToProject.OrgName != ch.Org { - ch.PrintfError("A project in another org deploys from this repository. Please switch to org %q to push changes to the project %q.\n", o.pushToProject.OrgName, o.pushToProject.Name) + if o.PushToProject.OrgName != ch.Org { + ch.PrintfError("A project in another org deploys from this repository. Please switch to org %q to push changes to the project %q.\n", o.PushToProject.OrgName, o.PushToProject.Name) return fmt.Errorf("aborting deploy") } - if subpath != "" && o.pushToProject.Subpath != subpath { + if subpath != "" && o.PushToProject.Subpath != subpath { // just for verification confirm that subpath matches the one stored in project - return fmt.Errorf("current project subpath %q does not match the one stored in rill %q. Try doing deploy using rill cli from github repo root by passing explicit subpath using `rill deploy --subpath %s`", subpath, o.pushToProject.Subpath, o.pushToProject.Subpath) + return fmt.Errorf("current project subpath %q does not match the one stored in rill %q. Try doing deploy using rill cli from github repo root by passing explicit subpath using `rill deploy --subpath %s`", subpath, o.PushToProject.Subpath, o.PushToProject.Subpath) } // set flags based on existing project - o.Managed = o.pushToProject.ManagedGitId != "" - o.Github = o.pushToProject.ManagedGitId == "" && o.pushToProject.GitRemote != "" - o.ArchiveUpload = o.pushToProject.ArchiveAssetId != "" + o.Managed = o.PushToProject.ManagedGitId != "" + o.Github = o.PushToProject.ManagedGitId == "" && o.PushToProject.GitRemote != "" + o.ArchiveUpload = o.PushToProject.ArchiveAssetId != "" - ch.PrintfBold("\nFound existing project: ") - ch.Printf("%s/%s\n", o.pushToProject.OrgName, o.pushToProject.Name) - if !ch.Interactive { - return nil - } - return cmdutil.ConfirmPrompt("Do you want to push current changes to the existing project?", true) + // check if we need to create a new deployment or just push to existing one + return o.shouldCreateDeployment(ch) } if o.remoteURL == "" { @@ -234,11 +230,11 @@ func (o *DeployOpts) detectGitRemoteAndProject(ctx context.Context, ch *cmdutil. } for _, p := range resp.Projects { if p.ManagedGitId != "" { - o.pushToProject = p + o.PushToProject = p o.remoteURL = p.GitRemote return nil } - o.pushToProject = p + o.PushToProject = p o.remoteURL = p.GitRemote // do not return yet, there might be a managed project // this is not possible with new flow but keeping it for consistency @@ -256,6 +252,32 @@ func (o *DeployOpts) detectGitRemoteAndProject(ctx context.Context, ch *cmdutil. return nil } +func (o *DeployOpts) shouldCreateDeployment(ch *cmdutil.Helper) error { + if o.PushToProject.ManagedGitId != "" || o.PushToProject.ArchiveAssetId != "" { + // for managed git and archive upload we allow pushing from any branch so we should not create a new deployment + return nil + } + currentBranch, err := gitutil.CurrentBranch(o.GitPath) + if err != nil { + return err + } + redeploy := currentBranch == o.PushToProject.PrimaryBranch + if ch.Interactive { + if redeploy { + if err := cmdutil.ConfirmPrompt(fmt.Sprintf("Found existing project: %q/%q. Do you want to push current changes to this project?\n", o.PushToProject.OrgName, o.PushToProject.Name), true); err != nil { + return err + } + } else { + ch.PrintfWarn("Found existing project: %q/%q. But current branch %q does not match the primary branch %q of the project.\n", o.PushToProject.OrgName, o.PushToProject.Name, currentBranch, o.PushToProject.PrimaryBranch) + if err := cmdutil.ConfirmPrompt("Do you want to create a new deployment instead?", true); err != nil { + return err + } + } + } + o.CreateDeployment = !redeploy + return nil +} + func DeployCmd(ch *cmdutil.Helper) *cobra.Command { opts := &DeployOpts{ ProdVersion: "latest", @@ -374,7 +396,7 @@ func DeployWithUploadFlow(ctx context.Context, ch *cmdutil.Helper, opts *DeployO return fmt.Errorf("failed to set up .gitignore: %w", err) } - if opts.pushToProject != nil { + if opts.PushToProject != nil { return redeployProject(ctx, ch, opts) } @@ -462,7 +484,7 @@ func redeployProject(ctx context.Context, ch *cmdutil.Helper, opts *DeployOpts) if err != nil { return err } - proj := opts.pushToProject + proj := opts.PushToProject if proj.ManagedGitId != "" { err := ch.GitHelper(ch.Org, proj.Name, opts.LocalProjectPath()).PushToManagedRepo(ctx) if err != nil { @@ -479,8 +501,8 @@ func redeployProject(ctx context.Context, ch *cmdutil.Helper, opts *DeployOpts) return fmt.Errorf("current project subpath %q does not match the one stored in rill %q. Run rill cli from github repo root and pass explicit subpath using `rill deploy --subpath %s`", subpath, proj.Subpath, proj.Subpath) } config := &gitutil.Config{ - Remote: opts.pushToProject.GitRemote, - DefaultBranch: opts.pushToProject.PrimaryBranch, + Remote: opts.PushToProject.GitRemote, + DefaultBranch: opts.PushToProject.PrimaryBranch, Subpath: subpath, } author, err := ch.GitSignature(ctx, repoRoot) diff --git a/cli/cmd/project/deployment/create.go b/cli/cmd/project/deployment/create.go index ee4d85dca9ee..05d58894dc53 100644 --- a/cli/cmd/project/deployment/create.go +++ b/cli/cmd/project/deployment/create.go @@ -27,11 +27,7 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { branch = args[1] } - client, err := ch.Client() - if err != nil { - return err - } - + var err error if project == "" { project, err = ch.InferProjectName(cmd.Context(), path, "use --project to specify the name") if err != nil { @@ -39,37 +35,64 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { } } - if environment == "prod" && editable { - return fmt.Errorf("prod deployments cannot be editable") - } + return CreateDeployment(cmd.Context(), ch, project, branch, environment, editable) + }, + } - ch.PrintfBold("Creating %q deployment for branch %q...\n", environment, branch) + createCmd.Flags().StringVar(&project, "project", "", "Project name") + createCmd.Flags().StringVar(&path, "path", ".", "Project directory") + createCmd.Flags().StringVar(&environment, "environment", "dev", "Optional environment to create for (options: dev, prod)") + createCmd.Flags().BoolVar(&editable, "editable", false, "Make the deployment editable (changes are persisted back to git repo)") - resp, err := client.CreateDeployment(cmd.Context(), &adminv1.CreateDeploymentRequest{ - Org: ch.Org, - Project: project, - Environment: environment, - Branch: branch, - Editable: editable, - }) + return createCmd +} + +// CreateDeployment creates a deployment for the specified branch in specified environment. +// If env is not set then it will prompt user to select environment (default: dev). +func CreateDeployment(ctx context.Context, ch *cmdutil.Helper, project, branch, env string, editable bool) error { + if env == "prod" && editable { + return fmt.Errorf("prod deployments cannot be editable") + } + var err error + if ch.Interactive && env == "" { // not set by caller + env, err = cmdutil.SelectPrompt("Environment to create deployment for?", []string{"dev", "prod"}, "dev") + if err != nil { + return err + } + if env != "prod" { + editable, err = cmdutil.YesNoPrompt("Do you want an editable deployment", false) if err != nil { return err } + } + } + if env == "" { // still unset - default to dev + env = "dev" + } - ch.Printf("Deployment created with branch %q\n", resp.Deployment.Branch) - ch.Printf("Provisioning runtime (this may take a moment)") + client, err := ch.Client() + if err != nil { + return err + } - // Poll for deployment status and print result - return pollDeploymentStatus(cmd.Context(), client, ch, resp.Deployment.Id, project, branch, environment) - }, + resp, err := client.CreateDeployment(ctx, &adminv1.CreateDeploymentRequest{ + Org: ch.Org, + Project: project, + Environment: env, + Branch: branch, + Editable: editable, + }) + if err != nil { + return err } - createCmd.Flags().StringVar(&project, "project", "", "Project name") - createCmd.Flags().StringVar(&path, "path", ".", "Project directory") - createCmd.Flags().StringVar(&environment, "environment", "dev", "Optional environment to create for (options: dev, prod)") - createCmd.Flags().BoolVar(&editable, "editable", false, "Make the deployment editable (changes are persisted back to git repo)") + if ch.Interactive { + ch.Printf("Deployment created with branch %q\n", resp.Deployment.Branch) + ch.Printf("Provisioning runtime (this may take a moment)") + } - return createCmd + // Poll for deployment status and print result + return pollDeploymentStatus(ctx, client, ch, resp.Deployment.Id, project, branch, env) } // pollDeploymentStatus polls the deployment status until it's either running or errored, then prints the result diff --git a/cli/pkg/cmdutil/helper.go b/cli/pkg/cmdutil/helper.go index 99b85d0b8061..be638a3b7d87 100644 --- a/cli/pkg/cmdutil/helper.go +++ b/cli/pkg/cmdutil/helper.go @@ -25,6 +25,7 @@ import ( runtimeclient "github.com/rilldata/rill/runtime/client" "github.com/rilldata/rill/runtime/pkg/activity" "github.com/rilldata/rill/runtime/pkg/fileutil" + rtgitutil "github.com/rilldata/rill/runtime/pkg/gitutil" "go.uber.org/zap" "golang.org/x/sync/errgroup" "golang.org/x/term" @@ -603,11 +604,11 @@ func (h *Helper) CommitAndSafePush(ctx context.Context, root string, config *git } // 2. Check status of the subpath - status, err := gitutil.RunGitStatus(root, config.Subpath, config.RemoteName(), "") + status, err := gitutil.RunGitStatus(root, config.Subpath, config.RemoteName(), fmt.Sprintf("%s/%s", config.RemoteName(), config.DefaultBranch)) if err != nil { return fmt.Errorf("failed to get git status: %w", err) } - if status.Branch != config.DefaultBranch { + if status.Branch != config.DefaultBranch && !config.ManagedRepo { // ensured upstream but just in case return fmt.Errorf("current branch %q does not match expected branch %q", status.Branch, config.DefaultBranch) } @@ -632,10 +633,13 @@ func (h *Helper) CommitAndSafePush(ctx context.Context, root string, config *git // The push can still fail if there were new remote commits since the fetch. But that's okay, the user can just retry. switch choice { case "1": - err := gitutil.RunUpstreamMerge(ctx, config.RemoteName(), root, status.Branch, false) + merged, err := rtgitutil.MergeWithBailOnConflict(root, fmt.Sprintf("%s/%s", config.RemoteName(), config.DefaultBranch)) if err != nil { return fmt.Errorf("local is behind remote and failed to sync with remote: %w", err) } + if !merged { + return fmt.Errorf("local is behind remote and has conflicts that must be resolved manually") + } return gitutil.CommitAndPush(ctx, root, config, commitMsg, author) case "2": // Instead of a force push, we do a merge with favourLocal=true to ensure we don't lose history. @@ -646,7 +650,7 @@ func (h *Helper) CommitAndSafePush(ctx context.Context, root string, config *git // monorepo setups are advanced use cases and we can require users to manually resolve remote changes return fmt.Errorf("cannot overwrite remote changes in a monorepo setup. Merge remote changes manually") } - err := gitutil.RunUpstreamMerge(ctx, config.RemoteName(), root, status.Branch, true) + err := rtgitutil.MergeWithStrategy(root, fmt.Sprintf("%s/%s", config.RemoteName(), config.DefaultBranch), "ours") if err != nil { return fmt.Errorf("local is behind remote and failed to sync with remote: %w", err) } diff --git a/cli/pkg/gitutil/gitcmdwrapper.go b/cli/pkg/gitutil/gitcmdwrapper.go index a112a3bbdf43..ffbc1c23b6ce 100644 --- a/cli/pkg/gitutil/gitcmdwrapper.go +++ b/cli/pkg/gitutil/gitcmdwrapper.go @@ -208,6 +208,18 @@ func InferGitRepoRoot(path string) (string, error) { return filepath.Join(path, strings.TrimSpace(string(data))), nil } +// CurrentBranch returns the current branch of the git repository at the specified path. +// Returns empty string and no error if in detached head state. +// Callers should ensure that it is a valid git repository. +func CurrentBranch(path string) (string, error) { + cmd := exec.Command("git", "-C", path, "branch", "--show-current") + data, err := cmd.CombinedOutput() + if err == nil { + return strings.TrimSpace(string(data)), nil + } + return "", fmt.Errorf("failed to determine current branch: %s: %w", string(data), err) +} + func isGitIgnored(repoRoot, subpath string) (bool, error) { cmd := exec.Command("git", "-C", repoRoot, "check-ignore", subpath) err := cmd.Run() diff --git a/cli/pkg/gitutil/gitutil.go b/cli/pkg/gitutil/gitutil.go index b34ba0f3f920..033e117b5b63 100644 --- a/cli/pkg/gitutil/gitutil.go +++ b/cli/pkg/gitutil/gitutil.go @@ -42,16 +42,17 @@ func (g *Config) FullyQualifiedRemote() (string, error) { if g.Remote == "" { return "", fmt.Errorf("remote is not set") } + if g.Username == "" { + return g.Remote, nil + } u, err := url.Parse(g.Remote) if err != nil { return "", err } - if g.Username != "" { - if g.Password != "" { - u.User = url.UserPassword(g.Username, g.Password) - } else { - u.User = url.User(g.Username) - } + if g.Password != "" { + u.User = url.UserPassword(g.Username, g.Password) + } else { + u.User = url.User(g.Username) } return u.String(), nil } @@ -170,12 +171,13 @@ func CommitAndPush(ctx context.Context, projectPath string, config *Config, comm // check current branch matches deployed branch headRef, err := repo.Head() + var branch string if err == nil { if !headRef.Name().IsBranch() { return fmt.Errorf("detached HEAD state detected. Checkout a branch") } - branch := headRef.Name().Short() - if headRef.Name().Short() != config.DefaultBranch { + branch = headRef.Name().Short() + if branch != config.DefaultBranch && !config.ManagedRepo { return fmt.Errorf("current branch %q does not match deployed branch %q", branch, config.DefaultBranch) } } else if !errors.Is(err, plumbing.ErrReferenceNotFound) { @@ -228,7 +230,15 @@ func CommitAndPush(ctx context.Context, projectPath string, config *Config, comm return fmt.Errorf("failed to parse remote URL: %w", err) } u.User = url.UserPassword(config.Username, config.Password) - return RunGitPush(ctx, projectPath, u.String(), config.DefaultBranch) + cmd := exec.CommandContext(ctx, "git", "-C", projectPath, "push", u.String(), fmt.Sprintf("HEAD:%s", config.DefaultBranch)) + if out, err := cmd.CombinedOutput(); err != nil { + var execErr *exec.ExitError + if errors.As(err, &execErr) { + return fmt.Errorf("git push failed: %s(%s)", string(out), string(execErr.Stderr)) + } + return fmt.Errorf("git push failed: %w", err) + } + return nil } func Clone(ctx context.Context, path string, c *Config) (*git.Repository, error) { diff --git a/runtime/pkg/gitutil/gitcmdwrapper.go b/runtime/pkg/gitutil/gitcmdwrapper.go index 74922b3ae18c..404ce5697e56 100644 --- a/runtime/pkg/gitutil/gitcmdwrapper.go +++ b/runtime/pkg/gitutil/gitcmdwrapper.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os/exec" + "strings" ) // MergeWithStrategy merge a branch into the current branch using the specified strategy. @@ -38,12 +39,16 @@ func MergeWithStrategy(path, branch, strategy string) error { func MergeWithBailOnConflict(path, branch string) (bool, error) { // First try the merge cmd := exec.Command("git", "-C", path, "merge", "--no-ff", branch) - _, err := cmd.Output() + out, err := cmd.CombinedOutput() if err == nil { // Merge succeeded return true, nil } + if strings.Contains(string(out), "Aborting") { + return false, nil // Merge failed due to conflicts, but git already aborted the merge, so we can just return. + } + // Merge failed, try to abort abortCmd := exec.Command("git", "-C", path, "merge", "--abort") abortErr := abortCmd.Run()