diff --git a/client/clienter.go b/client/clienter.go index fb37773..ab1d824 100644 --- a/client/clienter.go +++ b/client/clienter.go @@ -15,6 +15,7 @@ type Clienter interface { CloneRepos(ctx context.Context, dir string) ([]*clientctx.Repository, error) CommitRepos(ctx context.Context, dirs []string, args ...string) error DiffRepos(ctx context.Context, repoDirs []string, cfg *repos.DiffConfig) error + FetchRepos(ctx context.Context, repoDirs []string, args ...string) error GetBranchAndTagNames(ctx context.Context, dirs []string) ([]string, error) GetBranchNames(ctx context.Context, dirs []string) ([]string, error) GetDirs(ctx context.Context, dir string) ([]string, error) diff --git a/client/repos/fetch.go b/client/repos/fetch.go new file mode 100644 index 0000000..f586209 --- /dev/null +++ b/client/repos/fetch.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) FetchRepos(ctx context.Context, dirs []string, args ...string) error { + count := len(dirs) + args = append([]string{"fetch"}, 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("Fetching (%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 cd44a0e..0f8f41f 100644 --- a/client/testclient/client.go +++ b/client/testclient/client.go @@ -37,6 +37,12 @@ func (c *TestClient) CloneRepos(ctx context.Context, baseDir string) ([]*clientc return nil, c.Errors["CloneRepos"] } +func (c *TestClient) FetchRepos(ctx context.Context, repoDirs []string, args ...string) error { + c.CommandsCalled = append(c.CommandsCalled, "FetchRepos") + + return c.Errors["FetchRepos"] +} + func (c *TestClient) GetBranchNames(ctx context.Context, dirs []string) ([]string, error) { c.CommandsCalled = append(c.CommandsCalled, "GetBranchNames") diff --git a/cmd/fetch.go b/cmd/fetch.go new file mode 100644 index 0000000..f17387f --- /dev/null +++ b/cmd/fetch.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "context" + "fmt" + + ctxhelper "github.com/gomicro/align/client/context" + "github.com/gosuri/uiprogress" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func init() { + RootCmd.AddCommand(fetchCmd) + + fetchCmd.Flags().StringVar(&dir, "dir", ".", "directory to fetch repos in") + fetchCmd.Flags().BoolVar(&tags, "tags", false, "fetch all tags") +} + +var fetchCmd = &cobra.Command{ + Use: "fetch [remote]", + Short: "Fetch from remotes across all repos in a directory", + Long: `Fetch from remotes across all repos in a directory without merging into the working tree.`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: fetchCmdValidArgsFunc, + PersistentPreRun: setupClient, + RunE: fetchFunc, +} + +func fetchCmdValidArgsFunc(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + setupClient(cmd, args) + + fetchDir, err := cmd.Flags().GetString("dir") + if err != nil { + fetchDir = "." + } + + ctx := context.Background() + + repoDirs, err := clt.GetDirs(ctx, fetchDir) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + names, err := clt.GetRemoteNames(ctx, repoDirs) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return names, cobra.ShellCompDirectiveNoFileComp +} + +func fetchFunc(cmd *cobra.Command, args []string) error { + 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) + } + + if tags { + args = append(args, "--tags") + } + + err = clt.FetchRepos(ctx, repoDirs, args...) + if err != nil { + cmd.SilenceUsage = true + return fmt.Errorf("fetch repos: %w", err) + } + + return nil +} diff --git a/cmd/fetch_test.go b/cmd/fetch_test.go new file mode 100644 index 0000000..ccfddea --- /dev/null +++ b/cmd/fetch_test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/gomicro/align/client/testclient" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestFetch(t *testing.T) { + viper.Set("verbose", true) + t.Cleanup(func() { viper.Set("verbose", false) }) + + t.Run("calls expected commands", func(t *testing.T) { + tc := testclient.New() + clt = tc + + err := fetchFunc(fetchCmd, []string{}) + assert.NoError(t, err) + + tc.AssertCommandsCalled(t, "GetDirs", "FetchRepos") + }) + + t.Run("calls expected commands with remote arg", func(t *testing.T) { + tc := testclient.New() + clt = tc + + err := fetchFunc(fetchCmd, []string{"origin"}) + assert.NoError(t, err) + + tc.AssertCommandsCalled(t, "GetDirs", "FetchRepos") + }) + + t.Run("returns error on get dirs failure", func(t *testing.T) { + tc := testclient.New() + tc.Errors["GetDirs"] = errors.New("some dirs error") + clt = tc + + err := fetchFunc(fetchCmd, []string{}) + assert.ErrorContains(t, err, "get dirs") + + tc.AssertCommandsCalled(t, "GetDirs") + }) + + t.Run("returns error on fetch repos failure", func(t *testing.T) { + tc := testclient.New() + tc.Errors["FetchRepos"] = errors.New("some fetch error") + clt = tc + + err := fetchFunc(fetchCmd, []string{}) + assert.ErrorContains(t, err, "fetch repos") + + tc.AssertCommandsCalled(t, "GetDirs", "FetchRepos") + }) +}