refac(branch checkout): extract checkout logic into a handler (#740)

This seems like further reinforcement of the handler idea.
The commands that need to use checkout functionality
(and verify branch is restacked after checkout)
now get this for free without `(&branchCheckoutCmd{...}).Run(...)`
and worrying about the N arguments it takes.
This commit is contained in:
Abhinav Gupta
2025-07-12 13:58:59 -07:00
committed by GitHub
parent 899e264c3b
commit b271f9f65e
13 changed files with 1030 additions and 129 deletions
+7 -10
View File
@@ -6,15 +6,14 @@ import (
"fmt"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/handler/checkout"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
"go.abhg.dev/gs/internal/ui"
)
type bottomCmd struct {
checkoutOptions
checkout.Options
}
func (*bottomCmd) Help() string {
@@ -26,12 +25,10 @@ func (*bottomCmd) Help() string {
func (cmd *bottomCmd) Run(
ctx context.Context,
log *silog.Logger,
view ui.View,
wt *git.Worktree,
store *state.Store,
svc *spice.Service,
trackHandler TrackHandler,
checkoutHandler CheckoutHandler,
) error {
current, err := wt.CurrentBranch(ctx)
if err != nil {
@@ -48,8 +45,8 @@ func (cmd *bottomCmd) Run(
return fmt.Errorf("find bottom: %w", err)
}
return (&branchCheckoutCmd{
checkoutOptions: cmd.checkoutOptions,
Branch: bottom,
}).Run(ctx, log, view, wt, store, svc, trackHandler)
return checkoutHandler.CheckoutBranch(ctx, &checkout.Request{
Branch: bottom,
Options: &cmd.Options,
})
}
+26 -66
View File
@@ -2,25 +2,17 @@ package main
import (
"context"
"errors"
"fmt"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/track"
"go.abhg.dev/gs/internal/handler/checkout"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
"go.abhg.dev/gs/internal/ui"
)
type checkoutOptions struct {
DryRun bool `short:"n" xor:"detach-or-dry-run" help:"Print the target branch without checking it out"`
Detach bool `xor:"detach-or-dry-run" help:"Detach HEAD after checking out"`
}
type branchCheckoutCmd struct {
checkoutOptions
checkout.Options
BranchPromptConfig
// Allow users to opt out of the "branch not tracked" prompt.
@@ -82,68 +74,36 @@ func (cmd *branchCheckoutCmd) AfterApply(
return nil
}
// CheckoutHandler allows checking out branches.
type CheckoutHandler interface {
CheckoutBranch(ctx context.Context, req *checkout.Request) error
}
func (cmd *branchCheckoutCmd) Run(
ctx context.Context,
log *silog.Logger,
view ui.View,
wt *git.Worktree,
store *state.Store,
svc *spice.Service,
trackHandler TrackHandler,
handler CheckoutHandler,
) error {
if cmd.Branch != store.Trunk() {
if err := svc.VerifyRestacked(ctx, cmd.Branch); err != nil {
var restackErr *spice.BranchNeedsRestackError
switch {
case errors.As(err, &restackErr):
log.Warnf("%v: needs to be restacked: run 'gs branch restack --branch=%v'", cmd.Branch, cmd.Branch)
case errors.Is(err, state.ErrNotExist):
if !ui.Interactive(view) || !cmd.TrackUntrackedPrompt {
log.Warnf("%v: branch not tracked: run 'gs branch track'", cmd.Branch)
break
}
log.Warnf("%v: branch not tracked", cmd.Branch)
shouldTrack := true
prompt := ui.NewConfirm().
WithValue(&shouldTrack).
WithTitle("Do you want to track this branch now?")
if err := ui.Run(view, prompt); err != nil {
return fmt.Errorf("prompt: %w", err)
}
if shouldTrack {
err := trackHandler.AddBranch(ctx, &track.AddBranchRequest{
Branch: cmd.Branch,
})
if err != nil {
return fmt.Errorf("track branch: %w", err)
}
}
case errors.Is(err, git.ErrNotExist):
return fmt.Errorf("branch %q does not exist", cmd.Branch)
default:
log.Warnf("error checking branch: %v", err)
return handler.CheckoutBranch(ctx, &checkout.Request{
Branch: cmd.Branch,
Options: &cmd.Options,
ShouldTrack: func(branch string) (bool, error) {
if !ui.Interactive(view) || !cmd.TrackUntrackedPrompt {
log.Warnf("%v: branch not tracked: run 'gs branch track'", branch)
return false, nil
}
}
}
if cmd.DryRun {
fmt.Println(cmd.Branch)
return nil
}
log.Warnf("%v: branch not tracked", branch)
shouldTrack := true
prompt := ui.NewConfirm().
WithValue(&shouldTrack).
WithTitle("Do you want to track this branch now?")
if err := ui.Run(view, prompt); err != nil {
return false, fmt.Errorf("prompt: %w", err)
}
if cmd.Detach {
if err := wt.DetachHead(ctx, cmd.Branch); err != nil {
return fmt.Errorf("detach HEAD: %w", err)
}
return nil
}
if err := wt.Checkout(ctx, cmd.Branch); err != nil {
return fmt.Errorf("checkout branch: %w", err)
}
return nil
return shouldTrack, nil
},
})
}
+3 -4
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/checkout"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
@@ -35,7 +36,7 @@ func (cmd *branchFoldCmd) Run(
wt *git.Worktree,
store *state.Store,
svc *spice.Service,
trackHandler TrackHandler,
checkoutHandler CheckoutHandler,
) error {
if cmd.Branch == "" {
currentBranch, err := wt.CurrentBranch(ctx)
@@ -131,9 +132,7 @@ func (cmd *branchFoldCmd) Run(
}
// Check out base and delete the branch we are folding.
if err := (&branchCheckoutCmd{Branch: b.Base}).Run(
ctx, log, view, wt, store, svc, trackHandler,
); err != nil {
if err := checkoutHandler.CheckoutBranch(ctx, &checkout.Request{Branch: b.Base}); err != nil {
return fmt.Errorf("checkout base: %w", err)
}
+7 -8
View File
@@ -5,15 +5,15 @@ import (
"fmt"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/checkout"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
"go.abhg.dev/gs/internal/ui"
)
type downCmd struct {
checkoutOptions
checkout.Options
N int `arg:"" optional:"" help:"Number of branches to move up." default:"1"`
}
@@ -30,11 +30,10 @@ func (*downCmd) Help() string {
func (cmd *downCmd) Run(
ctx context.Context,
log *silog.Logger,
view ui.View,
wt *git.Worktree,
store *state.Store,
svc *spice.Service,
trackHandler TrackHandler,
checkoutHandler CheckoutHandler,
) error {
current, err := wt.CurrentBranch(ctx)
if err != nil {
@@ -73,8 +72,8 @@ outer:
current = below
}
return (&branchCheckoutCmd{
checkoutOptions: cmd.checkoutOptions,
Branch: below,
}).Run(ctx, log, view, wt, store, svc, trackHandler)
return checkoutHandler.CheckoutBranch(ctx, &checkout.Request{
Branch: below,
Options: &cmd.Options,
})
}
+4 -5
View File
@@ -7,12 +7,12 @@ import (
"slices"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/checkout"
"go.abhg.dev/gs/internal/must"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
"go.abhg.dev/gs/internal/ui"
)
type downstackEditCmd struct {
@@ -39,12 +39,11 @@ func (*downstackEditCmd) Help() string {
func (cmd *downstackEditCmd) Run(
ctx context.Context,
log *silog.Logger,
view ui.View,
repo *git.Repository,
wt *git.Worktree,
store *state.Store,
svc *spice.Service,
trackHandler TrackHandler,
checkoutHandler CheckoutHandler,
) error {
if cmd.Editor == "" {
cmd.Editor = gitEditor(ctx, repo)
@@ -92,7 +91,7 @@ func (cmd *downstackEditCmd) Run(
return fmt.Errorf("edit downstack: %w", err)
}
return (&branchCheckoutCmd{
return checkoutHandler.CheckoutBranch(ctx, &checkout.Request{
Branch: res.Stack[len(res.Stack)-1],
}).Run(ctx, log, view, wt, store, svc, trackHandler)
})
}
+137
View File
@@ -0,0 +1,137 @@
// Package checkout implements a Handler to change branches in a stack.
package checkout
import (
"cmp"
"context"
"errors"
"fmt"
"io"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/track"
"go.abhg.dev/gs/internal/must"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
)
//go:generate mockgen -destination mocks_test.go -package checkout -typed . GitWorktree,TrackHandler,Service,Store
// Options defines options for checking out a branch.
// These turn into command line flags, so be mindful of what you add here.
type Options struct {
DryRun bool `short:"n" xor:"detach-or-dry-run" help:"Print the target branch without checking it out"`
Detach bool `xor:"detach-or-dry-run" help:"Detach HEAD after checking out"`
}
// Store provides access to the git-spice state.
type Store interface {
// Trunk returns the name of the trunk branch.
Trunk() string
}
// GitWorktree allows changing which branch or commit
// is checked out in the current working tree.
type GitWorktree interface {
DetachHead(ctx context.Context, commitish string) error
Checkout(ctx context.Context, branch string) error
}
// TrackHandler allows tracking new branches with git-spice.
type TrackHandler interface {
AddBranch(ctx context.Context, req *track.AddBranchRequest) error
}
// Service provides access to the spice service methods
type Service interface {
// VerifyRestacked checks if the branch is restacked.
VerifyRestacked(ctx context.Context, branch string) error
}
// Handler provides a central place for handling checkout operations.
type Handler struct {
Stdout io.Writer // required
Log *silog.Logger // required
Store Store // required
Worktree GitWorktree // required
Track TrackHandler // required
Service Service // required
}
// Request is a request to checkout a branch.
type Request struct {
// Branch is the name of the branch to checkout.
Branch string // required
// Options are the options for checking out the branch.
Options *Options // optional
// ShouldTrack is called if the branch being checked out is untracked,
// and allows the caller to decide if the branch should be tracked.
ShouldTrack func(branch string) (bool, error) // optional
}
// CheckoutBranch checks out the specified branch with git-spice,
// offering to track it if it's not already tracked.
func (h *Handler) CheckoutBranch(ctx context.Context, req *Request) error {
branch := req.Branch
opts := cmp.Or(req.Options, &Options{})
if req.ShouldTrack == nil {
req.ShouldTrack = func(string) (bool, error) {
return false, nil
}
}
must.NotBeBlankf(branch, "branch name must not be blank")
must.NotBef(opts.DryRun && opts.Detach, "cannot use both dry-run and detach options")
log := h.Log
if branch != h.Store.Trunk() {
if err := h.Service.VerifyRestacked(ctx, branch); err != nil {
var restackErr *spice.BranchNeedsRestackError
switch {
case errors.As(err, &restackErr):
log.Warnf("%v: needs to be restacked: run 'gs branch restack --branch=%v'", branch, branch)
case errors.Is(err, state.ErrNotExist):
shouldTrack, err := req.ShouldTrack(branch)
if err != nil {
return fmt.Errorf("check if branch should be tracked: %w", err)
}
if shouldTrack {
err := h.Track.AddBranch(ctx, &track.AddBranchRequest{
Branch: branch,
})
if err != nil {
log.Warn("Error tracking branch", "branch", branch, "error", err)
}
}
case errors.Is(err, git.ErrNotExist):
return fmt.Errorf("branch %q does not exist", branch)
default:
log.Warn("Unable to check if branch is restacked",
"branch", branch, "error", err)
}
}
}
if opts.DryRun {
_, _ = fmt.Fprintln(h.Stdout, branch)
return nil
}
if opts.Detach {
if err := h.Worktree.DetachHead(ctx, branch); err != nil {
return fmt.Errorf("detach HEAD: %w", err)
}
return nil
}
if err := h.Worktree.Checkout(ctx, branch); err != nil {
return fmt.Errorf("checkout branch: %w", err)
}
return nil
}
+501
View File
@@ -0,0 +1,501 @@
package checkout
import (
"bytes"
"errors"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/track"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.uber.org/mock/gomock"
)
func TestHandler_CheckoutBranch_Trunk(t *testing.T) {
mockStore := NewMockStore(gomock.NewController(t))
mockStore.
EXPECT().
Trunk().
Return("main").
AnyTimes()
t.Run("Normal", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockWorktree := NewMockGitWorktree(ctrl)
handler := &Handler{
Stdout: io.Discard,
Log: silog.Nop(),
Store: mockStore,
Worktree: mockWorktree,
Track: NewMockTrackHandler(ctrl),
Service: NewMockService(ctrl),
}
mockWorktree.
EXPECT().
Checkout(gomock.Any(), "main").
Return(nil)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "main",
})
assert.NoError(t, err)
})
t.Run("DryRun", func(t *testing.T) {
ctrl := gomock.NewController(t)
var stdout bytes.Buffer
handler := &Handler{
Stdout: &stdout,
Log: silog.Nop(),
Store: mockStore,
Worktree: NewMockGitWorktree(ctrl),
Track: NewMockTrackHandler(ctrl),
Service: NewMockService(ctrl),
}
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "main",
Options: &Options{DryRun: true},
})
assert.NoError(t, err)
assert.Equal(t, "main\n", stdout.String())
})
t.Run("Detach", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockWorktree := NewMockGitWorktree(ctrl)
handler := &Handler{
Stdout: io.Discard,
Log: silog.Nop(),
Store: mockStore,
Worktree: mockWorktree,
Track: NewMockTrackHandler(ctrl),
Service: NewMockService(ctrl),
}
mockWorktree.
EXPECT().
DetachHead(gomock.Any(), "main").
Return(nil)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "main",
Options: &Options{Detach: true},
})
assert.NoError(t, err)
})
}
func TestHandler_CheckoutBranch_NonTrunk(t *testing.T) {
mockStore := NewMockStore(gomock.NewController(t))
mockStore.
EXPECT().
Trunk().
Return("main").
AnyTimes()
t.Run("AlreadyRestacked", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockWorktree := NewMockGitWorktree(ctrl)
mockService := NewMockService(ctrl)
handler := &Handler{
Stdout: io.Discard,
Log: silog.Nop(),
Store: mockStore,
Worktree: mockWorktree,
Track: NewMockTrackHandler(ctrl),
Service: mockService,
}
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(nil)
mockWorktree.
EXPECT().
Checkout(gomock.Any(), "feature").
Return(nil)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "feature",
})
assert.NoError(t, err)
})
t.Run("DryRun", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockWorktree := NewMockGitWorktree(ctrl)
mockTrack := NewMockTrackHandler(ctrl)
mockService := NewMockService(ctrl)
var stdout bytes.Buffer
handler := &Handler{
Stdout: &stdout,
Log: silog.Nop(),
Store: mockStore,
Worktree: mockWorktree,
Track: mockTrack,
Service: mockService,
}
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(nil)
req := &Request{
Branch: "feature",
Options: &Options{DryRun: true},
}
err := handler.CheckoutBranch(t.Context(), req)
assert.NoError(t, err)
assert.Equal(t, "feature\n", stdout.String())
})
t.Run("Detach", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockWorktree := NewMockGitWorktree(ctrl)
mockService := NewMockService(ctrl)
handler := &Handler{
Stdout: io.Discard,
Log: silog.Nop(),
Store: mockStore,
Worktree: mockWorktree,
Track: NewMockTrackHandler(ctrl),
Service: mockService,
}
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(nil)
mockWorktree.
EXPECT().
DetachHead(gomock.Any(), "feature").
Return(nil)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "feature",
Options: &Options{Detach: true},
})
assert.NoError(t, err)
})
t.Run("NeedsRestack", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockWorktree := NewMockGitWorktree(ctrl)
mockService := NewMockService(ctrl)
var logBuffer bytes.Buffer
handler := &Handler{
Stdout: io.Discard,
Log: silog.New(&logBuffer, nil),
Store: mockStore,
Worktree: mockWorktree,
Track: NewMockTrackHandler(ctrl),
Service: mockService,
}
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(&spice.BranchNeedsRestackError{
Base: "main",
BaseHash: git.Hash("abc123"),
})
mockWorktree.
EXPECT().
Checkout(gomock.Any(), "feature").
Return(nil)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "feature",
})
assert.NoError(t, err)
assert.Contains(t, logBuffer.String(), "needs to be restacked")
assert.Contains(t, logBuffer.String(), "gs branch restack --branch=feature")
})
t.Run("NotTrackedButShouldTrack", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockWorktree := NewMockGitWorktree(ctrl)
mockTrack := NewMockTrackHandler(ctrl)
mockService := NewMockService(ctrl)
handler := &Handler{
Stdout: io.Discard,
Log: silog.Nop(),
Store: mockStore,
Worktree: mockWorktree,
Track: mockTrack,
Service: mockService,
}
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(state.ErrNotExist)
mockTrack.
EXPECT().
AddBranch(gomock.Any(), &track.AddBranchRequest{
Branch: "feature",
}).
Return(nil)
mockWorktree.
EXPECT().
Checkout(gomock.Any(), "feature").
Return(nil)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "feature",
ShouldTrack: func(string) (bool, error) {
return true, nil
},
})
assert.NoError(t, err)
})
t.Run("NotTrackedNotRequested", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockWorktree := NewMockGitWorktree(ctrl)
mockService := NewMockService(ctrl)
handler := &Handler{
Stdout: io.Discard,
Log: silog.Nop(),
Store: mockStore,
Worktree: mockWorktree,
Track: NewMockTrackHandler(ctrl),
Service: mockService,
}
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(state.ErrNotExist)
mockWorktree.
EXPECT().
Checkout(gomock.Any(), "feature").
Return(nil)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "feature",
})
assert.NoError(t, err)
})
t.Run("BranchDoesNotExist", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockService := NewMockService(ctrl)
handler := &Handler{
Stdout: io.Discard,
Log: silog.Nop(),
Store: mockStore,
Worktree: NewMockGitWorktree(ctrl),
Track: NewMockTrackHandler(ctrl),
Service: mockService,
}
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(git.ErrNotExist)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "feature",
})
assert.Error(t, err)
assert.ErrorContains(t, err, `branch "feature" does not exist`)
})
t.Run("OtherVerifyError", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockWorktree := NewMockGitWorktree(ctrl)
mockService := NewMockService(ctrl)
var logBuffer bytes.Buffer
handler := &Handler{
Stdout: io.Discard,
Log: silog.New(&logBuffer, nil),
Store: mockStore,
Worktree: mockWorktree,
Track: NewMockTrackHandler(ctrl),
Service: mockService,
}
unknownError := errors.New("some other error")
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(unknownError)
mockWorktree.
EXPECT().
Checkout(gomock.Any(), "feature").
Return(nil)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "feature",
})
assert.NoError(t, err)
assert.Contains(t, logBuffer.String(), "Unable to check if branch is restacked")
})
}
func TestHandler_CheckoutBranch_EdgeCases(t *testing.T) {
mockStore := NewMockStore(gomock.NewController(t))
mockStore.
EXPECT().
Trunk().
Return("main").
AnyTimes()
t.Run("ShouldTrackError", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockService := NewMockService(ctrl)
handler := &Handler{
Stdout: io.Discard,
Log: silog.Nop(),
Store: mockStore,
Worktree: NewMockGitWorktree(ctrl),
Track: NewMockTrackHandler(ctrl),
Service: mockService,
}
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(state.ErrNotExist)
shouldTrackError := errors.New("should track error")
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "feature",
ShouldTrack: func(string) (bool, error) {
return false, shouldTrackError
},
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "check if branch should be tracked")
assert.ErrorIs(t, err, shouldTrackError)
})
t.Run("TrackError", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockTrack := NewMockTrackHandler(ctrl)
mockService := NewMockService(ctrl)
mockWorktree := NewMockGitWorktree(ctrl)
var logBuffer bytes.Buffer
handler := &Handler{
Stdout: io.Discard,
Log: silog.New(&logBuffer, nil),
Store: mockStore,
Worktree: mockWorktree,
Track: mockTrack,
Service: mockService,
}
trackError := errors.New("track error")
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(state.ErrNotExist)
mockTrack.
EXPECT().
AddBranch(gomock.Any(), &track.AddBranchRequest{
Branch: "feature",
}).
Return(trackError)
mockWorktree.
EXPECT().
Checkout(gomock.Any(), "feature").
Return(nil)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "feature",
ShouldTrack: func(string) (bool, error) {
return true, nil
},
})
require.NoError(t, err)
assert.Contains(t, logBuffer.String(), "Error tracking branch")
})
t.Run("CheckoutError", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockWorktree := NewMockGitWorktree(ctrl)
mockService := NewMockService(ctrl)
handler := &Handler{
Stdout: io.Discard,
Log: silog.Nop(),
Store: mockStore,
Worktree: mockWorktree,
Track: NewMockTrackHandler(ctrl),
Service: mockService,
}
checkoutError := errors.New("checkout error")
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(nil)
mockWorktree.
EXPECT().
Checkout(gomock.Any(), "feature").
Return(checkoutError)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "feature",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "checkout branch")
assert.ErrorIs(t, err, checkoutError)
})
t.Run("DetachError", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockWorktree := NewMockGitWorktree(ctrl)
mockService := NewMockService(ctrl)
handler := &Handler{
Stdout: io.Discard,
Log: silog.Nop(),
Store: mockStore,
Worktree: mockWorktree,
Track: NewMockTrackHandler(ctrl),
Service: mockService,
}
detachError := errors.New("detach error")
mockService.
EXPECT().
VerifyRestacked(gomock.Any(), "feature").
Return(nil)
mockWorktree.
EXPECT().
DetachHead(gomock.Any(), "feature").
Return(detachError)
err := handler.CheckoutBranch(t.Context(), &Request{
Branch: "feature",
Options: &Options{Detach: true},
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "detach HEAD")
assert.ErrorIs(t, err, detachError)
})
}
+304
View File
@@ -0,0 +1,304 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: go.abhg.dev/gs/internal/handler/checkout (interfaces: GitWorktree,TrackHandler,Service,Store)
//
// Generated by this command:
//
// mockgen -destination mocks_test.go -package checkout -typed . GitWorktree,TrackHandler,Service,Store
//
// Package checkout is a generated GoMock package.
package checkout
import (
context "context"
reflect "reflect"
track "go.abhg.dev/gs/internal/handler/track"
gomock "go.uber.org/mock/gomock"
)
// MockGitWorktree is a mock of GitWorktree interface.
type MockGitWorktree struct {
ctrl *gomock.Controller
recorder *MockGitWorktreeMockRecorder
isgomock struct{}
}
// MockGitWorktreeMockRecorder is the mock recorder for MockGitWorktree.
type MockGitWorktreeMockRecorder struct {
mock *MockGitWorktree
}
// NewMockGitWorktree creates a new mock instance.
func NewMockGitWorktree(ctrl *gomock.Controller) *MockGitWorktree {
mock := &MockGitWorktree{ctrl: ctrl}
mock.recorder = &MockGitWorktreeMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockGitWorktree) EXPECT() *MockGitWorktreeMockRecorder {
return m.recorder
}
// Checkout mocks base method.
func (m *MockGitWorktree) Checkout(ctx context.Context, branch string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Checkout", ctx, branch)
ret0, _ := ret[0].(error)
return ret0
}
// Checkout indicates an expected call of Checkout.
func (mr *MockGitWorktreeMockRecorder) Checkout(ctx, branch any) *MockGitWorktreeCheckoutCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Checkout", reflect.TypeOf((*MockGitWorktree)(nil).Checkout), ctx, branch)
return &MockGitWorktreeCheckoutCall{Call: call}
}
// MockGitWorktreeCheckoutCall wrap *gomock.Call
type MockGitWorktreeCheckoutCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGitWorktreeCheckoutCall) Return(arg0 error) *MockGitWorktreeCheckoutCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGitWorktreeCheckoutCall) Do(f func(context.Context, string) error) *MockGitWorktreeCheckoutCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGitWorktreeCheckoutCall) DoAndReturn(f func(context.Context, string) error) *MockGitWorktreeCheckoutCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// DetachHead mocks base method.
func (m *MockGitWorktree) DetachHead(ctx context.Context, commitish string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DetachHead", ctx, commitish)
ret0, _ := ret[0].(error)
return ret0
}
// DetachHead indicates an expected call of DetachHead.
func (mr *MockGitWorktreeMockRecorder) DetachHead(ctx, commitish any) *MockGitWorktreeDetachHeadCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetachHead", reflect.TypeOf((*MockGitWorktree)(nil).DetachHead), ctx, commitish)
return &MockGitWorktreeDetachHeadCall{Call: call}
}
// MockGitWorktreeDetachHeadCall wrap *gomock.Call
type MockGitWorktreeDetachHeadCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockGitWorktreeDetachHeadCall) Return(arg0 error) *MockGitWorktreeDetachHeadCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockGitWorktreeDetachHeadCall) Do(f func(context.Context, string) error) *MockGitWorktreeDetachHeadCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockGitWorktreeDetachHeadCall) DoAndReturn(f func(context.Context, string) error) *MockGitWorktreeDetachHeadCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockTrackHandler is a mock of TrackHandler interface.
type MockTrackHandler struct {
ctrl *gomock.Controller
recorder *MockTrackHandlerMockRecorder
isgomock struct{}
}
// MockTrackHandlerMockRecorder is the mock recorder for MockTrackHandler.
type MockTrackHandlerMockRecorder struct {
mock *MockTrackHandler
}
// NewMockTrackHandler creates a new mock instance.
func NewMockTrackHandler(ctrl *gomock.Controller) *MockTrackHandler {
mock := &MockTrackHandler{ctrl: ctrl}
mock.recorder = &MockTrackHandlerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockTrackHandler) EXPECT() *MockTrackHandlerMockRecorder {
return m.recorder
}
// AddBranch mocks base method.
func (m *MockTrackHandler) AddBranch(ctx context.Context, req *track.AddBranchRequest) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AddBranch", ctx, req)
ret0, _ := ret[0].(error)
return ret0
}
// AddBranch indicates an expected call of AddBranch.
func (mr *MockTrackHandlerMockRecorder) AddBranch(ctx, req any) *MockTrackHandlerAddBranchCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddBranch", reflect.TypeOf((*MockTrackHandler)(nil).AddBranch), ctx, req)
return &MockTrackHandlerAddBranchCall{Call: call}
}
// MockTrackHandlerAddBranchCall wrap *gomock.Call
type MockTrackHandlerAddBranchCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockTrackHandlerAddBranchCall) Return(arg0 error) *MockTrackHandlerAddBranchCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockTrackHandlerAddBranchCall) Do(f func(context.Context, *track.AddBranchRequest) error) *MockTrackHandlerAddBranchCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockTrackHandlerAddBranchCall) DoAndReturn(f func(context.Context, *track.AddBranchRequest) error) *MockTrackHandlerAddBranchCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockService is a mock of Service interface.
type MockService struct {
ctrl *gomock.Controller
recorder *MockServiceMockRecorder
isgomock struct{}
}
// MockServiceMockRecorder is the mock recorder for MockService.
type MockServiceMockRecorder struct {
mock *MockService
}
// NewMockService creates a new mock instance.
func NewMockService(ctrl *gomock.Controller) *MockService {
mock := &MockService{ctrl: ctrl}
mock.recorder = &MockServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockService) EXPECT() *MockServiceMockRecorder {
return m.recorder
}
// VerifyRestacked mocks base method.
func (m *MockService) VerifyRestacked(ctx context.Context, branch string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "VerifyRestacked", ctx, branch)
ret0, _ := ret[0].(error)
return ret0
}
// VerifyRestacked indicates an expected call of VerifyRestacked.
func (mr *MockServiceMockRecorder) VerifyRestacked(ctx, branch any) *MockServiceVerifyRestackedCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyRestacked", reflect.TypeOf((*MockService)(nil).VerifyRestacked), ctx, branch)
return &MockServiceVerifyRestackedCall{Call: call}
}
// MockServiceVerifyRestackedCall wrap *gomock.Call
type MockServiceVerifyRestackedCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockServiceVerifyRestackedCall) Return(arg0 error) *MockServiceVerifyRestackedCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockServiceVerifyRestackedCall) Do(f func(context.Context, string) error) *MockServiceVerifyRestackedCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockServiceVerifyRestackedCall) DoAndReturn(f func(context.Context, string) error) *MockServiceVerifyRestackedCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
// MockStore is a mock of Store interface.
type MockStore struct {
ctrl *gomock.Controller
recorder *MockStoreMockRecorder
isgomock struct{}
}
// MockStoreMockRecorder is the mock recorder for MockStore.
type MockStoreMockRecorder struct {
mock *MockStore
}
// NewMockStore creates a new mock instance.
func NewMockStore(ctrl *gomock.Controller) *MockStore {
mock := &MockStore{ctrl: ctrl}
mock.recorder = &MockStoreMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockStore) EXPECT() *MockStoreMockRecorder {
return m.recorder
}
// Trunk mocks base method.
func (m *MockStore) Trunk() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Trunk")
ret0, _ := ret[0].(string)
return ret0
}
// Trunk indicates an expected call of Trunk.
func (mr *MockStoreMockRecorder) Trunk() *MockStoreTrunkCall {
mr.mock.ctrl.T.Helper()
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Trunk", reflect.TypeOf((*MockStore)(nil).Trunk))
return &MockStoreTrunkCall{Call: call}
}
// MockStoreTrunkCall wrap *gomock.Call
type MockStoreTrunkCall struct {
*gomock.Call
}
// Return rewrite *gomock.Call.Return
func (c *MockStoreTrunkCall) Return(arg0 string) *MockStoreTrunkCall {
c.Call = c.Call.Return(arg0)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockStoreTrunkCall) Do(f func() string) *MockStoreTrunkCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockStoreTrunkCall) DoAndReturn(f func() string) *MockStoreTrunkCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
+17
View File
@@ -20,6 +20,7 @@ import (
"go.abhg.dev/gs/internal/forge/github"
"go.abhg.dev/gs/internal/forge/gitlab"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/checkout"
"go.abhg.dev/gs/internal/handler/track"
"go.abhg.dev/gs/internal/secret"
"go.abhg.dev/gs/internal/silog"
@@ -347,6 +348,22 @@ func (cmd *mainCmd) AfterApply(ctx context.Context, kctx *kong.Context, logger *
Service: svc,
}, nil
}),
kctx.BindSingletonProvider(func(
log *silog.Logger,
store *state.Store,
wt *git.Worktree,
svc *spice.Service,
trackHandler TrackHandler,
) (CheckoutHandler, error) {
return &checkout.Handler{
Stdout: kctx.Stdout,
Log: log,
Store: store,
Worktree: wt,
Track: trackHandler,
Service: svc,
}, nil
}),
)
}
+3 -6
View File
@@ -7,11 +7,11 @@ import (
"strings"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/checkout"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
"go.abhg.dev/gs/internal/ui"
)
type stackEditCmd struct {
@@ -39,12 +39,11 @@ func (*stackEditCmd) Help() string {
func (cmd *stackEditCmd) Run(
ctx context.Context,
log *silog.Logger,
view ui.View,
repo *git.Repository,
wt *git.Worktree,
store *state.Store,
svc *spice.Service,
trackHandler TrackHandler,
checkoutHandler CheckoutHandler,
) error {
if cmd.Editor == "" {
cmd.Editor = gitEditor(ctx, repo)
@@ -97,7 +96,5 @@ func (cmd *stackEditCmd) Run(
return fmt.Errorf("edit downstack: %w", err)
}
return (&branchCheckoutCmd{
Branch: cmd.Branch,
}).Run(ctx, log, view, wt, store, svc, trackHandler)
return checkoutHandler.CheckoutBranch(ctx, &checkout.Request{Branch: cmd.Branch})
}
+7 -8
View File
@@ -5,17 +5,17 @@ import (
"fmt"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/checkout"
"go.abhg.dev/gs/internal/must"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
"go.abhg.dev/gs/internal/ui"
"go.abhg.dev/gs/internal/ui/widget"
)
type topCmd struct {
checkoutOptions
checkout.Options
}
func (*topCmd) Help() string {
@@ -32,9 +32,8 @@ func (cmd *topCmd) Run(
log *silog.Logger,
view ui.View,
wt *git.Worktree,
store *state.Store,
svc *spice.Service,
trackHandler TrackHandler,
checkoutHandler CheckoutHandler,
) error {
current, err := wt.CurrentBranch(ctx)
if err != nil {
@@ -81,8 +80,8 @@ func (cmd *topCmd) Run(
return nil
}
return (&branchCheckoutCmd{
checkoutOptions: cmd.checkoutOptions,
Branch: branch,
}).Run(ctx, log, view, wt, store, svc, trackHandler)
return checkoutHandler.CheckoutBranch(ctx, &checkout.Request{
Branch: branch,
Options: &cmd.Options,
})
}
+7 -14
View File
@@ -3,29 +3,22 @@ package main
import (
"context"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/handler/checkout"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/ui"
)
type trunkCmd struct {
checkoutOptions
checkout.Options
}
func (cmd *trunkCmd) Run(
ctx context.Context,
log *silog.Logger,
view ui.View,
wt *git.Worktree,
store *state.Store,
svc *spice.Service,
trackHandler TrackHandler,
checkoutHandler CheckoutHandler,
) error {
trunk := store.Trunk()
return (&branchCheckoutCmd{
checkoutOptions: cmd.checkoutOptions,
Branch: trunk,
}).Run(ctx, log, view, wt, store, svc, trackHandler)
return checkoutHandler.CheckoutBranch(ctx, &checkout.Request{
Branch: trunk,
Options: &cmd.Options,
})
}
+7 -8
View File
@@ -5,16 +5,16 @@ import (
"fmt"
"go.abhg.dev/gs/internal/git"
"go.abhg.dev/gs/internal/handler/checkout"
"go.abhg.dev/gs/internal/silog"
"go.abhg.dev/gs/internal/spice"
"go.abhg.dev/gs/internal/spice/state"
"go.abhg.dev/gs/internal/text"
"go.abhg.dev/gs/internal/ui"
"go.abhg.dev/gs/internal/ui/widget"
)
type upCmd struct {
checkoutOptions
checkout.Options
N int `arg:"" optional:"" help:"Number of branches to move up." default:"1"`
}
@@ -33,9 +33,8 @@ func (cmd *upCmd) Run(
log *silog.Logger,
view ui.View,
wt *git.Worktree,
store *state.Store,
svc *spice.Service,
trackHandler TrackHandler,
checkoutHandler CheckoutHandler,
) error {
current, err := wt.CurrentBranch(ctx)
if err != nil {
@@ -90,8 +89,8 @@ outer:
current = branch
}
return (&branchCheckoutCmd{
checkoutOptions: cmd.checkoutOptions,
Branch: branch,
}).Run(ctx, log, view, wt, store, svc, trackHandler)
return checkoutHandler.CheckoutBranch(ctx, &checkout.Request{
Branch: branch,
Options: &cmd.Options,
})
}