Repository: supabase/cli
Pull request #2190 (2024-04-19 05:12:12)

feat(branch): add persistent flag for project ref
## What kind of change does this PR introduce?

feature

## What is the current behavior?

```
$ supabase --experimental branches get
Selected project: zekxecbchaedtrzpqysa

    Select a branch:

    1. fix-import [5f9b7ee4-5204-4cb5-94b5-3ab2bb8ed2f4]
  >  2. fly-branch [8ec676b6-3a8d-4375-b326-1322965ba211]


    ↑/k up • ↓/j down • / filter • q quit • ? more
```

## What is the new behavior?

Feel free to include screenshots if it includes visual changes.

## Additional context

Add any other context or screenshots.



Patch:
diff --git a/cmd/branches.go b/cmd/branches.go
index ea1039bf..a4cd651d 100644
--- a/cmd/branches.go
+++ b/cmd/branches.go
@@ -1,8 +1,12 @@
 package cmd
 
 import (
+	"context"
+	"fmt"
+	"os"
 	"sort"
 
+	"github.com/go-errors/errors"
 	"github.com/spf13/afero"
 	"github.com/spf13/cobra"
 	"github.com/supabase/cli/internal/branches/create"
@@ -11,7 +15,9 @@ import (
 	"github.com/supabase/cli/internal/branches/get"
 	"github.com/supabase/cli/internal/branches/list"
 	"github.com/supabase/cli/internal/branches/update"
+	"github.com/supabase/cli/internal/gen/keys"
 	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/internal/utils/flags"
 	"github.com/supabase/cli/pkg/api"
 )
 
@@ -44,18 +50,29 @@ var (
 		Use:   "list",
 		Short: "List all preview branches",
 		Long:  "List all preview branches of the linked project.",
+		Args:  cobra.NoArgs,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			return list.Run(cmd.Context(), afero.NewOsFs())
 		},
 	}
 
+	branchId string
+
 	branchGetCmd = &cobra.Command{
-		Use:   "get <branch-id>",
+		Use:   "get [branch-id]",
 		Short: "Retrieve details of a preview branch",
 		Long:  "Retrieve details of the specified preview branch.",
-		Args:  cobra.ExactArgs(1),
+		Args:  cobra.MaximumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			return get.Run(cmd.Context(), args[0])
+			ctx := cmd.Context()
+			if len(args) == 0 {
+				if err := promptBranchId(ctx, flags.ProjectRef); err != nil {
+					return err
+				}
+			} else {
+				branchId = args[0]
+			}
+			return get.Run(ctx, branchId)
 		},
 	}
 
@@ -64,7 +81,7 @@ var (
 	resetOnPush bool
 
 	branchUpdateCmd = &cobra.Command{
-		Use:   "update <branch-id>",
+		Use:   "update [branch-id]",
 		Short: "Update a preview branch",
 		Long:  "Update a preview branch by its ID.",
 		Args:  cobra.ExactArgs(1),
@@ -79,17 +96,33 @@ var (
 			if cmd.Flags().Changed("reset-on-push") {
 				body.ResetOnPush = &resetOnPush
 			}
-			return update.Run(cmd.Context(), args[0], body, afero.NewOsFs())
+			ctx := cmd.Context()
+			if len(args) == 0 {
+				if err := promptBranchId(ctx, flags.ProjectRef); err != nil {
+					return err
+				}
+			} else {
+				branchId = args[0]
+			}
+			return update.Run(cmd.Context(), branchId, body, afero.NewOsFs())
 		},
 	}
 
 	branchDeleteCmd = &cobra.Command{
-		Use:   "delete <branch-id>",
+		Use:   "delete [branch-id]",
 		Short: "Delete a preview branch",
 		Long:  "Delete a preview branch by its ID.",
-		Args:  cobra.ExactArgs(1),
+		Args:  cobra.MaximumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
-			return delete.Run(cmd.Context(), args[0])
+			ctx := cmd.Context()
+			if len(args) == 0 {
+				if err := promptBranchId(ctx, flags.ProjectRef); err != nil {
+					return err
+				}
+			} else {
+				branchId = args[0]
+			}
+			return delete.Run(ctx, branchId)
 		},
 	}
 
@@ -104,7 +137,8 @@ var (
 )
 
 func init() {
-	branchesCmd.AddCommand(branchCreateCmd)
+	branchFlags := branchesCmd.PersistentFlags()
+	branchFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.")
 	// Setup enum flags
 	i := 0
 	for k := range utils.FlyRegions {
@@ -114,6 +148,7 @@ func init() {
 	sort.Strings(branchRegion.Allowed)
 	createFlags := branchCreateCmd.Flags()
 	createFlags.Var(&branchRegion, "region", "Select a region to deploy the branch database.")
+	branchesCmd.AddCommand(branchCreateCmd)
 	branchesCmd.AddCommand(branchListCmd)
 	branchesCmd.AddCommand(branchGetCmd)
 	updateFlags := branchUpdateCmd.Flags()
@@ -125,3 +160,48 @@ func init() {
 	branchesCmd.AddCommand(branchDisableCmd)
 	rootCmd.AddCommand(branchesCmd)
 }
+
+func promptBranchId(ctx context.Context, ref string) error {
+	resp, err := utils.GetSupabase().GetBranchesWithResponse(ctx, ref)
+	if err != nil {
+		return errors.Errorf("failed to list preview branches: %w", err)
+	}
+	if resp.JSON200 == nil {
+		return errors.New("Unexpected error listing preview branches: " + string(resp.Body))
+	}
+	console := utils.NewConsole()
+	if !console.IsTTY {
+		// Fallback to current git branch on GHA
+		gitBranch := keys.GetGitBranch(afero.NewOsFs())
+		title := "Enter the name of your branch: "
+		if len(gitBranch) > 0 {
+			title = fmt.Sprintf("%-2s (or leave blank to use %s): ", title, utils.Aqua(gitBranch))
+		}
+		if name, err := console.PromptText(title); err != nil {
+			return err
+		} else if len(name) > 0 {
+			gitBranch = name
+		}
+		for _, branch := range *resp.JSON200 {
+			if branch.Name == gitBranch {
+				branchId = branch.Id
+				return nil
+			}
+		}
+		return errors.Errorf("Branch not found: %s", gitBranch)
+	}
+	items := make([]utils.PromptItem, len(*resp.JSON200))
+	for i, branch := range *resp.JSON200 {
+		items[i] = utils.PromptItem{
+			Summary: branch.Name,
+			Details: branch.Id,
+		}
+	}
+	title := "Select a branch:"
+	choice, err := utils.PromptChoice(ctx, title, items)
+	if err == nil {
+		branchId = choice.Details
+		fmt.Fprintln(os.Stderr, "Selected branch ID:", branchId)
+	}
+	return err
+}
diff --git a/go.mod b/go.mod
index db947a1e..e806ef20 100644
--- a/go.mod
+++ b/go.mod
@@ -323,7 +323,7 @@ require (
 	golang.org/x/crypto v0.21.0 // indirect
 	golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
 	golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect
-	golang.org/x/net v0.22.0 // indirect
+	golang.org/x/net v0.23.0 // indirect
 	golang.org/x/sync v0.6.0 // indirect
 	golang.org/x/sys v0.19.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
diff --git a/go.sum b/go.sum
index a3aa34dd..40f1c6bd 100644
--- a/go.sum
+++ b/go.sum
@@ -1188,8 +1188,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
-golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
+golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
+golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
diff --git a/internal/branches/create/create.go b/internal/branches/create/create.go
index 03be45e9..b003b7b5 100644
--- a/internal/branches/create/create.go
+++ b/internal/branches/create/create.go
@@ -13,16 +13,16 @@ import (
 )
 
 func Run(ctx context.Context, name, region string, fsys afero.Fs) error {
-	ref, err := flags.LoadProjectRef(fsys)
-	if err != nil {
-		return err
-	}
 	gitBranch := keys.GetGitBranchOrDefault("", fsys)
-	if len(name) == 0 {
+	if len(name) == 0 && len(gitBranch) > 0 {
+		title := fmt.Sprintf("Do you want to create a branch named %s?", utils.Aqua(gitBranch))
+		if shouldCreate := utils.NewConsole().PromptYesNo(title, true); !shouldCreate {
+			return context.Canceled
+		}
 		name = gitBranch
 	}
 
-	resp, err := utils.GetSupabase().CreateBranchWithResponse(ctx, ref, api.CreateBranchJSONRequestBody{
+	resp, err := utils.GetSupabase().CreateBranchWithResponse(ctx, flags.ProjectRef, api.CreateBranchJSONRequestBody{
 		BranchName: name,
 		GitBranch:  &gitBranch,
 		Region:     &region,
diff --git a/internal/branches/disable/disable.go b/internal/branches/disable/disable.go
index 1d313a5a..a4a9fadd 100644
--- a/internal/branches/disable/disable.go
+++ b/internal/branches/disable/disable.go
@@ -12,17 +12,13 @@ import (
 )
 
 func Run(ctx context.Context, fsys afero.Fs) error {
-	ref, err := flags.LoadProjectRef(fsys)
-	if err != nil {
-		return err
-	}
-	resp, err := utils.GetSupabase().DisableBranchWithResponse(ctx, ref)
+	resp, err := utils.GetSupabase().DisableBranchWithResponse(ctx, flags.ProjectRef)
 	if err != nil {
 		return errors.Errorf("failed to disable preview branching: %w", err)
 	}
 	if resp.StatusCode() != http.StatusOK {
 		return errors.New("Unexpected error disabling preview branching: " + string(resp.Body))
 	}
-	fmt.Println("Disabled preview branching for project:", ref)
+	fmt.Println("Disabled preview branching for project:", flags.ProjectRef)
 	return nil
 }
diff --git a/internal/branches/list/list.go b/internal/branches/list/list.go
index edda5139..6d8b9617 100644
--- a/internal/branches/list/list.go
+++ b/internal/branches/list/list.go
@@ -13,11 +13,7 @@ import (
 )
 
 func Run(ctx context.Context, fsys afero.Fs) error {
-	ref, err := flags.LoadProjectRef(fsys)
-	if err != nil {
-		return err
-	}
-	resp, err := utils.GetSupabase().GetBranchesWithResponse(ctx, ref)
+	resp, err := utils.GetSupabase().GetBranchesWithResponse(ctx, flags.ProjectRef)
 	if err != nil {
 		return errors.Errorf("failed to list preview branches: %w", err)
 	}
@@ -27,7 +23,7 @@ func Run(ctx context.Context, fsys afero.Fs) error {
 	}
 
 	table := `|ID|NAME|DEFAULT|GIT BRANCH|RESET ON PUSH|STATUS|CREATED AT (UTC)|UPDATED AT (UTC)|
-|-|-|-|-|-|-|-|
+|-|-|-|-|-|-|-|-|
 `
 	for _, branch := range *resp.JSON200 {
 		gitBranch := " "
diff --git a/internal/db/reset/reset.go b/internal/db/reset/reset.go
index 38219c57..a6311dc3 100644
--- a/internal/db/reset/reset.go
+++ b/internal/db/reset/reset.go
@@ -51,7 +51,7 @@ func Run(ctx context.Context, version string, config pgconn.Config, fsys afero.F
 	}
 	if !utils.IsLocalDatabase(config) {
 		msg := "Do you want to reset the remote database?"
-		if shouldReset := utils.NewConsole().PromptYesNo(msg, true); !shouldReset {
+		if shouldReset := utils.NewConsole().PromptYesNo(msg, false); !shouldReset {
 			utils.CmdSuggestion = ""
 			return errors.New(context.Canceled)
 		}
diff --git a/internal/db/reset/templates/drop.sql b/internal/db/reset/templates/drop.sql
index 031357dc..fa44da33 100644
--- a/internal/db/reset/templates/drop.sql
+++ b/internal/db/reset/templates/drop.sql
@@ -33,6 +33,17 @@ begin
     execute format('drop table if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname);
   end loop;
 
+  -- truncate auth tables
+  for rec in
+    select *
+    from pg_class c
+    where
+      c.relnamespace::regnamespace::name = 'auth'
+      and c.relkind = 'r'
+  loop
+    execute format('truncate %I.%I restart identity cascade', rec.relnamespace::regnamespace::name, rec.relname);
+  end loop;
+
   -- sequences
   for rec in
     select *
diff --git a/internal/projects/delete/delete.go b/internal/projects/delete/delete.go
index 7692b457..616afcbf 100644
--- a/internal/projects/delete/delete.go
+++ b/internal/projects/delete/delete.go
@@ -17,7 +17,8 @@ func PreRun(ref string) error {
 	if err := utils.AssertProjectRefIsValid(ref); err != nil {
 		return err
 	}
-	if !utils.NewConsole().PromptYesNo("Do you want to delete project "+utils.Aqua(ref)+"? This action is irreversible.", true) {
+	title := fmt.Sprintf("Do you want to delete project %s? This action is irreversible.", utils.Aqua(ref))
+	if shouldDelete := utils.NewConsole().PromptYesNo(title, false); !shouldDelete {
 		return errors.New("Not deleting project: " + utils.Aqua(ref))
 	}
 	return nil
diff --git a/internal/storage/rm/rm.go b/internal/storage/rm/rm.go
index 6553c423..6b788358 100644
--- a/internal/storage/rm/rm.go
+++ b/internal/storage/rm/rm.go
@@ -51,7 +51,7 @@ func Run(ctx context.Context, paths []string, recursive bool, fsys afero.Fs) err
 	}
 	for bucket, prefixes := range groups {
 		confirm := fmt.Sprintf("Confirm deleting files in bucket %v?", utils.Bold(bucket))
-		if shouldDelete := utils.NewConsole().PromptYesNo(confirm, true); !shouldDelete {
+		if shouldDelete := utils.NewConsole().PromptYesNo(confirm, false); !shouldDelete {
 			continue
 		}
 		// Always try deleting first in case the paths resolve to extensionless files
diff --git a/internal/utils/console.go b/internal/utils/console.go
index 65d57ea2..9e0a0979 100644
--- a/internal/utils/console.go
+++ b/internal/utils/console.go
@@ -12,7 +12,7 @@ import (
 )
 
 type Console struct {
-	isTTY  bool
+	IsTTY  bool
 	stdin  *bufio.Scanner
 	stdout io.Writer
 	stderr io.Writer
@@ -20,7 +20,7 @@ type Console struct {
 
 func NewConsole() Console {
 	return Console{
-		isTTY:  term.IsTerminal(int(os.Stdin.Fd())),
+		IsTTY:  term.IsTerminal(int(os.Stdin.Fd())),
 		stdin:  bufio.NewScanner(os.Stdin),
 		stdout: os.Stdout,
 		stderr: GetDebugLogger(),
@@ -69,7 +69,7 @@ func (c Console) PromptText(label string) (string, error) {
 	}
 	token := strings.TrimSpace(c.stdin.Text())
 	// Echo input from non-interactive terminal
-	if !c.isTTY {
+	if !c.IsTTY {
 		fmt.Fprintln(c.stdout, token)
 	}
 	return token, nil


Test patch:
diff --git a/internal/branches/create/create_test.go b/internal/branches/create/create_test.go
index 3c619435..410b3aa8 100644
--- a/internal/branches/create/create_test.go
+++ b/internal/branches/create/create_test.go
@@ -4,33 +4,32 @@ import (
 	"context"
 	"net"
 	"net/http"
-	"os"
 	"testing"
 
 	"github.com/spf13/afero"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/supabase/cli/internal/testing/apitest"
-	"github.com/supabase/cli/internal/testing/fstest"
 	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/internal/utils/flags"
 	"github.com/supabase/cli/pkg/api"
 	"gopkg.in/h2non/gock.v1"
 )
 
 func TestCreateCommand(t *testing.T) {
+	// Setup valid project ref
+	flags.ProjectRef = apitest.RandomProjectRef()
+
 	t.Run("creates preview branch", func(t *testing.T) {
-		// Setup valid project ref
-		ref := apitest.RandomProjectRef()
 		// Setup valid access token
 		token := apitest.RandomAccessToken(t)
 		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
-		require.NoError(t, afero.WriteFile(fsys, utils.ProjectRefPath, []byte(ref), 0644))
 		// Setup mock api
 		defer gock.OffAll()
 		gock.New(utils.DefaultApiHost).
-			Post("/v1/projects/" + ref + "/branches").
+			Post("/v1/projects/" + flags.ProjectRef + "/branches").
 			Reply(http.StatusCreated).
 			JSON(api.BranchResponse{
 				Id: "test-uuid",
@@ -41,24 +40,14 @@ func TestCreateCommand(t *testing.T) {
 		assert.NoError(t, err)
 	})
 
-	t.Run("throws error on permission denied", func(t *testing.T) {
-		// Setup in-memory fs
-		fsys := &fstest.OpenErrorFs{DenyPath: utils.ProjectRefPath}
-		// Run test
-		err := Run(context.Background(), "branch", "region", fsys)
-		// Check error
-		assert.ErrorIs(t, err, os.ErrPermission)
-	})
-
 	t.Run("throws error on network disconnected", func(t *testing.T) {
-		ref := apitest.RandomProjectRef()
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
-		require.NoError(t, afero.WriteFile(fsys, utils.ProjectRefPath, []byte(ref), 0644))
+		require.NoError(t, afero.WriteFile(fsys, utils.ProjectRefPath, []byte(flags.ProjectRef), 0644))
 		// Setup mock api
 		defer gock.OffAll()
 		gock.New(utils.DefaultApiHost).
-			Post("/v1/projects/" + ref + "/branches").
+			Post("/v1/projects/" + flags.ProjectRef + "/branches").
 			ReplyError(net.ErrClosed)
 		// Run test
 		err := Run(context.Background(), "", "sin", fsys)
@@ -67,14 +56,13 @@ func TestCreateCommand(t *testing.T) {
 	})
 
 	t.Run("throws error on service unavailable", func(t *testing.T) {
-		ref := apitest.RandomProjectRef()
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
-		require.NoError(t, afero.WriteFile(fsys, utils.ProjectRefPath, []byte(ref), 0644))
+		require.NoError(t, afero.WriteFile(fsys, utils.ProjectRefPath, []byte(flags.ProjectRef), 0644))
 		// Setup mock api
 		defer gock.OffAll()
 		gock.New(utils.DefaultApiHost).
-			Post("/v1/projects/" + ref + "/branches").
+			Post("/v1/projects/" + flags.ProjectRef + "/branches").
 			Reply(http.StatusServiceUnavailable)
 		// Run test
 		err := Run(context.Background(), "", "sin", fsys)
diff --git a/internal/db/reset/reset_test.go b/internal/db/reset/reset_test.go
index cbeecb93..6841dbf4 100644
--- a/internal/db/reset/reset_test.go
+++ b/internal/db/reset/reset_test.go
@@ -17,6 +17,7 @@ import (
 	"github.com/stretchr/testify/require"
 	"github.com/supabase/cli/internal/db/start"
 	"github.com/supabase/cli/internal/testing/apitest"
+	"github.com/supabase/cli/internal/testing/fstest"
 	"github.com/supabase/cli/internal/testing/pgtest"
 	"github.com/supabase/cli/internal/utils"
 	"gopkg.in/h2non/gock.v1"
@@ -35,6 +36,16 @@ func TestResetCommand(t *testing.T) {
 	}
 
 	t.Run("throws error on context canceled", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Run test
+		err := Run(context.Background(), "", pgconn.Config{Host: "db.supabase.co"}, fsys)
+		// Check error
+		assert.ErrorIs(t, err, context.Canceled)
+	})
+
+	t.Run("throws error on invalid port", func(t *testing.T) {
+		defer fstest.MockStdin(t, "y")()
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
 		// Run test
diff --git a/internal/storage/rm/rm_test.go b/internal/storage/rm/rm_test.go
index 49d4e3da..b56b77ca 100644
--- a/internal/storage/rm/rm_test.go
+++ b/internal/storage/rm/rm_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/stretchr/testify/require"
 	"github.com/supabase/cli/internal/storage/client"
 	"github.com/supabase/cli/internal/testing/apitest"
+	"github.com/supabase/cli/internal/testing/fstest"
 	"github.com/supabase/cli/internal/utils"
 	"github.com/supabase/cli/pkg/api"
 	"gopkg.in/h2non/gock.v1"
@@ -70,6 +71,7 @@ func TestStorageRM(t *testing.T) {
 	})
 
 	t.Run("removes multiple objects", func(t *testing.T) {
+		defer fstest.MockStdin(t, "y")()
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
 		projectRef := apitest.RandomProjectRef()
@@ -113,6 +115,7 @@ func TestStorageRM(t *testing.T) {
 	})
 
 	t.Run("removes buckets and directories", func(t *testing.T) {
+		defer fstest.MockStdin(t, "y")()
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
 		projectRef := apitest.RandomProjectRef()
@@ -187,6 +190,7 @@ func TestStorageRM(t *testing.T) {
 	})
 
 	t.Run("throws error on delete failure", func(t *testing.T) {
+		defer fstest.MockStdin(t, "y")()
 		// Setup in-memory fs
 		fsys := afero.NewMemMapFs()
 		projectRef := apitest.RandomProjectRef()