Skip to content

Commit

Permalink
feat: Implement av stack for-each command (#212)
Browse files Browse the repository at this point in the history
Adds a way to execute a custom command on each branch in the stack.

Fixes #210.

```
GIT_PAGER='' av stack for-each -- git show --format='subject: %s' --quiet HEAD -- 
Executing command git show "--format=subject: %s" --quiet HEAD -- for 3 branches:
  - switching to branch 2023-11-13-one
subject: 2023-11-13-one-a
  - switching to branch 2023-11-13-two
subject: 2023-11-13-two-a
  - switching to branch 2023-11-13-three
subject: 2023-11-13-three-a
```
  • Loading branch information
twavv authored Nov 14, 2023
1 parent eacefbd commit dc11025
Show file tree
Hide file tree
Showing 9 changed files with 201 additions and 11 deletions.
6 changes: 4 additions & 2 deletions cmd/av/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import (
)

var stackCmd = &cobra.Command{
Use: "stack",
Short: "managed stacked pull requests",
Use: "stack",
Aliases: []string{"st"},
Short: "managed stacked pull requests",
}

func init() {
stackCmd.AddCommand(
stackBranchCmd,
stackBranchCommitCmd,
stackDiffCmd,
stackForEachCmd,
stackNextCmd,
stackPrevCmd,
stackReorderCmd,
Expand Down
5 changes: 3 additions & 2 deletions cmd/av/stack_branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ var stackBranchFlags struct {
Force bool
}
var stackBranchCmd = &cobra.Command{
Use: "branch [flags] <branch-name>",
Short: "create a new stacked branch",
Use: "branch [flags] <branch-name>",
Aliases: []string{"b", "br"},
Short: "create a new stacked branch",
Long: `Create a new branch that is stacked on the current branch.
If the --rename/-m flag is given, the current branch is renamed to the name
Expand Down
2 changes: 1 addition & 1 deletion cmd/av/stack_branchcommit.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ var stackBranchCommitFlags struct {

var stackBranchCommitCmd = &cobra.Command{
Use: "branch-commit [flags]",
Aliases: []string{"bc"},
Aliases: []string{"branchcommit", "bc"},
Short: "create a new stacked branch and commit staged changes to it",
Long: "Create a new branch that is stacked on the current branch and commit all staged changes with the specified arguments.",
SilenceUsage: true,
Expand Down
115 changes: 115 additions & 0 deletions cmd/av/stack_foreach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"fmt"
"os"
"os/exec"

"emperror.dev/errors"
"github.com/aviator-co/av/internal/git"
"github.com/aviator-co/av/internal/meta"
"github.com/aviator-co/av/internal/utils/colors"
"github.com/aviator-co/av/internal/utils/executils"
"github.com/spf13/cobra"
)

var stackForEachFlags struct {
previous bool
subsequent bool
}

var stackForEachCmd = &cobra.Command{
Use: "for-each [flags] -- <command> [args...] ggit ",
Aliases: []string{"foreach", "fe"},
Short: "execute a command for each branch in the current stack",
Long: `Execute a command for each branch in the current stack.
To prevent flags for the command to be executed from being parsed as flags for
this command, use the "--" separator (see examples below).
Output from the command will be printed to stdout/stderr as it is generated.
Examples:
Print the current HEAD commit for each branch in the stack:
$ av stack for-each -- git rev-parse HEAD
Push every branch in the stack:
$ av stack for-each -- git push --force
Note that the "--" separator is required here to prevent "--force" from being
interpreted as a flag for the "stack for-each" command.
`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
repo, err := getRepo()
if err != nil {
return err
}
db, err := getDB(repo)
if err != nil {
return err
}
tx := db.ReadTx()
currentBranch, err := repo.CurrentBranchName()
if err != nil {
return err
}

var branches []string
if stackForEachFlags.previous {
branches, err = meta.PreviousBranches(tx, currentBranch)
if err != nil {
return err
}
branches = append(branches, currentBranch)
} else if stackForEachFlags.subsequent {
branches = meta.SubsequentBranches(tx, currentBranch)
branches = append([]string{currentBranch}, branches...)
} else {
branches, err = meta.PreviousBranches(tx, currentBranch)
if err != nil {
return err
}
branches = append(branches, currentBranch)
branches = append(branches, meta.SubsequentBranches(tx, currentBranch)...)
}

_, _ = fmt.Fprint(os.Stderr,
"Executing command ", colors.CliCmd(executils.FormatCommandLine(args)),
" for ", colors.UserInput(len(branches)), " branches:\n",
)
for _, branch := range branches {
_, _ = fmt.Fprint(os.Stderr,
" - switching to branch ", colors.UserInput(branch), "\n",
)
if _, err := repo.CheckoutBranch(&git.CheckoutBranch{Name: branch}); err != nil {
return errors.Wrapf(err, "failed to switch to branch %q", branch)
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return errors.Wrapf(err, "failed to execute command for branch %q", branch)
}
}

// Switch back to the original branch.
// We only do this on success, because on failure, it's likely that the
// user will want to be on the branch that had issues.
if _, err := repo.CheckoutBranch(&git.CheckoutBranch{Name: currentBranch}); err != nil {
return errors.Wrapf(err, "failed to switch back to branch %q", currentBranch)
}
return nil
},
}

func init() {
stackForEachCmd.Flags().BoolVar(
&stackForEachFlags.previous, "previous", false,
"apply the command only to the current branch and all previous branches in the stack",
)
stackForEachCmd.Flags().BoolVar(
&stackForEachFlags.subsequent, "subsequent", false,
"apply the command only to the current branch and all subsequent branches in the stack",
)
stackForEachCmd.MarkFlagsMutuallyExclusive("previous", "subsequent")
}
5 changes: 3 additions & 2 deletions cmd/av/stack_next.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ var stackNextFlags struct {
}

var stackNextCmd = &cobra.Command{
Use: "next [<n>|--last]",
Short: "checkout the next branch in the stack",
Use: "next [<n>|--last]",
Aliases: []string{"n"},
Short: "checkout the next branch in the stack",
RunE: func(cmd *cobra.Command, args []string) error {
// Get the subsequent branches so we can checkout the nth one
repo, err := getRepo()
Expand Down
5 changes: 3 additions & 2 deletions cmd/av/stack_prev.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ var stackPrevFlags struct {
}

var stackPrevCmd = &cobra.Command{
Use: "prev [<n>|--first]",
Short: "checkout the previous branch in the stack",
Use: "prev [<n>|--first]",
Aliases: []string{"p"},
Short: "checkout the previous branch in the stack",
RunE: func(cmd *cobra.Command, args []string) error {
// Get the previous branches so we can checkout the nth one
repo, err := getRepo()
Expand Down
5 changes: 3 additions & 2 deletions cmd/av/stack_tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import (
)

var stackTreeCmd = &cobra.Command{
Use: "tree",
Short: "show the tree of stacked branches",
Use: "tree",
Aliases: []string{"t"},
Short: "show the tree of stacked branches",
RunE: func(cmd *cobra.Command, args []string) error {
repo, err := getRepo()
if err != nil {
Expand Down
28 changes: 28 additions & 0 deletions e2e_tests/stack_foreach_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package e2e_tests

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/aviator-co/av/internal/git/gittest"
)

func TestStackForEach(t *testing.T) {
repo := gittest.NewTempRepo(t)
Chdir(t, repo.Dir())

// Create a stack of three branches
RequireAv(t, "stack", "branch", "stack-1")
gittest.CommitFile(t, repo, "my-file", []byte("1a\n"), gittest.WithMessage("Commit 1a"))
RequireAv(t, "stack", "branch", "stack-2")
gittest.CommitFile(t, repo, "my-file", []byte("2a\n"), gittest.WithMessage("Commit 2a"))
RequireAv(t, "stack", "branch", "stack-3")
gittest.CommitFile(t, repo, "my-file", []byte("3a\n"), gittest.WithMessage("Commit 3a"))

out := RequireAv(t,
"stack", "for-each", "--",
"git", "show", "--format=%s", "--quiet", "HEAD", "--",
)
require.Equal(t, "Commit 1a\nCommit 2a\nCommit 3a\n", out.Stdout)
}
41 changes: 41 additions & 0 deletions internal/utils/executils/executils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package executils

import (
"fmt"
"strings"
)

// FormatCommandLine formats a command line for display.
// This is meant to prevent confusing output when a command line contains
// arguments with spaces or other special characters.
func FormatCommandLine(args []string) string {
// NB: strings.Builder never returns an error while writing, so we suppress
// the error return values below.
sb := strings.Builder{}
for i, arg := range args {
if i > 0 {
_, _ = sb.WriteString(" ")
}
if cliArgumentNeedsQuoting(arg) {
_, _ = fmt.Fprintf(&sb, "%q", arg)
} else {
_, _ = sb.WriteString(arg)
}
}
return sb.String()
}

func cliArgumentNeedsQuoting(arg string) bool {
if arg == "" {
return true
}
for _, r := range arg {
isAllowedRune := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
(r == '-')
if !isAllowedRune {
return true
}
}
return false
}

0 comments on commit dc11025

Please sign in to comment.