diff --git a/client/clienter.go b/client/clienter.go index b4e2348..748a243 100644 --- a/client/clienter.go +++ b/client/clienter.go @@ -30,6 +30,7 @@ type Clienter interface { PushRepos(ctx context.Context, repoDirs []string, args ...string) error Remotes(ctx context.Context, repoDirs []string, args ...string) error Remove(ctx context.Context, dirs []string, name string) error + ResetRepos(ctx context.Context, repoDirs []string, args ...string) error SetURLs(ctx context.Context, repoDirs []string, name, baseURL string) error StageFiles(ctx context.Context, dirs []string, args ...string) error StashRepos(ctx context.Context, dirs []string, args ...string) error diff --git a/client/repos/reset.go b/client/repos/reset.go new file mode 100644 index 0000000..2a49567 --- /dev/null +++ b/client/repos/reset.go @@ -0,0 +1,80 @@ +package repos + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + + ctxhelper "github.com/gomicro/align/client/context" + "github.com/gosuri/uiprogress" +) + +func (r *Repos) ResetRepos(ctx context.Context, dirs []string, args ...string) error { + count := len(dirs) + args = append([]string{"reset"}, args...) + + verbose := ctxhelper.Verbose(ctx) + + var bar *uiprogress.Bar + currRepo := "" + + if verbose { + r.scrb.BeginDescribe("Command") + defer r.scrb.EndDescribe() + + r.scrb.Print(fmt.Sprintf("git %s", strings.Join(args, " "))) + + r.scrb.BeginDescribe("directories") + defer r.scrb.EndDescribe() + } else { + bar = uiprogress.AddBar(count). + AppendCompleted(). + PrependElapsed(). + PrependFunc(func(b *uiprogress.Bar) string { + return fmt.Sprintf("Resetting (%d/%d)", b.Current(), count) + }). + AppendFunc(func(b *uiprogress.Bar) string { + return currRepo + }) + } + + var errs []error + + for _, dir := range dirs { + currRepo = fmt.Sprintf("\nCurrent Repo: %v", dir) + + out := &bytes.Buffer{} + errout := &bytes.Buffer{} + + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Stdout = out + cmd.Stderr = errout + cmd.Dir = dir + + err := cmd.Run() + if verbose { + r.scrb.BeginDescribe(dir) + if err != nil { + r.scrb.Error(err) + r.scrb.PrintLines(errout) + } else { + r.scrb.PrintLines(out) + } + + r.scrb.EndDescribe() + } else { + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w: %s", dir, err, strings.TrimSpace(errout.String()))) + } + + bar.Incr() + } + } + + currRepo = "" + + return errors.Join(errs...) +} diff --git a/client/testclient/client.go b/client/testclient/client.go index ffe8cc7..f1dd116 100644 --- a/client/testclient/client.go +++ b/client/testclient/client.go @@ -121,6 +121,12 @@ func (c *TestClient) Remotes(ctx context.Context, repoDirs []string, args ...str return c.Errors["Remotes"] } +func (c *TestClient) ResetRepos(ctx context.Context, repoDirs []string, args ...string) error { + c.CommandsCalled = append(c.CommandsCalled, "ResetRepos") + + return c.Errors["ResetRepos"] +} + func (c *TestClient) SetURLs(ctx context.Context, repoDirs []string, name, baseURL string) error { c.CommandsCalled = append(c.CommandsCalled, "SetURLs") diff --git a/cmd/reset.go b/cmd/reset.go new file mode 100644 index 0000000..f10b9f1 --- /dev/null +++ b/cmd/reset.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "context" + "fmt" + + ctxhelper "github.com/gomicro/align/client/context" + "github.com/gosuri/uiprogress" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + hard bool + soft bool + mixed bool +) + +func init() { + RootCmd.AddCommand(resetCmd) + + resetCmd.Flags().StringVar(&dir, "dir", ".", "directory to reset repos in") + resetCmd.Flags().BoolVar(&hard, "hard", false, "reset index and working tree, discarding all uncommitted changes") + resetCmd.Flags().BoolVar(&soft, "soft", false, "reset HEAD only, keeping all changes staged") + resetCmd.Flags().BoolVar(&mixed, "mixed", false, "reset HEAD and index, keeping changes in the working tree (unstaged)") + + resetCmd.MarkFlagsMutuallyExclusive("hard", "soft", "mixed") +} + +var resetCmd = &cobra.Command{ + Use: "reset [ref]", + Short: "Reset the current branch across all repos", + Long: `Reset the current branch across all repos in a directory. One of --hard, --soft, or --mixed must be provided.`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: resetCmdValidArgsFunc, + PersistentPreRun: setupClient, + RunE: resetFunc, +} + +func resetCmdValidArgsFunc(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + setupClient(cmd, args) + + resetDir, err := cmd.Flags().GetString("dir") + if err != nil { + resetDir = "." + } + + ctx := context.Background() + + repoDirs, err := clt.GetDirs(ctx, resetDir) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + names, err := clt.GetBranchAndTagNames(ctx, repoDirs) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return names, cobra.ShellCompDirectiveNoFileComp +} + +func resetFunc(cmd *cobra.Command, args []string) error { + if !hard && !soft && !mixed { + return fmt.Errorf("one of --hard, --soft, or --mixed is required") + } + + verbose := viper.GetBool("verbose") + ctx := ctxhelper.WithVerbose(context.Background(), verbose) + + if !verbose { + uiprogress.Start() + defer uiprogress.Stop() + } + + repoDirs, err := clt.GetDirs(ctx, dir) + if err != nil { + cmd.SilenceUsage = true + return fmt.Errorf("get dirs: %w", err) + } + + var modeFlag string + switch { + case hard: + modeFlag = "--hard" + case soft: + modeFlag = "--soft" + case mixed: + modeFlag = "--mixed" + } + + args = append([]string{modeFlag}, args...) + + err = clt.ResetRepos(ctx, repoDirs, args...) + if err != nil { + cmd.SilenceUsage = true + return fmt.Errorf("reset repos: %w", err) + } + + return nil +} diff --git a/cmd/reset_test.go b/cmd/reset_test.go new file mode 100644 index 0000000..eb37850 --- /dev/null +++ b/cmd/reset_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/gomicro/align/client/testclient" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestReset(t *testing.T) { + viper.Set("verbose", true) + t.Cleanup(func() { viper.Set("verbose", false) }) + + t.Run("hard reset calls expected commands", func(t *testing.T) { + hard = true + t.Cleanup(func() { hard = false }) + + tc := testclient.New() + clt = tc + + err := resetFunc(resetCmd, []string{}) + assert.NoError(t, err) + + tc.AssertCommandsCalled(t, "GetDirs", "ResetRepos") + }) + + t.Run("soft reset calls expected commands", func(t *testing.T) { + soft = true + t.Cleanup(func() { soft = false }) + + tc := testclient.New() + clt = tc + + err := resetFunc(resetCmd, []string{}) + assert.NoError(t, err) + + tc.AssertCommandsCalled(t, "GetDirs", "ResetRepos") + }) + + t.Run("mixed reset calls expected commands", func(t *testing.T) { + mixed = true + t.Cleanup(func() { mixed = false }) + + tc := testclient.New() + clt = tc + + err := resetFunc(resetCmd, []string{}) + assert.NoError(t, err) + + tc.AssertCommandsCalled(t, "GetDirs", "ResetRepos") + }) + + t.Run("returns error when no mode flag provided", func(t *testing.T) { + tc := testclient.New() + clt = tc + + err := resetFunc(resetCmd, []string{}) + assert.ErrorContains(t, err, "--hard, --soft, or --mixed is required") + + tc.AssertNoCommandsCalled(t) + }) + + t.Run("returns error on get dirs failure", func(t *testing.T) { + hard = true + t.Cleanup(func() { hard = false }) + + tc := testclient.New() + tc.Errors["GetDirs"] = errors.New("some dirs error") + clt = tc + + err := resetFunc(resetCmd, []string{}) + assert.ErrorContains(t, err, "get dirs") + + tc.AssertCommandsCalled(t, "GetDirs") + }) + + t.Run("returns error on reset repos failure", func(t *testing.T) { + hard = true + t.Cleanup(func() { hard = false }) + + tc := testclient.New() + tc.Errors["ResetRepos"] = errors.New("some reset error") + clt = tc + + err := resetFunc(resetCmd, []string{}) + assert.ErrorContains(t, err, "reset repos") + + tc.AssertCommandsCalled(t, "GetDirs", "ResetRepos") + }) +}