Skip to content

Commit

Permalink
feat(stack reorder): Implement PickCmd (#126)
Browse files Browse the repository at this point in the history
Implements the actual `pick` command (along with adding the necessary other things like `Repo.CherryPick`).
  • Loading branch information
twavv authored May 26, 2023
1 parent 16635c3 commit f0cf48c
Show file tree
Hide file tree
Showing 10 changed files with 266 additions and 11 deletions.
7 changes: 4 additions & 3 deletions cmd/av/stack_reorder.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ squashed, dropped, or moved within the stack.
`

var stackReorderCmd = &cobra.Command{
Use: "reorder",
Short: "reorder the stack",
Long: strings.TrimSpace(stackReorderDoc),
Use: "reorder",
Short: "reorder the stack",
Hidden: true,
Long: strings.TrimSpace(stackReorderDoc),
RunE: func(cmd *cobra.Command, args []string) error {
return errors.New("not implemented")
},
Expand Down
84 changes: 84 additions & 0 deletions internal/git/cherrypick.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package git

import (
"emperror.dev/errors"
"fmt"
"strings"
)

type CherryPickResume string

const (
CherryPickContinue CherryPickResume = "continue"
CherryPickSkip CherryPickResume = "skip"
CherryPickQuit CherryPickResume = "quit"
CherryPickAbort CherryPickResume = "abort"
)

type CherryPick struct {
// Commits is a list of commits to apply.
Commits []string

// NoCommit specifies whether or not to cherry-pick without committing
// (equivalent to the --no-commit flag on `git cherry-pick`).
NoCommit bool

// FastForward specifies whether or not to fast-forward the current branch
// if possible (equivalent to the --ff flag on `git cherry-pick`).
// If true, and the parent of the commit is the current HEAD, the HEAD
// will be fast forwarded to the commit (instead of re-applied).
FastForward bool

// Resume specifies how to resume a cherry-pick operation that was
// interrupted by a conflict (equivalent to the --continue, --skip, --quit,
// and --abort flags on `git cherry-pick`).
// Mutually exclusive with all other options.
Resume CherryPickResume
}

type ErrCherryPickConflict struct {
ConflictingCommit string
Output string
}

func (e ErrCherryPickConflict) Error() string {
return fmt.Sprintf("cherry-pick conflict: failed to apply %s", ShortSha(e.ConflictingCommit))
}

// CherryPick applies the given commits on top of the current HEAD.
// If there are conflicts, ErrCherryPickConflict is returned.
func (r *Repo) CherryPick(opts CherryPick) error {
args := []string{"cherry-pick"}

if opts.Resume != "" {
args = append(args, fmt.Sprintf("--%s", opts.Resume))
} else {
if opts.FastForward {
args = append(args, "--ff")
}
if opts.NoCommit {
args = append(args, "--no-commit")
}
args = append(args, opts.Commits...)
}

run, err := r.Run(&RunOpts{
Args: args,
})
if err != nil {
return err
}

if run.ExitCode != 0 {
cherryPickHead, err := r.readGitFile("CHERRY_PICK_HEAD")
if err != nil {
return errors.WrapIff(err, "expected CHERRY_PICK_HEAD to exist after cherry-pick failure")
}
return ErrCherryPickConflict{
ConflictingCommit: strings.TrimSpace(cherryPickHead),
Output: string(run.Stderr),
}
}

return nil
}
42 changes: 42 additions & 0 deletions internal/git/cherrypick_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package git_test

import (
"github.com/aviator-co/av/internal/git"
"github.com/aviator-co/av/internal/git/gittest"
"github.com/aviator-co/av/internal/utils/errutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"testing"
)

func TestRepo_CherryPick(t *testing.T) {
repo := gittest.NewTempRepo(t)

c1 := gittest.CommitFile(t, repo, "file", []byte("first commit\n"))
c2 := gittest.CommitFile(t, repo, "file", []byte("first commit\nsecond commit\n"))

// Switch back to c1 and test that we can cherry-pick c2 on top of it
if _, err := repo.CheckoutBranch(&git.CheckoutBranch{Name: c1}); err != nil {
t.Fatal(err)
}
require.NoError(t, repo.CherryPick(git.CherryPick{Commits: []string{c2}}))
contents, err := os.ReadFile(filepath.Join(repo.Dir(), "file"))
require.NoError(t, err)
assert.Equal(t, "first commit\nsecond commit\n", string(contents))

// Switch back to c1 and check that we can fast-forward to c2
if _, err := repo.CheckoutBranch(&git.CheckoutBranch{Name: c1}); err != nil {
t.Fatal(err)
}
require.NoError(t, repo.CherryPick(git.CherryPick{Commits: []string{c2}, FastForward: true}))
contents, err = os.ReadFile(filepath.Join(repo.Dir(), "file"))
require.NoError(t, err)

// We're back to c2, so trying to cherry-pick c1 should fail.
err = repo.CherryPick(git.CherryPick{Commits: []string{c1}})
conflictErr, ok := errutils.As[git.ErrCherryPickConflict](err)
require.True(t, ok, "expected cherry-pick conflict")
assert.Equal(t, c1, conflictErr.ConflictingCommit)
}
6 changes: 5 additions & 1 deletion internal/git/gittest/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func WithAmend() CommitFileOpt {
}
}

func CommitFile(t *testing.T, repo *git.Repo, filename string, body []byte, cfOpts ...CommitFileOpt) {
func CommitFile(t *testing.T, repo *git.Repo, filename string, body []byte, cfOpts ...CommitFileOpt) string {
opts := commitFileOpts{
msg: fmt.Sprintf("Write %s", filename),
}
Expand All @@ -50,4 +50,8 @@ func CommitFile(t *testing.T, repo *git.Repo, filename string, body []byte, cfOp
}
_, err = repo.Git(args...)
require.NoError(t, err, "failed to commit file: %s", filename)

head, err := repo.RevParse(&git.RevParse{Rev: "HEAD"})
require.NoError(t, err, "failed to get HEAD")
return head
}
15 changes: 15 additions & 0 deletions internal/git/internal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package git

import (
"os"
"path/filepath"
)

// readGitFile reads a file from the .git directory.
func (r *Repo) readGitFile(name string) (string, error) {
data, err := os.ReadFile(filepath.Join(r.GitDir(), name))
if err != nil {
return "", err
}
return string(data), nil
}
26 changes: 24 additions & 2 deletions internal/reorder/cmds.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
package reorder

import (
"fmt"
"github.com/aviator-co/av/internal/git"
"io"
)

// Context is the context of a reorder operation.
// Commands can use the context to access the current state of the reorder
// operation and mutate the context to reflect their changes.
// Commands can use the context to access the current state of the reorder.
type Context struct {
// Repo is the repository the reorder operation is being performed on.
Repo *git.Repo
// State is the current state of the reorder operation.
State State
// Output is the output stream to write interactive messages to.
// Commands should write to this stream instead of stdout/stderr.
Output io.Writer
}

func (c *Context) Print(a ...any) {
_, _ = fmt.Fprint(c.Output, a...)
}

// State is the state of a reorder operation.
// It is meant to be serializable to allow the user to continue/abort a reorder
// operation if there is a conflict.
type State struct {
// The current HEAD of the reorder operation.
Head string
// The name of the current branch in the reorder operation.
Expand Down
10 changes: 9 additions & 1 deletion internal/reorder/errors.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package reorder

import "fmt"
import (
"emperror.dev/errors"
"fmt"
)

type ErrInvalidCmd struct {
Cmd string
Expand All @@ -10,3 +13,8 @@ type ErrInvalidCmd struct {
func (e ErrInvalidCmd) Error() string {
return fmt.Sprintf("invalid %s command: %s", e.Cmd, e.Reason)
}

// ErrInterruptReorder is an error that is returned by Cmd implementations when
// the reorder operation should be suspended (and later resumed with --continue,
// --skip, or --reorder).
var ErrInterruptReorder = errors.Sentinel("interrupt reorder")
38 changes: 34 additions & 4 deletions internal/reorder/pick.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ package reorder

import (
"fmt"
"github.com/aviator-co/av/internal/git"
"github.com/aviator-co/av/internal/utils/colors"
"github.com/aviator-co/av/internal/utils/errutils"
"github.com/kr/text"
"strings"
)

// PickCmd is a command that picks a commit from the history and applies it on
Expand All @@ -10,12 +15,37 @@ type PickCmd struct {
Commit string
}

func (b PickCmd) Execute(ctx *Context) error {
panic("not implemented")
func (p PickCmd) Execute(ctx *Context) error {
err := ctx.Repo.CherryPick(git.CherryPick{
Commits: []string{p.Commit},
// Use FastForward to avoid always amending commits.
FastForward: true,
})
if conflict, ok := errutils.As[git.ErrCherryPickConflict](err); ok {
ctx.Print(
colors.Failure(" - ", conflict.Error(), "\n"),
colors.Faint(text.Indent(strings.TrimRight(conflict.Output, "\n"), " "), "\n"),
)
return ErrInterruptReorder
} else if err != nil {
return err
}

ctx.Print(
colors.Success(" - applied commit "),
colors.UserInput(git.ShortSha(p.Commit)),
colors.Success(" without conflict\n"),
)
head, err := ctx.Repo.RevParse(&git.RevParse{Rev: "HEAD"})
if err != nil {
return err
}
ctx.State.Head = head
return nil
}

func (b PickCmd) String() string {
return fmt.Sprintf("pick %s", b.Commit)
func (p PickCmd) String() string {
return fmt.Sprintf("pick %s", p.Commit)
}

var _ Cmd = &PickCmd{}
Expand Down
32 changes: 32 additions & 0 deletions internal/reorder/pick_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
package reorder

import (
"bytes"
"github.com/aviator-co/av/internal/git"
"github.com/aviator-co/av/internal/git/gittest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
)

func TestPickCmd_String(t *testing.T) {
assert.Equal(t, "pick mycommit", PickCmd{Commit: "mycommit"}.String())
}

func TestPickCmd_Execute(t *testing.T) {
repo := gittest.NewTempRepo(t)
out := &bytes.Buffer{}
ctx := &Context{repo, State{Branch: "main"}, out}

start, err := repo.RevParse(&git.RevParse{Rev: "HEAD"})
require.NoError(t, err)
next := gittest.CommitFile(t, repo, "file", []byte("hello\n"))

t.Run("fast-forward commit", func(t *testing.T) {
_, err = repo.Git("reset", "--hard", start)
require.NoError(t, err)
require.NoError(t, PickCmd{Commit: next}.Execute(ctx), "PickCmd.Execute should succeed with a fast-forward")
require.Equal(t, next, ctx.State.Head, "PickCmd.Execute should update the state's head")
})

t.Run("conflicting commit", func(t *testing.T) {
out.Reset()
_, err = repo.Git("reset", "--hard", start)
require.NoError(t, err)
gittest.CommitFile(t, repo, "file", []byte("bonjour\n"))
// Trying to re-apply the commit `next` should be a conflict
err := PickCmd{Commit: next}.Execute(ctx)
require.ErrorIs(t, err, ErrInterruptReorder, "PickCmd.Execute should return ErrInterruptReorder on conflict")
require.Contains(t, out.String(), git.ShortSha(next), "PickCmd.Execute should print the conflicting commit")
})
}
17 changes: 17 additions & 0 deletions internal/utils/errutils/as.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package errutils

import "emperror.dev/errors"

// As is a wrapper around errors.As using generics that returns the concrete
// error type if err is of type T.
func As[T error](err error) (T, bool) {
var concreteErr T
if err == nil {
return concreteErr, false
}
if errors.As(err, &concreteErr) {
return concreteErr, true
} else {
return concreteErr, false
}
}

0 comments on commit f0cf48c

Please sign in to comment.