Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add restack command #293

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/av/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func init() {
stackPrevCmd,
stackReorderCmd,
stackReparentCmd,
stackRestackCmd,
stackSubmitCmd,
stackSwitchCmd,
stackSyncCmd,
Expand Down
272 changes: 272 additions & 0 deletions cmd/av/stack_restack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
package main

import (
"os"
"strings"

"emperror.dev/errors"
"github.com/aviator-co/av/internal/actions"
"github.com/aviator-co/av/internal/git"
"github.com/aviator-co/av/internal/meta"
"github.com/aviator-co/av/internal/sequencer"
"github.com/aviator-co/av/internal/sequencer/planner"
"github.com/aviator-co/av/internal/utils/colors"
"github.com/aviator-co/av/internal/utils/stackutils"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/go-git/go-git/v5/plumbing"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
)

var stackRestackFlags struct {
DryRun bool
Abort bool
Continue bool
Skip bool
}

var stackRestackCmd = &cobra.Command{
Use: "restack",
Short: "Restack branches",
Args: cobra.NoArgs,
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
}

var opts []tea.ProgramOption
if !isatty.IsTerminal(os.Stdout.Fd()) {
opts = []tea.ProgramOption{
tea.WithInput(nil),
}
}
p := tea.NewProgram(stackRestackViewModel{
repo: repo,
db: db,
spinner: spinner.New(spinner.WithSpinner(spinner.Dot)),
}, opts...)
model, err := p.Run()
if err != nil {
return err
}
if err := model.(stackRestackViewModel).err; err != nil {
return actions.ErrExitSilently{ExitCode: 1}
}
if s := model.(stackRestackViewModel).rebaseConflictErrorHeadline; s != "" {
return actions.ErrExitSilently{ExitCode: 1}
}
return nil
},
}

type stackRestackState struct {
InitialBranch string
StNode *stackutils.StackTreeNode
Seq *sequencer.Sequencer
}

type stackRestackSeqResult struct {
result *git.RebaseResult
err error
}

type stackRestackViewModel struct {
repo *git.Repo
db meta.DB
state *stackRestackState
spinner spinner.Model

rebaseConflictErrorHeadline string
rebaseConflictHint string
abortedBranch plumbing.ReferenceName
err error
}

func (vm stackRestackViewModel) Init() tea.Cmd {
return tea.Batch(vm.spinner.Tick, vm.initCmd)
}

func (vm stackRestackViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case error:
vm.err = msg
return vm, tea.Quit
case *stackRestackState:
vm.state = msg
if stackRestackFlags.DryRun {
return vm, tea.Quit
}
if stackRestackFlags.Skip || stackRestackFlags.Continue || stackRestackFlags.Abort {
if stackRestackFlags.Abort {
vm.abortedBranch = vm.state.Seq.CurrentSyncRef
}
return vm, vm.runSeqWithContinuationFlags
}
return vm, vm.runSeq
case *stackRestackSeqResult:
if msg.err == nil && msg.result == nil {
// Finished the sequence.
if err := vm.repo.WriteStateFile(git.StateFileKindRestack, nil); err != nil {
vm.err = err
}
if _, err := vm.repo.CheckoutBranch(&git.CheckoutBranch{Name: vm.state.InitialBranch}); err != nil {
vm.err = err
}
return vm, tea.Quit
}
if msg.result.Status == git.RebaseConflict {
vm.rebaseConflictErrorHeadline = msg.result.ErrorHeadline
vm.rebaseConflictHint = msg.result.Hint
if err := vm.repo.WriteStateFile(git.StateFileKindRestack, vm.state); err != nil {
vm.err = err
}
return vm, tea.Quit
}
vm.err = msg.err
if vm.err != nil {
return vm, tea.Quit
}
return vm, vm.runSeq
case spinner.TickMsg:
var cmd tea.Cmd
vm.spinner, cmd = vm.spinner.Update(msg)
return vm, cmd
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return vm, tea.Quit
}
}
return vm, nil
}

func (vm stackRestackViewModel) View() string {
sb := strings.Builder{}
if vm.state != nil && vm.state.Seq != nil {
if vm.state.Seq.CurrentSyncRef != "" {
sb.WriteString("Restacking " + vm.state.Seq.CurrentSyncRef.Short() + "...\n")
} else if vm.abortedBranch != "" {
sb.WriteString("Restack aborted\n")
} else {
sb.WriteString("Restack done\n")
}
syncedBranches := map[plumbing.ReferenceName]bool{}
pendingBranches := map[plumbing.ReferenceName]bool{}
seenCurrent := false
for _, op := range vm.state.Seq.Operations {
if op.Name == vm.state.Seq.CurrentSyncRef || op.Name == vm.abortedBranch {
seenCurrent = true
} else if !seenCurrent {
syncedBranches[op.Name] = true
} else {
pendingBranches[op.Name] = true
}
}

sb.WriteString(stackutils.RenderTree(vm.state.StNode, func(branchName string, isTrunk bool) string {
bn := plumbing.NewBranchReferenceName(branchName)
if syncedBranches[bn] {
return colors.Success("✓ " + branchName)
}
if pendingBranches[bn] {
return lipgloss.NewStyle().Foreground(colors.Amber500).Render(branchName)
}
if bn == vm.state.Seq.CurrentSyncRef {
return lipgloss.NewStyle().Foreground(colors.Amber500).Render(vm.spinner.View() + branchName)
}
if bn == vm.abortedBranch {
return colors.Failure("✗ " + branchName)
}
return branchName
}))
}
if vm.rebaseConflictErrorHeadline != "" {
sb.WriteString("\n")
sb.WriteString(colors.Failure("Rebase conflict while rebasing ", vm.state.Seq.CurrentSyncRef.Short()) + "\n")
sb.WriteString(vm.rebaseConflictErrorHeadline + "\n")
sb.WriteString(vm.rebaseConflictHint + "\n")
sb.WriteString("\n")
sb.WriteString("Resolve the conflicts and continue the restack with " + colors.CliCmd("av stack restack --continue") + "\n")
}
if vm.err != nil {
sb.WriteString(vm.err.Error() + "\n")
}
return sb.String()
}

func (vm stackRestackViewModel) initCmd() tea.Msg {
var state stackRestackState
if err := vm.repo.ReadStateFile(git.StateFileKindRestack, &state); err != nil && os.IsNotExist(err) {
var currentBranch string
if dh, err := vm.repo.DetachedHead(); err != nil {
return err
} else if !dh {
currentBranch, err = vm.repo.CurrentBranchName()
if err != nil {
return err
}
}
if _, exist := vm.db.ReadTx().Branch(currentBranch); !exist {
return errors.New("current branch is not adopted to av")
}
state.InitialBranch = currentBranch
state.StNode, err = stackutils.BuildStackTreeCurrentStack(vm.db.ReadTx(), currentBranch, true)
if err != nil {
return err
}
targetBranches, err := planner.GetTargetBranches(vm.db.ReadTx(), vm.repo, false, planner.CurrentStack)
if err != nil {
return err
}
ops, err := planner.PlanForRestack(vm.db.ReadTx(), vm.repo, targetBranches)
if err != nil {
return err
}
if len(ops) == 0 {
return errors.New("nothing to restack")
}
state.Seq = sequencer.NewSequencer("origin", vm.db, ops)
} else if err != nil {
return err
}
return &state
}

func (vm stackRestackViewModel) runSeqWithContinuationFlags() tea.Msg {
result, err := vm.state.Seq.Run(vm.repo, vm.db, stackRestackFlags.Abort, stackRestackFlags.Continue, stackRestackFlags.Skip)
return &stackRestackSeqResult{result: result, err: err}
}

func (vm stackRestackViewModel) runSeq() tea.Msg {
result, err := vm.state.Seq.Run(vm.repo, vm.db, false, false, false)
return &stackRestackSeqResult{result: result, err: err}
}

func init() {
stackRestackCmd.Flags().BoolVar(
&stackRestackFlags.Continue, "continue", false,
"continue an in-progress restack",
)
stackRestackCmd.Flags().BoolVar(
&stackRestackFlags.Abort, "abort", false,
"abort an in-progress restack",
)
stackRestackCmd.Flags().BoolVar(
&stackRestackFlags.Skip, "skip", false,
"skip the current commit and continue an in-progress restack",
)
stackRestackCmd.Flags().BoolVar(
&stackRestackFlags.DryRun, "dry-run", false,
"dry-run the restack",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you briefly explain in different words what does dry-run do?

)

stackRestackCmd.MarkFlagsMutuallyExclusive("continue", "abort", "skip")
}
43 changes: 43 additions & 0 deletions docs/av-stack-restack.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# av-stack-restack

## NAME

av-stack-restack - Rebase the stacked branches

## SYNOPSIS

```synopsis
av stack restack [--dry-run] [--continue | --abort | --skip]
```

## DESCRIPTION

`av stack restack` is a command to re-align the stacked branches. When a parent
branch is amended or has a new commit, the children branches need to be rebased
on the new parent. This command does the rebase operation for all the branches
in the current stack.

## REBASE CONFLICT

Rebasing can cause a conflict. When a conflict happens, it prompts you to
resolve the conflict, and continue with `av stack restack --continue`. This is
similar to `git rebase --continue`, but it continues with syncing the rest of
the branches.

## OPTIONS

`--continue`
: Continue an in-progress rebase.

`--abort`
: Abort an in-progress rebase.

`--skip`
: Skip the current commit and continue an in-progress rebase.

`--dry-run`
: Show the list of branches that will be rebased without actually rebasing.

## SEE ALSO

`av-stack-sync`(1) for syncing with the remote repository.
Loading