From 7b637e6da0504bb84e026a3752d443e1517920da Mon Sep 17 00:00:00 2001 From: dan9186 Date: Sun, 5 Apr 2026 11:02:22 -0700 Subject: [PATCH 1/2] wire in merge command --- client/clienter.go | 1 + client/repos/merge.go | 80 ++++++++++++++++++++++++++++ client/testclient/client.go | 6 +++ cmd/merge.go | 103 ++++++++++++++++++++++++++++++++++++ cmd/merge_test.go | 60 +++++++++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 client/repos/merge.go create mode 100644 cmd/merge.go create mode 100644 cmd/merge_test.go diff --git a/client/clienter.go b/client/clienter.go index e01ee91..b4e2348 100644 --- a/client/clienter.go +++ b/client/clienter.go @@ -25,6 +25,7 @@ type Clienter interface { GetTagNames(ctx context.Context, dirs []string) ([]string, error) ListTags(ctx context.Context, repoDirs []string, args ...string) error LogRepos(ctx context.Context, repoDirs []string, ignoreEmtpy bool, args ...string) error + MergeRepos(ctx context.Context, repoDirs []string, args ...string) error PullRepos(ctx context.Context, repoDirs []string, args ...string) error PushRepos(ctx context.Context, repoDirs []string, args ...string) error Remotes(ctx context.Context, repoDirs []string, args ...string) error diff --git a/client/repos/merge.go b/client/repos/merge.go new file mode 100644 index 0000000..6d2cabf --- /dev/null +++ b/client/repos/merge.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) MergeRepos(ctx context.Context, dirs []string, args ...string) error { + count := len(dirs) + args = append([]string{"merge"}, 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("Merging (%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 1c5ba97..ffe8cc7 100644 --- a/client/testclient/client.go +++ b/client/testclient/client.go @@ -97,6 +97,12 @@ func (c *TestClient) ListTags(ctx context.Context, repoDirs []string, args ...st return c.Errors["ListTags"] } +func (c *TestClient) MergeRepos(ctx context.Context, repoDirs []string, args ...string) error { + c.CommandsCalled = append(c.CommandsCalled, "MergeRepos") + + return c.Errors["MergeRepos"] +} + func (c *TestClient) PullRepos(ctx context.Context, repoDirs []string, args ...string) error { c.CommandsCalled = append(c.CommandsCalled, "PullRepos") diff --git a/cmd/merge.go b/cmd/merge.go new file mode 100644 index 0000000..7a7bdaf --- /dev/null +++ b/cmd/merge.go @@ -0,0 +1,103 @@ +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 ( + noFF bool + squash bool + abortMerge bool +) + +func init() { + RootCmd.AddCommand(mergeCmd) + + mergeCmd.Flags().StringVar(&dir, "dir", ".", "directory to merge repos in") + mergeCmd.Flags().BoolVar(&noFF, "no-ff", false, "create a merge commit even when fast-forward is possible") + mergeCmd.Flags().BoolVar(&squash, "squash", false, "squash commits from the branch into a single commit") + mergeCmd.Flags().BoolVar(&abortMerge, "abort", false, "abort an in-progress merge") + + mergeCmd.MarkFlagsMutuallyExclusive("squash", "no-ff") + mergeCmd.MarkFlagsMutuallyExclusive("abort", "squash") + mergeCmd.MarkFlagsMutuallyExclusive("abort", "no-ff") +} + +var mergeCmd = &cobra.Command{ + Use: "merge [branch]", + Short: "Merge a branch into the current branch across all repos", + Long: `Merge a branch into the current branch across all repos in a directory.`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: mergeCmdValidArgsFunc, + PersistentPreRun: setupClient, + RunE: mergeFunc, +} + +func mergeCmdValidArgsFunc(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + setupClient(cmd, args) + + mergeDir, err := cmd.Flags().GetString("dir") + if err != nil { + mergeDir = "." + } + + ctx := context.Background() + + repoDirs, err := clt.GetDirs(ctx, mergeDir) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + names, err := clt.GetBranchNames(ctx, repoDirs) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return names, cobra.ShellCompDirectiveNoFileComp +} + +func mergeFunc(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 abortMerge { + args = []string{"--abort"} + } else { + if noFF { + args = append(args, "--no-ff") + } + + if squash { + args = append(args, "--squash") + } + } + + err = clt.MergeRepos(ctx, repoDirs, args...) + if err != nil { + cmd.SilenceUsage = true + return fmt.Errorf("merge repos: %w", err) + } + + return nil +} diff --git a/cmd/merge_test.go b/cmd/merge_test.go new file mode 100644 index 0000000..d132765 --- /dev/null +++ b/cmd/merge_test.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/gomicro/align/client/testclient" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestMerge(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 := mergeFunc(mergeCmd, []string{"main"}) + assert.NoError(t, err) + + tc.AssertCommandsCalled(t, "GetDirs", "MergeRepos") + }) + + t.Run("aborts in-progress merge", func(t *testing.T) { + abortMerge = true + t.Cleanup(func() { abortMerge = false }) + + tc := testclient.New() + clt = tc + + err := mergeFunc(mergeCmd, []string{}) + assert.NoError(t, err) + + tc.AssertCommandsCalled(t, "GetDirs", "MergeRepos") + }) + + 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 := mergeFunc(mergeCmd, []string{"main"}) + assert.ErrorContains(t, err, "get dirs") + + tc.AssertCommandsCalled(t, "GetDirs") + }) + + t.Run("returns error on merge repos failure", func(t *testing.T) { + tc := testclient.New() + tc.Errors["MergeRepos"] = errors.New("some merge error") + clt = tc + + err := mergeFunc(mergeCmd, []string{"main"}) + assert.ErrorContains(t, err, "merge repos") + + tc.AssertCommandsCalled(t, "GetDirs", "MergeRepos") + }) +} From 1390c0f0f347d8ec78bf6dfeb92bc3e08dc945be Mon Sep 17 00:00:00 2001 From: dan9186 Date: Sun, 5 Apr 2026 11:04:08 -0700 Subject: [PATCH 2/2] go fmt --- cmd/merge.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/merge.go b/cmd/merge.go index 7a7bdaf..8ecf853 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -11,8 +11,8 @@ import ( ) var ( - noFF bool - squash bool + noFF bool + squash bool abortMerge bool )