Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cli/cmd/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ func DeployCmd(ch *cmdutil.Helper) *cobra.Command {
deployCmd.Flags().StringVar(&opts.PrimaryBranch, "primary-branch", "", "Git branch to deploy from (default: the default Git branch)")
deployCmd.Flags().IntVar(&opts.Slots, "prod-slots", local.DefaultProdSlots(ch), "Slots to allocate for production deployments")
deployCmd.Flags().IntVar(&opts.DevSlots, "dev-slots", local.DefaultDevSlots(ch), "Slots to allocate for dev deployments")
deployCmd.Flags().BoolVar(&opts.PushEnv, "push-env", true, "Push local .env file to Rill Cloud")
if !ch.IsDev() {
if err := deployCmd.Flags().MarkHidden("prod-slots"); err != nil {
panic(err)
Expand All @@ -81,7 +80,8 @@ func DeployCmd(ch *cmdutil.Helper) *cobra.Command {
panic(err)
}
}

deployCmd.Flags().BoolVar(&opts.PushEnv, "push-env", true, "Push local .env file to Rill Cloud")
deployCmd.Flags().BoolVar(&opts.ForcePush, "force-push", false, "Force push local changes in case of Rill managed repos")
deployCmd.Flags().BoolVar(&opts.Managed, "managed", false, "Create project using rill managed repo")
deployCmd.Flags().BoolVar(&opts.ArchiveUpload, "archive", false, "Create project using tarballs(for testing only)")
err := deployCmd.Flags().MarkHidden("archive")
Expand All @@ -92,7 +92,7 @@ func DeployCmd(ch *cmdutil.Helper) *cobra.Command {
// subpath cannot be used with archive or managed deploys
deployCmd.MarkFlagsMutuallyExclusive("managed", "archive", "subpath")
deployCmd.MarkFlagsMutuallyExclusive("managed", "archive", "github")

deployCmd.MarkFlagsMutuallyExclusive("force-push", "github")
deployCmd.Flags().BoolVar(&opts.SkipDeploy, "skip-deploy", false, "Skip the runtime deployment step (for testing only)")
if !ch.IsDev() {
err = deployCmd.Flags().MarkHidden("skip-deploy")
Expand Down
20 changes: 15 additions & 5 deletions cli/cmd/project/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type DeployOpts struct {
Slots int
DevSlots int
PushEnv bool
ForcePush bool

ArchiveUpload bool
// Managed indicates if the project should be deployed using Rill Managed Git.
Expand Down Expand Up @@ -90,6 +91,9 @@ func (o *DeployOpts) ValidateAndApplyDefaults(ctx context.Context, ch *cmdutil.H
return err
}
if exists {
if p.ManagedGitId == "" && p.ArchiveAssetId == "" && o.ForcePush {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, Any specific reason for not allowing it for maanged repos ? If they are using a non-protected branch ideally we can do a force push, right ? If its a protected branch, it fails when doing the force push..

@k-anshul k-anshul Jun 30, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For self hosted repos a user can still force their changes but it is done using MergeWithOursStrategy to avoid force push and let user recover changes. This was intentionally done sometime back :

// Instead of a force push, we do a merge with favourLocal=true to ensure we don't lose history.
// This is not equivalent to a force push but is safer for users.

For managed repos most users have their changes backed in other git providers so it is okay to do a force push for them if they choose to do so.

return fmt.Errorf("project %q/%q is connected to a GitHub repository. Cannot use --force-push flag", ch.Org, o.Name)
}
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
Expand Down Expand Up @@ -128,9 +132,14 @@ 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)
return fmt.Errorf("aborting deploy")
if o.pushToProject.ManagedGitId == "" {
if 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.ForcePush {
return fmt.Errorf("project %s/%s is connected to a GitHub repository. Cannot use --force-push flag", 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)
Expand Down Expand Up @@ -288,7 +297,6 @@ func DeployCmd(ch *cmdutil.Helper) *cobra.Command {
deployCmd.Flags().StringVar(&opts.PrimaryBranch, "primary-branch", "", "Git branch to deploy from (default: the default Git branch)")
deployCmd.Flags().IntVar(&opts.Slots, "prod-slots", local.DefaultProdSlots(ch), "Slots to allocate for production deployments")
deployCmd.Flags().IntVar(&opts.DevSlots, "dev-slots", local.DefaultDevSlots(ch), "Slots to allocate for dev deployments")
deployCmd.Flags().BoolVar(&opts.PushEnv, "push-env", true, "Push local .env file to Rill Cloud")
if !ch.IsDev() {
if err := deployCmd.Flags().MarkHidden("prod-slots"); err != nil {
panic(err)
Expand All @@ -298,6 +306,8 @@ func DeployCmd(ch *cmdutil.Helper) *cobra.Command {
}
}

deployCmd.Flags().BoolVar(&opts.PushEnv, "push-env", true, "Push local .env file to Rill Cloud")
deployCmd.Flags().BoolVar(&opts.ForcePush, "force-push", false, "Force push local changes")
deployCmd.Flags().BoolVar(&opts.SkipDeploy, "skip-deploy", false, "Skip the runtime deployment step (for testing only)")
if !ch.IsDev() {
err := deployCmd.Flags().MarkHidden("skip-deploy")
Expand Down Expand Up @@ -464,7 +474,7 @@ func redeployProject(ctx context.Context, ch *cmdutil.Helper, opts *DeployOpts)
}
proj := opts.pushToProject
if proj.ManagedGitId != "" {
err := ch.GitHelper(ch.Org, proj.Name, opts.LocalProjectPath()).PushToManagedRepo(ctx)
err := ch.GitHelper(ch.Org, proj.Name, opts.LocalProjectPath()).PushToManagedRepo(ctx, opts.ForcePush)
if err != nil {
return err
}
Expand Down
5 changes: 4 additions & 1 deletion cli/pkg/cmdutil/githelper.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func (g *GitHelper) PushToNewManagedRepo(ctx context.Context, primaryBranch stri
return gitRepo, nil
}

func (g *GitHelper) PushToManagedRepo(ctx context.Context) error {
func (g *GitHelper) PushToManagedRepo(ctx context.Context, forcePush bool) error {
gitConfig, err := g.GitConfig(ctx)
if err != nil {
return err
Expand All @@ -127,6 +127,9 @@ func (g *GitHelper) PushToManagedRepo(ctx context.Context) error {
if err != nil {
return err
}
if forcePush {
return gitutil.CommitAndForcePush(ctx, g.localPath, gitConfig, "", author)
}
err = g.h.CommitAndSafePush(ctx, g.localPath, gitConfig, "", author, "1")
if err != nil {
return err
Expand Down
2 changes: 1 addition & 1 deletion cli/pkg/local/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ func (s *Server) RedeployProject(ctx context.Context, r *connect.Request[localv1
} else if r.Msg.Reupload {
if projResp.Project.ManagedGitId != "" {
// If rill-managed project then push to the repo based on org/project passed in.
err = s.app.ch.GitHelper(projResp.Project.OrgName, projResp.Project.Name, s.app.ProjectPath).PushToManagedRepo(ctx)
err = s.app.ch.GitHelper(projResp.Project.OrgName, projResp.Project.Name, s.app.ProjectPath).PushToManagedRepo(ctx, false)
if err != nil {
return nil, err
}
Expand Down
1 change: 1 addition & 0 deletions docs/docs/reference/cli/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ rill deploy [<path>] [flags]
--provisioner string Project provisioner
--primary-branch string Git branch to deploy from (default: the default Git branch)
--push-env Push local .env file to Rill Cloud (default true)
--force-push Force push local changes in case of Rill managed repos
--managed Create project using rill managed repo
--github Use github repo to create the project
```
Expand Down
1 change: 1 addition & 0 deletions docs/docs/reference/cli/project/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ rill project deploy [<path>] [flags]
--provisioner string Project provisioner
--primary-branch string Git branch to deploy from (default: the default Git branch)
--push-env Push local .env file to Rill Cloud (default true)
--force-push Force push local changes
```

### Global flags
Expand Down
37 changes: 37 additions & 0 deletions runtime/pkg/gitutil/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,43 @@ func CommitAndPush(ctx context.Context, path string, config *Config, commitMsg s
return Push(ctx, path, remote, config.DefaultBranch)
}

// CommitAndForcePush is similar to CommitAndPush but force pushes the local changes to the remote.
// Unlike CommitAndPush, the current local branch need not match config.DefaultBranch, and HEAD may
// be detached.
func CommitAndForcePush(ctx context.Context, path string, config *Config, commitMsg string, author Signature) error {
if config.Subpath != "" {
// this is ensured upstream but just to be safe
return fmt.Errorf("force push does not support subpath, please commit and push from the root of the repository")
}
err := EnsureInit(ctx, path, config.DefaultBranch)
if err != nil {
return fmt.Errorf("failed to init git repo: %w", err)
}

if commitMsg == "" {
commitMsg = "Auto committed by Rill"
}
_, err = CommitAll(ctx, path, "", commitMsg, author)
if err != nil && !errors.Is(err, ErrEmptyCommit) {
return fmt.Errorf("failed to commit files to git: %w", err)
}

err = SetRemote(path, config)
if err != nil {
return err
}

if config.Username == "" {
return ForcePush(ctx, path, config.RemoteName(), config.DefaultBranch)
}

remote, err := config.FullyQualifiedRemote()
if err != nil {
return err
}
return ForcePush(ctx, path, remote, config.DefaultBranch)
Comment thread
k-anshul marked this conversation as resolved.
}

// IsCommitHash reports whether s is a full hex commit hash (SHA-1 or SHA-256).
// Use it to validate untrusted hashes before passing them as git CLI arguments: it rules out
// strings that git would interpret as flags or other revision syntax.
Expand Down
95 changes: 95 additions & 0 deletions runtime/pkg/gitutil/commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,101 @@ import (
"github.com/stretchr/testify/require"
)

func TestCommitAndForcePush(t *testing.T) {
ctx := context.Background()
author := Signature{Name: "Rill", Email: "noreply@rilldata.com"}

t.Run("succeeds on matching branch with uncommitted changes", func(t *testing.T) {
local, remote := setupRepoWithRemote(t)
branch := getCurrentBranch(t, local)

require.NoError(t, os.WriteFile(filepath.Join(local, "new.txt"), []byte("hello"), 0644))

config := &Config{Remote: remote, DefaultBranch: branch}
require.NoError(t, CommitAndForcePush(ctx, local, config, "force push commit", author))

localTip, err := Hash(ctx, local, "HEAD")
require.NoError(t, err)
remoteTip, err := Hash(ctx, remote, "refs/heads/"+branch)
require.NoError(t, err)
require.Equal(t, localTip, remoteTip)
})

t.Run("pushes even when there is nothing to commit", func(t *testing.T) {
local, remote := setupRepoWithRemote(t)
branch := getCurrentBranch(t, local)

// create a local-only commit with nothing left in the working tree
createCommit(t, local, "unpushed.txt", "content", "local-only commit")
localTip, err := Hash(ctx, local, "HEAD")
require.NoError(t, err)

config := &Config{Remote: remote, DefaultBranch: branch}
require.NoError(t, CommitAndForcePush(ctx, local, config, "", author))

remoteTip, err := Hash(ctx, remote, "refs/heads/"+branch)
require.NoError(t, err)
require.Equal(t, localTip, remoteTip, "local-only commit must reach the remote")
})

t.Run("succeeds from a detached HEAD", func(t *testing.T) {
local, remote := setupRepoWithRemote(t)
branch := getCurrentBranch(t, local)

require.NoError(t, execGit(local, "checkout", "--detach"))
require.NoError(t, os.WriteFile(filepath.Join(local, "detached.txt"), []byte("from detached HEAD"), 0644))

config := &Config{Remote: remote, DefaultBranch: branch}
require.NoError(t, CommitAndForcePush(ctx, local, config, "detached commit", author))

localTip, err := Hash(ctx, local, "HEAD")
require.NoError(t, err)
remoteTip, err := Hash(ctx, remote, "refs/heads/"+branch)
require.NoError(t, err)
require.Equal(t, localTip, remoteTip, "force push from detached HEAD must update the remote branch")
})

t.Run("succeeds from a different local branch", func(t *testing.T) {
local, remote := setupRepoWithRemote(t)
defaultBranch := getCurrentBranch(t, local)

require.NoError(t, execGit(local, "checkout", "-b", "feature"))
require.NoError(t, os.WriteFile(filepath.Join(local, "feature.txt"), []byte("feature work"), 0644))

config := &Config{Remote: remote, DefaultBranch: defaultBranch}
require.NoError(t, CommitAndForcePush(ctx, local, config, "feature commit", author))

localTip, err := Hash(ctx, local, "HEAD")
require.NoError(t, err)
remoteTip, err := Hash(ctx, remote, "refs/heads/"+defaultBranch)
require.NoError(t, err)
require.Equal(t, localTip, remoteTip, "remote default branch must be overwritten with the local feature branch tip")
})

t.Run("overwrites divergent remote history", func(t *testing.T) {
local, remote := setupRepoWithRemote(t)
branch := getCurrentBranch(t, local)

// advance the remote with a commit that local doesn't have
createRemoteCommit(t, remote, "remote-only.txt", "remote content", "remote-only commit")

// advance local with a different commit, creating a true divergence from the initial tip
createCommit(t, local, "local-only.txt", "local content", "local-only commit")

// add uncommitted changes so CommitAll also has work to do
require.NoError(t, os.WriteFile(filepath.Join(local, "extra.txt"), []byte("extra"), 0644))

config := &Config{Remote: remote, DefaultBranch: branch}
require.NoError(t, CommitAndForcePush(ctx, local, config, "force over divergence", author))

localTip, err := Hash(ctx, local, "HEAD")
require.NoError(t, err)
remoteTip, err := Hash(ctx, remote, "refs/heads/"+branch)
require.NoError(t, err)
require.Equal(t, localTip, remoteTip, "force push must overwrite the divergent remote history")
})
}

func TestCurrentBranch(t *testing.T) {
ctx := context.Background()

Expand Down
8 changes: 8 additions & 0 deletions runtime/pkg/gitutil/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@ func Push(ctx context.Context, path, remote, refspec string) error {
return err
}

// ForcePush force-pushes HEAD to branch on remote, overwriting the remote ref unconditionally.
// It uses the refspec HEAD:<branch> so it works even when the local HEAD is detached or points
// to a different branch. remote may be a remote name or a URL with embedded credentials.
func ForcePush(ctx context.Context, path, remote, branch string) error {
_, err := Run(ctx, path, "push", "--force", remote, "HEAD:"+branch)
return err
}

// UpstreamMerge merges the remote tracking branch `<remoteName>/<branch>` into the current branch.
// If favourLocal is true, merge conflicts are resolved in favour of local changes.
func UpstreamMerge(ctx context.Context, path, remoteName, branch string, favourLocal bool) error {
Expand Down
Loading