diff --git a/README.md b/README.md index 3f3c811f..6ca83ac0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ automatically update the dependent PR when the base PR is updated. Read more at PRs](https://www.aviator.co/blog/rethinking-code-reviews-with-stacked-prs/). # Community + Join our discord community: [https://discord.gg/TFgtZtN8](https://discord.gg/NFsYWNzXcH) # Features @@ -82,7 +83,7 @@ $ gh pr merge feature-1 Sync the stack: ```sh -$ av stack sync +$ av sync ✓ GitHub fetch is done ✓ Restack is done @@ -142,13 +143,16 @@ Add Aviator to your APT repositories. echo "deb [trusted=yes] https://apt.fury.io/aviator/ /" > \ /etc/apt/sources.list.d/fury.list ``` + And then apt install. + ```sh sudo apt update sudo apt install av ``` ### Alternatively + If you'd prefer you can download the `.deb` file from the [releases page](https://github.com/aviator-co/av/releases). ```sh @@ -156,7 +160,9 @@ apt install ./av_$VERSION_linux_$ARCH.deb ``` ## RPM-based systems + Add the following file `/etc/yum.repos.d/fury.repo`. + ```conf [fury] name=Gemfury Private Repo @@ -166,16 +172,19 @@ gpgcheck=0 ``` Run the following command to confirm the configuration is working. + ```sh yum --disablerepo=* --enablerepo=fury list available ``` And then run yum install. + ```sh sudo yum install av ``` ### Alternatively + If you'd prefer you can download the `.rpm` file from the [releases page](https://github.com/aviator-co/av/releases). ```sh @@ -215,19 +224,19 @@ Download the binary from the [releases page](https://github.com/aviator-co/av/re # Example commands -| Command | Description | -| --------------------- | ---------------------------------------------------------- | -| `av stack branch` | Create a new child branch from the current branch. | -| `av stack restack` | Rebase the branches to their parents. | -| `av pr create` | Create or update a PR. | -| `av stack tree` | Visualize the PRs. | -| `av stack sync --all` | Fetch and rebase all branches. | -| `av stack adopt` | Adopt a branch that is not created from `av stack branch`. | -| `av stack reparent` | Change the parent of the current branch. | -| `av stack switch` | Check out branches interactively. | -| `av stack reorder` | Reorder the branches. | -| `av commit amend` | Amend the last commit and rebase the children. | -| `av commit split` | Split the last commit. | +| Command | Description | +| ------------------- | ---------------------------------------------------------- | +| `av stack branch` | Create a new child branch from the current branch. | +| `av stack restack` | Rebase the branches to their parents. | +| `av pr create` | Create or update a PR. | +| `av stack tree` | Visualize the PRs. | +| `av sync --all` | Fetch and rebase all branches. | +| `av stack adopt` | Adopt a branch that is not created from `av stack branch`. | +| `av stack reparent` | Change the parent of the current branch. | +| `av stack switch` | Check out branches interactively. | +| `av stack reorder` | Reorder the branches. | +| `av commit amend` | Amend the last commit and rebase the children. | +| `av commit split` | Split the last commit. | # How it works @@ -238,6 +247,6 @@ new base branch using the remembered starting point as the merge base. # Learn more -* [Rethinking code reviews with stacked PRs](https://www.aviator.co/blog/rethinking-code-reviews-with-stacked-prs/) -* [Issue Tracker](https://github.com/aviator-co/av/issues) -* [Changelog](https://github.com/aviator-co/av/releases) +- [Rethinking code reviews with stacked PRs](https://www.aviator.co/blog/rethinking-code-reviews-with-stacked-prs/) +- [Issue Tracker](https://github.com/aviator-co/av/issues) +- [Changelog](https://github.com/aviator-co/av/releases) diff --git a/cmd/av/auth.go b/cmd/av/auth.go index 83bbec12..2ee9c024 100644 --- a/cmd/av/auth.go +++ b/cmd/av/auth.go @@ -1,12 +1,77 @@ package main import ( + "context" + "fmt" + "os" + + "emperror.dev/errors" + "github.com/aviator-co/av/internal/gh" + + "github.com/aviator-co/av/internal/avgql" + "github.com/aviator-co/av/internal/utils/colors" "github.com/spf13/cobra" ) var authCmd = &cobra.Command{ - Use: "auth", - Short: "Manage authentication", + Use: "auth", + Short: "Check user authentication status", + SilenceUsage: true, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + if err := checkAviatorAuthStatus(); err != nil { + fmt.Fprintln(os.Stderr, colors.Warning(err.Error())) + } + if err := checkGitHubAuthStatus(); err != nil { + fmt.Fprintln(os.Stderr, colors.Failure(err.Error())) + } + }, +} + +func checkAviatorAuthStatus() error { + avClient, err := avgql.NewClient() + if err != nil { + return err + } + + var query struct{ avgql.ViewerSubquery } + if err := avClient.Query(context.Background(), &query, nil); err != nil { + return err + } + if err := query.CheckViewer(); err != nil { + return err + } + + fmt.Fprint(os.Stderr, + "Logged in to Aviator as ", colors.UserInput(query.Viewer.FullName), + " (", colors.UserInput(query.Viewer.Email), ").\n", + ) + return nil +} + +func checkGitHubAuthStatus() error { + ghClient, err := getGitHubClient() + if err != nil { + return err + } + + viewer, err := ghClient.Viewer(context.Background()) + if err != nil { + // GitHub API returns 401 Unauthorized if the token is invalid or + // expired. + if gh.IsHTTPUnauthorized(err) { + return errors.New( + "You are not logged in to GitHub. Please verify that your API token is correct.", + ) + } + return errors.Wrap(err, "Failed to query GitHub") + } + + fmt.Fprint(os.Stderr, + "Logged in to GitHub as ", colors.UserInput(viewer.Name), + " (", colors.UserInput(viewer.Login), ").\n", + ) + return nil } func init() { diff --git a/cmd/av/auth_status.go b/cmd/av/auth_status.go index e9883e13..e93c4790 100644 --- a/cmd/av/auth_status.go +++ b/cmd/av/auth_status.go @@ -1,83 +1,18 @@ package main import ( - "context" "fmt" - "os" - "emperror.dev/errors" - "github.com/aviator-co/av/internal/actions" - "github.com/aviator-co/av/internal/gh" - - "github.com/aviator-co/av/internal/avgql" - "github.com/aviator-co/av/internal/utils/colors" "github.com/spf13/cobra" ) var authStatusCmd = &cobra.Command{ - Use: "status", - Short: "Show info about the logged in user", - SilenceUsage: true, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - exitCode := 0 - if err := checkAviatorAuthStatus(); err != nil { - _, _ = fmt.Fprintln(os.Stderr, colors.Failure(err.Error())) - exitCode = 1 - } - if err := checkGitHubAuthStatus(); err != nil { - _, _ = fmt.Fprintln(os.Stderr, colors.Failure(err.Error())) - exitCode = 1 - } - if exitCode != 0 { - return actions.ErrExitSilently{ExitCode: exitCode} - } - return nil + Use: "status", + Short: "Deprecated: Show info about the logged in user (use 'av auth' instead)", + Hidden: true, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("'av auth status' is deprecated. Please use 'av auth' instead.") + authCmd.Run(cmd, args) }, } - -func checkAviatorAuthStatus() error { - avClient, err := avgql.NewClient() - if err != nil { - return err - } - - var query struct{ avgql.ViewerSubquery } - if err := avClient.Query(context.Background(), &query, nil); err != nil { - return err - } - if err := query.CheckViewer(); err != nil { - return err - } - - _, _ = fmt.Fprint(os.Stderr, - "Logged in to Aviator as ", colors.UserInput(query.Viewer.FullName), - " (", colors.UserInput(query.Viewer.Email), ").\n", - ) - return nil -} - -func checkGitHubAuthStatus() error { - ghClient, err := getGitHubClient() - if err != nil { - return err - } - - viewer, err := ghClient.Viewer(context.Background()) - if err != nil { - // GitHub API returns 401 Unauthorized if the token is invalid or - // expired. - if gh.IsHTTPUnauthorized(err) { - return errors.New( - "You are not logged in to GitHub. Please verify that your API token is correct.", - ) - } - return errors.Wrap(err, "Failed to query GitHub") - } - - _, _ = fmt.Fprint(os.Stderr, - "Logged in to GitHub as ", colors.UserInput(viewer.Name), - " (", colors.UserInput(viewer.Login), ").\n", - ) - return nil -} diff --git a/cmd/av/commit_split.go b/cmd/av/commit_split.go index f69ba120..5aff9ce1 100644 --- a/cmd/av/commit_split.go +++ b/cmd/av/commit_split.go @@ -131,7 +131,7 @@ func splitCommit(repo *git.Repo, currentBranchName, currentCommitOID string) err // TODO: We should rebase the stacks after split. _, _ = fmt.Fprint( os.Stderr, - "Run `av stack sync` to sync your stack if necessary.", + "Run 'av sync' to sync your stack if necessary.", ) } diff --git a/cmd/av/main.go b/cmd/av/main.go index 8019c2ec..df2d84f8 100644 --- a/cmd/av/main.go +++ b/cmd/av/main.go @@ -99,6 +99,7 @@ func init() { stackCmd, versionCmd, authCmd, + syncCmd, ) } diff --git a/cmd/av/stack_adopt.go b/cmd/av/stack_adopt.go index d699c5c2..a148f68a 100644 --- a/cmd/av/stack_adopt.go +++ b/cmd/av/stack_adopt.go @@ -460,7 +460,7 @@ func init() { "dry-run adoption", ) - _ = stackSyncCmd.RegisterFlagCompletionFunc( + _ = stackAdoptCmd.RegisterFlagCompletionFunc( "parent", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { branches, _ := allBranches() diff --git a/cmd/av/stack_diff.go b/cmd/av/stack_diff.go index 99ab29dc..44cff9f0 100644 --- a/cmd/av/stack_diff.go +++ b/cmd/av/stack_diff.go @@ -117,7 +117,7 @@ Generates the diff between the working tree and the parent branch colors.Warning("\nWARNING: Branch "), colors.UserInput(currentBranchName), colors.Warning(" is not up to date with parent branch "), colors.UserInput(branch.Parent.Name), colors.Warning(". Run "), - colors.CliCmd("av stack sync"), colors.Warning(" to synchronize the branch.\n"), + colors.CliCmd("av sync"), colors.Warning(" to synchronize the branch.\n"), ) return actions.ErrExitSilently{ExitCode: 1} } diff --git a/cmd/av/stack_switch.go b/cmd/av/stack_switch.go index 93674f01..3e146722 100644 --- a/cmd/av/stack_switch.go +++ b/cmd/av/stack_switch.go @@ -293,7 +293,7 @@ func (vm stackSwitchViewModel) View() string { return ret } -func (_ stackSwitchViewModel) renderBranchInfo( +func (stackSwitchViewModel) renderBranchInfo( stbi *stackTreeBranchInfo, currentBranchName string, branchName string, diff --git a/cmd/av/stack_sync.go b/cmd/av/stack_sync.go index a24bb4bd..66a0ab01 100644 --- a/cmd/av/stack_sync.go +++ b/cmd/av/stack_sync.go @@ -1,52 +1,18 @@ package main import ( - "os" + "fmt" "strings" - "emperror.dev/errors" - "github.com/aviator-co/av/internal/actions" - "github.com/aviator-co/av/internal/config" - "github.com/aviator-co/av/internal/gh" - "github.com/aviator-co/av/internal/gh/ghui" - "github.com/aviator-co/av/internal/git" - "github.com/aviator-co/av/internal/git/gitui" - "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/sequencer/sequencerui" - "github.com/aviator-co/av/internal/utils/sliceutils" - "github.com/aviator-co/av/internal/utils/uiutils" - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/erikgeiser/promptkit/selection" - "github.com/go-git/go-git/v5/plumbing" "github.com/spf13/cobra" ) -var stackSyncFlags struct { - All bool - RebaseToTrunk bool - Current bool - Abort bool - Continue bool - Skip bool - Push string - Prune string -} - -const ( - changeNoticePrompt = "Are you OK to continue with the new behavior?" - continueWithSyncChoice = "OK! Continue with av stack sync, rebasing onto the latest trunk (we will not ask again)" - abortSyncChoice = "Nope. Abort av stack sync (we will ask again next time)" -) - var stackSyncCmd = &cobra.Command{ Use: "sync", - Short: "Synchronize stacked branches with GitHub", + Short: "Deprecated: Synchronize stacked branches with GitHub (use 'av sync' instead)", Long: strings.TrimSpace(` +'av stack sync' is deprecated. Please use 'av sync' instead. + Synchronize stacked branches to be up-to-date with their parent branches. By default, this command will sync all branches starting at the root of the @@ -66,566 +32,11 @@ base branch. `), Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - if !sliceutils.Contains( - []string{"ask", "yes", "no"}, - strings.ToLower(stackSyncFlags.Push), - ) { - return errors.New("invalid value for --push; must be one of ask, yes, no") - } - if !sliceutils.Contains( - []string{"ask", "yes", "no"}, - strings.ToLower(stackSyncFlags.Prune), - ) { - return errors.New("invalid value for --prune; must be one of ask, yes, no") - } - if cmd.Flags().Changed("no-fetch") { - return actions.ErrExitSilently{ExitCode: 1} - } - if cmd.Flags().Changed("trunk") { - return actions.ErrExitSilently{ExitCode: 1} - } - if cmd.Flags().Changed("parent") { - return actions.ErrExitSilently{ExitCode: 1} - } - repo, err := getRepo() - if err != nil { - return err - } - db, err := getDB(repo) - if err != nil { - return err - } - client, err := getGitHubClient() - if err != nil { - return err - } - - return uiutils.RunBubbleTea(&stackSyncViewModel{ - repo: repo, - db: db, - client: client, - help: help.New(), - askingStackSyncChange: !config.UserState.NotifiedStackSyncChange, - }) + fmt.Println("'av stack sync' is deprecated. Please use 'av sync' instead.") + return syncCmd.RunE(cmd, args) }, } -type savedStackSyncState struct { - RestackState *sequencerui.RestackState - StackSyncState *stackSyncState -} - -type stackSyncState struct { - TargetBranches []plumbing.ReferenceName - Prune string - Push string -} - -type stackSyncViewModel struct { - repo *git.Repo - db meta.DB - client *gh.Client - help help.Model - - state *stackSyncState - changeNoticePrompt *selection.Model[string] - syncAllPrompt *selection.Model[string] - githubFetchModel *ghui.GitHubFetchModel - restackModel *sequencerui.RestackModel - githubPushModel *ghui.GitHubPushModel - pruneBranchModel *gitui.PruneBranchModel - - askingStackSyncChange bool - pushingToGitHub bool - pruningBranches bool - - quitWithAbortChoice bool - quitWithConflict bool - err error -} - -func (vm *stackSyncViewModel) Init() tea.Cmd { - if vm.askingStackSyncChange && os.Getenv("AV_STACK_SYNC_CHANGE_NO_ASK") != "1" { - vm.changeNoticePrompt = uiutils.NewPromptModel( - changeNoticePrompt, - []string{continueWithSyncChoice, abortSyncChoice}, - ) - return vm.changeNoticePrompt.Init() - } - return vm.initSync() -} - -func (vm *stackSyncViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case spinner.TickMsg: - var cmds []tea.Cmd - if vm.githubFetchModel != nil { - var cmd tea.Cmd - vm.githubFetchModel, cmd = vm.githubFetchModel.Update(msg) - cmds = append(cmds, cmd) - } - if vm.restackModel != nil { - var cmd tea.Cmd - vm.restackModel, cmd = vm.restackModel.Update(msg) - cmds = append(cmds, cmd) - } - if vm.githubPushModel != nil { - var cmd tea.Cmd - vm.githubPushModel, cmd = vm.githubPushModel.Update(msg) - cmds = append(cmds, cmd) - } - if vm.pruneBranchModel != nil { - var cmd tea.Cmd - vm.pruneBranchModel, cmd = vm.pruneBranchModel.Update(msg) - cmds = append(cmds, cmd) - } - return vm, tea.Batch(cmds...) - - case *ghui.GitHubFetchProgress: - var cmd tea.Cmd - vm.githubFetchModel, cmd = vm.githubFetchModel.Update(msg) - return vm, cmd - case *ghui.GitHubFetchDone: - return vm, vm.initSequencerState() - - case *sequencerui.RestackProgress: - var cmd tea.Cmd - vm.restackModel, cmd = vm.restackModel.Update(msg) - return vm, cmd - case *sequencerui.RestackConflict: - if err := vm.writeState(vm.restackModel.State); err != nil { - return vm, func() tea.Msg { return err } - } - vm.quitWithConflict = true - return vm, tea.Quit - case *sequencerui.RestackAbort: - if err := vm.writeState(nil); err != nil { - return vm, func() tea.Msg { return err } - } - return vm, tea.Quit - case *sequencerui.RestackDone: - if err := vm.writeState(nil); err != nil { - return vm, func() tea.Msg { return err } - } - return vm, vm.initPushBranches() - - case *ghui.GitHubPushProgress: - var cmd tea.Cmd - vm.githubPushModel, cmd = vm.githubPushModel.Update(msg) - return vm, cmd - case *ghui.GitHubPushDone: - vm.pushingToGitHub = false - return vm, vm.initPruneBranches() - - case *gitui.PruneBranchProgress: - var cmd tea.Cmd - vm.pruneBranchModel, cmd = vm.pruneBranchModel.Update(msg) - return vm, cmd - case *gitui.PruneBranchDone: - vm.pruningBranches = false - return vm, tea.Quit - - case promptUserShouldSyncAllMsg: - vm.syncAllPrompt = uiutils.NewPromptModel("You are on the trunk, do you want to sync all stacks?", []string{"Yes", "No"}) - return vm, vm.syncAllPrompt.Init() - - case tea.KeyMsg: - if vm.syncAllPrompt != nil { - switch msg.String() { - case " ", "enter": - c, err := vm.syncAllPrompt.Value() - if err != nil { - vm.err = err - return vm, tea.Quit - } - vm.syncAllPrompt = nil - if c == "Yes" { - stackSyncFlags.All = true - } - if c == "No" { - return vm, tea.Quit - } - return vm, vm.initSync() - case "ctrl+c": - return vm, tea.Quit - default: - _, cmd := vm.syncAllPrompt.Update(msg) - return vm, cmd - } - } else if vm.askingStackSyncChange { - - switch msg.String() { - case " ", "enter": - c, err := vm.changeNoticePrompt.Value() - if err != nil { - vm.err = err - return vm, tea.Quit - } - vm.askingStackSyncChange = false - if c == continueWithSyncChoice { - config.UserState.NotifiedStackSyncChange = true - if err := config.SaveUserState(); err != nil { - vm.err = err - return vm, tea.Quit - } - return vm, vm.initSync() - } else { - vm.quitWithAbortChoice = true - return vm, tea.Quit - } - case "ctrl+c": - return vm, tea.Quit - default: - _, cmd := vm.changeNoticePrompt.Update(msg) - return vm, cmd - } - } else if vm.pushingToGitHub { - switch msg.String() { - case "ctrl+c": - return vm, tea.Quit - default: - _, cmd := vm.githubPushModel.Update(msg) - return vm, cmd - } - } else if vm.pruningBranches { - switch msg.String() { - case "ctrl+c": - return vm, tea.Quit - default: - _, cmd := vm.pruneBranchModel.Update(msg) - return vm, cmd - } - } else { - switch msg.String() { - case "ctrl+c": - return vm, tea.Quit - } - } - case error: - vm.err = msg - return vm, tea.Quit - } - return vm, nil -} - -func (vm *stackSyncViewModel) View() string { - var ss []string - if vm.syncAllPrompt != nil { - ss = append(ss, vm.syncAllPrompt.View()) - ss = append(ss, vm.help.ShortHelpView(uiutils.PromptKeys)) - } - if vm.changeNoticePrompt != nil { - ss = append(ss, vm.viewChangeNotice()) - } - if vm.githubFetchModel != nil { - ss = append(ss, vm.githubFetchModel.View()) - } - if vm.restackModel != nil { - ss = append(ss, vm.restackModel.View()) - } - if vm.githubPushModel != nil { - ss = append(ss, vm.githubPushModel.View()) - } - if vm.pruneBranchModel != nil { - ss = append(ss, vm.pruneBranchModel.View()) - } - - var ret string - if len(ss) != 0 { - ret = lipgloss.NewStyle().MarginTop(1).MarginBottom(1).MarginLeft(2).Render( - lipgloss.JoinVertical(0, ss...), - ) - } - if vm.err != nil { - if len(ret) != 0 { - ret += "\n" - } - ret += renderError(vm.err) - } - return ret -} - -var commandStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) - -func (vm *stackSyncViewModel) viewChangeNotice() string { - boldStyle := lipgloss.NewStyle().Bold(true) - sb := strings.Builder{} - sb.WriteString( - boldStyle.Render( - "The behavior of ", - ) + commandStyle.Bold(true). - Render("av stack sync") + - boldStyle.Render( - " has changed. We will now ask for confirmation before syncing the stack.\n", - ), - ) - sb.WriteString("\n") - sb.WriteString("* " + commandStyle.Render("av stack sync") + " is split into four commands:\n") - sb.WriteString( - " * " + commandStyle.Render("av stack adopt") + " to adopt a new branch into the stack.\n", - ) - sb.WriteString( - " * " + commandStyle.Render("av stack reparent") + " to change the parent branch.\n", - ) - sb.WriteString( - " * " + commandStyle.Render("av stack restack") + " to rebase the stack locally.\n", - ) - sb.WriteString( - " * " + commandStyle.Render( - "av stack sync", - ) + " to rebase the stack with the remote repository.\n", - ) - sb.WriteString( - "* " + commandStyle.Render( - "av stack sync", - ) + " will ask if you want to push to the remote repository.\n", - ) - sb.WriteString( - "* " + commandStyle.Render( - "av stack sync", - ) + " will ask if you want to delete the branches that have been merged.\n", - ) - sb.WriteString("\n") - sb.WriteString( - "With this change, " + commandStyle.Render( - "av stack sync", - ) + " will always rebase onto the remote trunk branch (e.g., main or\n", - ) - sb.WriteString( - "master). If you do not want to rebase onto the remote trunk branch, please use " + commandStyle.Render( - "av stack restack", - ) + ".\n", - ) - sb.WriteString("\n") - sb.WriteString(vm.changeNoticePrompt.View()) - sb.WriteString(vm.help.ShortHelpView(uiutils.PromptKeys)) - sb.WriteString("\n") - return sb.String() -} - -type promptUserShouldSyncAllMsg struct { -} - -func (vm *stackSyncViewModel) initSync() tea.Cmd { - state, err := vm.readState() - if err != nil { - return func() tea.Msg { return err } - } - if state != nil { - return vm.continueWithState(state) - } - if stackSyncFlags.Abort || stackSyncFlags.Continue || stackSyncFlags.Skip { - return func() tea.Msg { return errors.New("no restack in progress") } - } - - isTrunkBranch, err := vm.repo.IsCurrentBranchTrunk() - if err != nil { - return func() tea.Msg { return err } - } - if isTrunkBranch && !stackSyncFlags.All { - return func() tea.Msg { - return promptUserShouldSyncAllMsg{} - } - } - vm.githubFetchModel, err = vm.createGitHubFetchModel() - if err != nil { - return func() tea.Msg { return err } - } - return vm.githubFetchModel.Init() -} - -func (vm *stackSyncViewModel) initSequencerState() tea.Cmd { - state, err := vm.createState() - if err != nil { - return func() tea.Msg { return err } - } - if state == nil { - return func() tea.Msg { return nothingToRestackError } - } - return vm.continueWithState(state) -} - -func (vm *stackSyncViewModel) continueWithState(state *savedStackSyncState) tea.Cmd { - vm.state = state.StackSyncState - vm.restackModel = sequencerui.NewRestackModel(vm.repo, vm.db) - vm.restackModel.Command = "av stack sync" - vm.restackModel.State = state.RestackState - vm.restackModel.Abort = stackSyncFlags.Abort - vm.restackModel.Continue = stackSyncFlags.Continue - vm.restackModel.Skip = stackSyncFlags.Skip - return vm.restackModel.Init() -} - -func (vm *stackSyncViewModel) readState() (*savedStackSyncState, error) { - var state savedStackSyncState - if err := vm.repo.ReadStateFile(git.StateFileKindSyncV2, &state); err != nil && - os.IsNotExist(err) { - return nil, nil - } else if err != nil { - return nil, err - } - return &state, nil -} - -func (vm *stackSyncViewModel) writeState(seqModel *sequencerui.RestackState) error { - if seqModel == nil { - return vm.repo.WriteStateFile(git.StateFileKindSyncV2, nil) - } - var state savedStackSyncState - state.RestackState = seqModel - state.StackSyncState = vm.state - return vm.repo.WriteStateFile(git.StateFileKindSyncV2, &state) -} - -func (vm *stackSyncViewModel) createGitHubFetchModel() (*ghui.GitHubFetchModel, error) { - status, err := vm.repo.Status() - if err != nil { - return nil, err - } - currentBranch := status.CurrentBranch - - var targetBranches []plumbing.ReferenceName - if stackSyncFlags.All { - var err error - targetBranches, err = planner.GetTargetBranches( - vm.db.ReadTx(), - vm.repo, - true, - planner.AllBranches, - ) - if err != nil { - return nil, err - } - } else { - if _, exist := vm.db.ReadTx().Branch(currentBranch); !exist { - return nil, errors.New("current branch is not adopted to av") - } - var err error - if stackSyncFlags.Current { - targetBranches, err = planner.GetTargetBranches(vm.db.ReadTx(), vm.repo, true, planner.CurrentAndParents) - } else { - targetBranches, err = planner.GetTargetBranches(vm.db.ReadTx(), vm.repo, true, planner.CurrentStack) - } - if err != nil { - return nil, err - } - } - - var currentBranchRef plumbing.ReferenceName - if currentBranch != "" { - currentBranchRef = plumbing.NewBranchReferenceName(currentBranch) - } - - return ghui.NewGitHubFetchModel( - vm.repo, - vm.db, - vm.client, - currentBranchRef, - targetBranches, - ), nil -} - -func (vm *stackSyncViewModel) createState() (*savedStackSyncState, error) { - state := savedStackSyncState{ - RestackState: &sequencerui.RestackState{}, - StackSyncState: &stackSyncState{ - Push: stackSyncFlags.Push, - Prune: stackSyncFlags.Prune, - }, - } - status, err := vm.repo.Status() - if err != nil { - return nil, err - } - currentBranch := status.CurrentBranch - state.RestackState.InitialBranch = currentBranch - - var targetBranches []plumbing.ReferenceName - if stackSyncFlags.All { - var err error - targetBranches, err = planner.GetTargetBranches( - vm.db.ReadTx(), - vm.repo, - true, - planner.AllBranches, - ) - if err != nil { - return nil, err - } - state.RestackState.RestackingAll = true - } else { - if _, exist := vm.db.ReadTx().Branch(currentBranch); !exist { - return nil, errors.New("current branch is not adopted to av") - } - var err error - if stackSyncFlags.Current { - targetBranches, err = planner.GetTargetBranches(vm.db.ReadTx(), vm.repo, true, planner.CurrentAndParents) - } else { - targetBranches, err = planner.GetTargetBranches(vm.db.ReadTx(), vm.repo, true, planner.CurrentStack) - } - if err != nil { - return nil, err - } - state.RestackState.RelatedBranches = append(state.RestackState.RelatedBranches, currentBranch) - } - state.StackSyncState.TargetBranches = targetBranches - - var currentBranchRef plumbing.ReferenceName - if currentBranch != "" { - currentBranchRef = plumbing.NewBranchReferenceName(currentBranch) - } - ops, err := planner.PlanForSync( - vm.db.ReadTx(), - vm.repo, - currentBranchRef, - stackSyncFlags.All, - stackSyncFlags.Current, - stackSyncFlags.RebaseToTrunk, - ) - if err != nil { - return nil, err - } - state.RestackState.Seq = sequencer.NewSequencer(vm.repo.GetRemoteName(), vm.db, ops) - return &state, nil -} - -func (vm *stackSyncViewModel) initPushBranches() tea.Cmd { - vm.githubPushModel = ghui.NewGitHubPushModel( - vm.repo, - vm.db, - vm.client, - vm.state.Push, - vm.state.TargetBranches, - ) - vm.pushingToGitHub = true - return vm.githubPushModel.Init() -} - -func (vm *stackSyncViewModel) initPruneBranches() tea.Cmd { - vm.pruneBranchModel = gitui.NewPruneBranchModel( - vm.repo, - vm.db, - vm.state.Prune, - vm.state.TargetBranches, - vm.restackModel.State.InitialBranch, - ) - vm.pruningBranches = true - return vm.pruneBranchModel.Init() -} - -func (vm *stackSyncViewModel) ExitError() error { - if errors.Is(vm.err, nothingToRestackError) { - return nil - } - if vm.err != nil { - return actions.ErrExitSilently{ExitCode: 1} - } - if vm.quitWithConflict { - return actions.ErrExitSilently{ExitCode: 1} - } - return nil -} - func init() { stackSyncCmd.Flags().BoolVar( &stackSyncFlags.All, "all", false, diff --git a/cmd/av/sync.go b/cmd/av/sync.go new file mode 100644 index 00000000..1e8364ca --- /dev/null +++ b/cmd/av/sync.go @@ -0,0 +1,683 @@ +package main + +import ( + "os" + "strings" + + "emperror.dev/errors" + "github.com/aviator-co/av/internal/actions" + "github.com/aviator-co/av/internal/config" + "github.com/aviator-co/av/internal/gh" + "github.com/aviator-co/av/internal/gh/ghui" + "github.com/aviator-co/av/internal/git" + "github.com/aviator-co/av/internal/git/gitui" + "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/sequencer/sequencerui" + "github.com/aviator-co/av/internal/utils/sliceutils" + "github.com/aviator-co/av/internal/utils/uiutils" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/erikgeiser/promptkit/selection" + "github.com/go-git/go-git/v5/plumbing" + "github.com/spf13/cobra" +) + +var stackSyncFlags struct { + All bool + RebaseToTrunk bool + Current bool + Abort bool + Continue bool + Skip bool + Push string + Prune string +} + +const ( + changeNoticePrompt = "Are you OK to continue with the new behavior?" + continueWithSyncChoice = "OK! Continue with av sync, rebasing onto the latest trunk (we will not ask again)" + abortSyncChoice = "Nope. Abort av sync (we will ask again next time)" +) + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Synchronize stacked branches with GitHub", + Long: strings.TrimSpace(` +Synchronize stacked branches to be up-to-date with their parent branches. + +By default, this command will sync all branches starting at the root of the +stack and recursively rebasing each branch based on the latest commit from the +parent branch. + +If the --all flag is given, this command will sync all branches in the repository. + +If the --current flag is given, this command will not recursively sync dependent +branches of the current branch within the stack. This allows you to make changes +to the current branch before syncing the rest of the stack. + +If the --rebase-to-trunk flag is given, this command will synchronize changes from the +latest commit to the repository base branch (e.g., main or master) into the +stack. This is useful for rebasing a whole stack on the latest changes from the +base branch. +`), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if !sliceutils.Contains( + []string{"ask", "yes", "no"}, + strings.ToLower(stackSyncFlags.Push), + ) { + return errors.New("invalid value for --push; must be one of ask, yes, no") + } + if !sliceutils.Contains( + []string{"ask", "yes", "no"}, + strings.ToLower(stackSyncFlags.Prune), + ) { + return errors.New("invalid value for --prune; must be one of ask, yes, no") + } + if cmd.Flags().Changed("no-fetch") { + return actions.ErrExitSilently{ExitCode: 1} + } + if cmd.Flags().Changed("trunk") { + return actions.ErrExitSilently{ExitCode: 1} + } + if cmd.Flags().Changed("parent") { + return actions.ErrExitSilently{ExitCode: 1} + } + repo, err := getRepo() + if err != nil { + return err + } + db, err := getDB(repo) + if err != nil { + return err + } + client, err := getGitHubClient() + if err != nil { + return err + } + + return uiutils.RunBubbleTea(&stackSyncViewModel{ + repo: repo, + db: db, + client: client, + help: help.New(), + askingStackSyncChange: !config.UserState.NotifiedStackSyncChange, + }) + }, +} + +type savedStackSyncState struct { + RestackState *sequencerui.RestackState + StackSyncState *stackSyncState +} + +type stackSyncState struct { + TargetBranches []plumbing.ReferenceName + Prune string + Push string +} + +type stackSyncViewModel struct { + repo *git.Repo + db meta.DB + client *gh.Client + help help.Model + + state *stackSyncState + changeNoticePrompt *selection.Model[string] + syncAllPrompt *selection.Model[string] + githubFetchModel *ghui.GitHubFetchModel + restackModel *sequencerui.RestackModel + githubPushModel *ghui.GitHubPushModel + pruneBranchModel *gitui.PruneBranchModel + + askingStackSyncChange bool + pushingToGitHub bool + pruningBranches bool + + quitWithAbortChoice bool + quitWithConflict bool + err error +} + +func (vm *stackSyncViewModel) Init() tea.Cmd { + if vm.askingStackSyncChange && os.Getenv("AV_STACK_SYNC_CHANGE_NO_ASK") != "1" { + vm.changeNoticePrompt = uiutils.NewPromptModel( + changeNoticePrompt, + []string{continueWithSyncChoice, abortSyncChoice}, + ) + return vm.changeNoticePrompt.Init() + } + return vm.initSync() +} + +func (vm *stackSyncViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spinner.TickMsg: + var cmds []tea.Cmd + if vm.githubFetchModel != nil { + var cmd tea.Cmd + vm.githubFetchModel, cmd = vm.githubFetchModel.Update(msg) + cmds = append(cmds, cmd) + } + if vm.restackModel != nil { + var cmd tea.Cmd + vm.restackModel, cmd = vm.restackModel.Update(msg) + cmds = append(cmds, cmd) + } + if vm.githubPushModel != nil { + var cmd tea.Cmd + vm.githubPushModel, cmd = vm.githubPushModel.Update(msg) + cmds = append(cmds, cmd) + } + if vm.pruneBranchModel != nil { + var cmd tea.Cmd + vm.pruneBranchModel, cmd = vm.pruneBranchModel.Update(msg) + cmds = append(cmds, cmd) + } + return vm, tea.Batch(cmds...) + + case *ghui.GitHubFetchProgress: + var cmd tea.Cmd + vm.githubFetchModel, cmd = vm.githubFetchModel.Update(msg) + return vm, cmd + case *ghui.GitHubFetchDone: + return vm, vm.initSequencerState() + + case *sequencerui.RestackProgress: + var cmd tea.Cmd + vm.restackModel, cmd = vm.restackModel.Update(msg) + return vm, cmd + case *sequencerui.RestackConflict: + if err := vm.writeState(vm.restackModel.State); err != nil { + return vm, func() tea.Msg { return err } + } + vm.quitWithConflict = true + return vm, tea.Quit + case *sequencerui.RestackAbort: + if err := vm.writeState(nil); err != nil { + return vm, func() tea.Msg { return err } + } + return vm, tea.Quit + case *sequencerui.RestackDone: + if err := vm.writeState(nil); err != nil { + return vm, func() tea.Msg { return err } + } + return vm, vm.initPushBranches() + + case *ghui.GitHubPushProgress: + var cmd tea.Cmd + vm.githubPushModel, cmd = vm.githubPushModel.Update(msg) + return vm, cmd + case *ghui.GitHubPushDone: + vm.pushingToGitHub = false + return vm, vm.initPruneBranches() + + case *gitui.PruneBranchProgress: + var cmd tea.Cmd + vm.pruneBranchModel, cmd = vm.pruneBranchModel.Update(msg) + return vm, cmd + case *gitui.PruneBranchDone: + vm.pruningBranches = false + return vm, tea.Quit + + case promptUserShouldSyncAllMsg: + vm.syncAllPrompt = uiutils.NewPromptModel("You are on the trunk, do you want to sync all stacks?", []string{"Yes", "No"}) + return vm, vm.syncAllPrompt.Init() + + case tea.KeyMsg: + if vm.syncAllPrompt != nil { + switch msg.String() { + case " ", "enter": + c, err := vm.syncAllPrompt.Value() + if err != nil { + vm.err = err + return vm, tea.Quit + } + vm.syncAllPrompt = nil + if c == "Yes" { + stackSyncFlags.All = true + } + if c == "No" { + return vm, tea.Quit + } + return vm, vm.initSync() + case "ctrl+c": + return vm, tea.Quit + default: + _, cmd := vm.syncAllPrompt.Update(msg) + return vm, cmd + } + } else if vm.askingStackSyncChange { + + switch msg.String() { + case " ", "enter": + c, err := vm.changeNoticePrompt.Value() + if err != nil { + vm.err = err + return vm, tea.Quit + } + vm.askingStackSyncChange = false + if c == continueWithSyncChoice { + config.UserState.NotifiedStackSyncChange = true + if err := config.SaveUserState(); err != nil { + vm.err = err + return vm, tea.Quit + } + return vm, vm.initSync() + } else { + vm.quitWithAbortChoice = true + return vm, tea.Quit + } + case "ctrl+c": + return vm, tea.Quit + default: + _, cmd := vm.changeNoticePrompt.Update(msg) + return vm, cmd + } + } else if vm.pushingToGitHub { + switch msg.String() { + case "ctrl+c": + return vm, tea.Quit + default: + _, cmd := vm.githubPushModel.Update(msg) + return vm, cmd + } + } else if vm.pruningBranches { + switch msg.String() { + case "ctrl+c": + return vm, tea.Quit + default: + _, cmd := vm.pruneBranchModel.Update(msg) + return vm, cmd + } + } else { + switch msg.String() { + case "ctrl+c": + return vm, tea.Quit + } + } + case error: + vm.err = msg + return vm, tea.Quit + } + return vm, nil +} + +func (vm *stackSyncViewModel) View() string { + var ss []string + if vm.syncAllPrompt != nil { + ss = append(ss, vm.syncAllPrompt.View()) + ss = append(ss, vm.help.ShortHelpView(uiutils.PromptKeys)) + } + if vm.changeNoticePrompt != nil { + ss = append(ss, vm.viewChangeNotice()) + } + if vm.githubFetchModel != nil { + ss = append(ss, vm.githubFetchModel.View()) + } + if vm.restackModel != nil { + ss = append(ss, vm.restackModel.View()) + } + if vm.githubPushModel != nil { + ss = append(ss, vm.githubPushModel.View()) + } + if vm.pruneBranchModel != nil { + ss = append(ss, vm.pruneBranchModel.View()) + } + + var ret string + if len(ss) != 0 { + ret = lipgloss.NewStyle().MarginTop(1).MarginBottom(1).MarginLeft(2).Render( + lipgloss.JoinVertical(0, ss...), + ) + } + if vm.err != nil { + if len(ret) != 0 { + ret += "\n" + } + ret += renderError(vm.err) + } + return ret +} + +var commandStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) + +func (vm *stackSyncViewModel) viewChangeNotice() string { + boldStyle := lipgloss.NewStyle().Bold(true) + sb := strings.Builder{} + sb.WriteString( + boldStyle.Render( + "The behavior of ", + ) + commandStyle.Bold(true). + Render("av sync") + + boldStyle.Render( + " has changed. We will now ask for confirmation before syncing the stack.\n", + ), + ) + sb.WriteString("\n") + sb.WriteString("* " + commandStyle.Render("av sync") + " is split into four commands:\n") + sb.WriteString( + " * " + commandStyle.Render("av stack adopt") + " to adopt a new branch into the stack.\n", + ) + sb.WriteString( + " * " + commandStyle.Render("av stack reparent") + " to change the parent branch.\n", + ) + sb.WriteString( + " * " + commandStyle.Render("av stack restack") + " to rebase the stack locally.\n", + ) + sb.WriteString( + " * " + commandStyle.Render( + "av sync", + ) + " to rebase the stack with the remote repository.\n", + ) + sb.WriteString( + "* " + commandStyle.Render( + "av sync", + ) + " will ask if you want to push to the remote repository.\n", + ) + sb.WriteString( + "* " + commandStyle.Render( + "av sync", + ) + " will ask if you want to delete the branches that have been merged.\n", + ) + sb.WriteString("\n") + sb.WriteString( + "With this change, " + commandStyle.Render( + "av sync", + ) + " will always rebase onto the remote trunk branch (e.g., main or\n", + ) + sb.WriteString( + "master). If you do not want to rebase onto the remote trunk branch, please use " + commandStyle.Render( + "av stack restack", + ) + ".\n", + ) + sb.WriteString("\n") + sb.WriteString(vm.changeNoticePrompt.View()) + sb.WriteString(vm.help.ShortHelpView(uiutils.PromptKeys)) + sb.WriteString("\n") + return sb.String() +} + +type promptUserShouldSyncAllMsg struct { +} + +func (vm *stackSyncViewModel) initSync() tea.Cmd { + state, err := vm.readState() + if err != nil { + return func() tea.Msg { return err } + } + if state != nil { + return vm.continueWithState(state) + } + if stackSyncFlags.Abort || stackSyncFlags.Continue || stackSyncFlags.Skip { + return func() tea.Msg { return errors.New("no restack in progress") } + } + + isTrunkBranch, err := vm.repo.IsCurrentBranchTrunk() + if err != nil { + return func() tea.Msg { return err } + } + if isTrunkBranch && !stackSyncFlags.All { + return func() tea.Msg { + return promptUserShouldSyncAllMsg{} + } + } + vm.githubFetchModel, err = vm.createGitHubFetchModel() + if err != nil { + return func() tea.Msg { return err } + } + return vm.githubFetchModel.Init() +} + +func (vm *stackSyncViewModel) initSequencerState() tea.Cmd { + state, err := vm.createState() + if err != nil { + return func() tea.Msg { return err } + } + if state == nil { + return func() tea.Msg { return nothingToRestackError } + } + return vm.continueWithState(state) +} + +func (vm *stackSyncViewModel) continueWithState(state *savedStackSyncState) tea.Cmd { + vm.state = state.StackSyncState + vm.restackModel = sequencerui.NewRestackModel(vm.repo, vm.db) + vm.restackModel.Command = "av sync" + vm.restackModel.State = state.RestackState + vm.restackModel.Abort = stackSyncFlags.Abort + vm.restackModel.Continue = stackSyncFlags.Continue + vm.restackModel.Skip = stackSyncFlags.Skip + return vm.restackModel.Init() +} + +func (vm *stackSyncViewModel) readState() (*savedStackSyncState, error) { + var state savedStackSyncState + if err := vm.repo.ReadStateFile(git.StateFileKindSyncV2, &state); err != nil && + os.IsNotExist(err) { + return nil, nil + } else if err != nil { + return nil, err + } + return &state, nil +} + +func (vm *stackSyncViewModel) writeState(seqModel *sequencerui.RestackState) error { + if seqModel == nil { + return vm.repo.WriteStateFile(git.StateFileKindSyncV2, nil) + } + var state savedStackSyncState + state.RestackState = seqModel + state.StackSyncState = vm.state + return vm.repo.WriteStateFile(git.StateFileKindSyncV2, &state) +} + +func (vm *stackSyncViewModel) createGitHubFetchModel() (*ghui.GitHubFetchModel, error) { + status, err := vm.repo.Status() + if err != nil { + return nil, err + } + currentBranch := status.CurrentBranch + + var targetBranches []plumbing.ReferenceName + if stackSyncFlags.All { + var err error + targetBranches, err = planner.GetTargetBranches( + vm.db.ReadTx(), + vm.repo, + true, + planner.AllBranches, + ) + if err != nil { + return nil, err + } + } else { + if _, exist := vm.db.ReadTx().Branch(currentBranch); !exist { + return nil, errors.New("current branch is not adopted to av") + } + var err error + if stackSyncFlags.Current { + targetBranches, err = planner.GetTargetBranches(vm.db.ReadTx(), vm.repo, true, planner.CurrentAndParents) + } else { + targetBranches, err = planner.GetTargetBranches(vm.db.ReadTx(), vm.repo, true, planner.CurrentStack) + } + if err != nil { + return nil, err + } + } + + var currentBranchRef plumbing.ReferenceName + if currentBranch != "" { + currentBranchRef = plumbing.NewBranchReferenceName(currentBranch) + } + + return ghui.NewGitHubFetchModel( + vm.repo, + vm.db, + vm.client, + currentBranchRef, + targetBranches, + ), nil +} + +func (vm *stackSyncViewModel) createState() (*savedStackSyncState, error) { + state := savedStackSyncState{ + RestackState: &sequencerui.RestackState{}, + StackSyncState: &stackSyncState{ + Push: stackSyncFlags.Push, + Prune: stackSyncFlags.Prune, + }, + } + status, err := vm.repo.Status() + if err != nil { + return nil, err + } + currentBranch := status.CurrentBranch + state.RestackState.InitialBranch = currentBranch + + var targetBranches []plumbing.ReferenceName + if stackSyncFlags.All { + var err error + targetBranches, err = planner.GetTargetBranches( + vm.db.ReadTx(), + vm.repo, + true, + planner.AllBranches, + ) + if err != nil { + return nil, err + } + state.RestackState.RestackingAll = true + } else { + if _, exist := vm.db.ReadTx().Branch(currentBranch); !exist { + return nil, errors.New("current branch is not adopted to av") + } + var err error + if stackSyncFlags.Current { + targetBranches, err = planner.GetTargetBranches(vm.db.ReadTx(), vm.repo, true, planner.CurrentAndParents) + } else { + targetBranches, err = planner.GetTargetBranches(vm.db.ReadTx(), vm.repo, true, planner.CurrentStack) + } + if err != nil { + return nil, err + } + state.RestackState.RelatedBranches = append(state.RestackState.RelatedBranches, currentBranch) + } + state.StackSyncState.TargetBranches = targetBranches + + var currentBranchRef plumbing.ReferenceName + if currentBranch != "" { + currentBranchRef = plumbing.NewBranchReferenceName(currentBranch) + } + ops, err := planner.PlanForSync( + vm.db.ReadTx(), + vm.repo, + currentBranchRef, + stackSyncFlags.All, + stackSyncFlags.Current, + stackSyncFlags.RebaseToTrunk, + ) + if err != nil { + return nil, err + } + state.RestackState.Seq = sequencer.NewSequencer(vm.repo.GetRemoteName(), vm.db, ops) + return &state, nil +} + +func (vm *stackSyncViewModel) initPushBranches() tea.Cmd { + vm.githubPushModel = ghui.NewGitHubPushModel( + vm.repo, + vm.db, + vm.client, + vm.state.Push, + vm.state.TargetBranches, + ) + vm.pushingToGitHub = true + return vm.githubPushModel.Init() +} + +func (vm *stackSyncViewModel) initPruneBranches() tea.Cmd { + vm.pruneBranchModel = gitui.NewPruneBranchModel( + vm.repo, + vm.db, + vm.state.Prune, + vm.state.TargetBranches, + vm.restackModel.State.InitialBranch, + ) + vm.pruningBranches = true + return vm.pruneBranchModel.Init() +} + +func (vm *stackSyncViewModel) ExitError() error { + if errors.Is(vm.err, nothingToRestackError) { + return nil + } + if vm.err != nil { + return actions.ErrExitSilently{ExitCode: 1} + } + if vm.quitWithConflict { + return actions.ErrExitSilently{ExitCode: 1} + } + return nil +} + +func init() { + syncCmd.Flags().BoolVar( + &stackSyncFlags.All, "all", false, + "synchronize all branches", + ) + syncCmd.Flags().BoolVar( + &stackSyncFlags.Current, "current", false, + "only sync changes to the current branch\n(don't recurse into descendant branches)", + ) + syncCmd.Flags().StringVar( + &stackSyncFlags.Push, "push", "ask", + "push the rebased branches to the remote repository\n(ask|yes|no)", + ) + syncCmd.Flags().StringVar( + &stackSyncFlags.Prune, "prune", "ask", + "delete branches that have been merged into the parent branch\n(ask|yes|no)", + ) + syncCmd.Flags().Lookup("prune").NoOptDefVal = "ask" + syncCmd.Flags().BoolVar( + &stackSyncFlags.RebaseToTrunk, "rebase-to-trunk", false, + "rebase the branches to the latest trunk always", + ) + + syncCmd.Flags().BoolVar( + &stackSyncFlags.Continue, "continue", false, + "continue an in-progress sync", + ) + syncCmd.Flags().BoolVar( + &stackSyncFlags.Abort, "abort", false, + "abort an in-progress sync", + ) + syncCmd.Flags().BoolVar( + &stackSyncFlags.Skip, "skip", false, + "skip the current commit and continue an in-progress sync", + ) + syncCmd.MarkFlagsMutuallyExclusive("current", "all") + syncCmd.MarkFlagsMutuallyExclusive("continue", "abort", "skip") + + // Deprecated flags + syncCmd.Flags().Bool("no-fetch", false, + "(deprecated; use av stack restack for offline restacking) do not fetch the latest status from GitHub", + ) + _ = syncCmd.Flags(). + MarkDeprecated("no-fetch", "please use av stack restack for offline restacking") + syncCmd.Flags().Bool("trunk", false, + "(deprecated; use --rebase-to-trunk to rebase all branches to trunk) rebase the stack on the trunk branch", + ) + _ = syncCmd.Flags(). + MarkDeprecated("trunk", "please use --rebase-to-trunk to rebase all branches to trunk") + syncCmd.Flags().String("parent", "", + "(deprecated; use av stack adopt or av stack reparent) parent branch to rebase onto", + ) + _ = syncCmd.Flags(). + MarkDeprecated("parent", "please use av stack adopt or av stack reparent") +} diff --git a/docs/av-auth-status.1.md b/docs/av-auth-status.1.md index ff7f3b98..45b9768e 100644 --- a/docs/av-auth-status.1.md +++ b/docs/av-auth-status.1.md @@ -1,13 +1,13 @@ -# av-auth-status +# av-auth ## NAME -av-auth-status - Show info about the logged in user +av-auth - Show info about the logged in user ## SYNOPSIS ```synopsis -av auth status +av auth ``` ## DESCRIPTION diff --git a/docs/av-git-interaction.7.md b/docs/av-git-interaction.7.md index e8d81775..152a92f4 100644 --- a/docs/av-git-interaction.7.md +++ b/docs/av-git-interaction.7.md @@ -20,7 +20,7 @@ There is a case where you created a branch without going through ## BRANCH DELETION -When you merge a branch, `av-stack-sync`(1) will prompt you to delete the merged +When you merge a branch, `av-sync`(1) will prompt you to delete the merged branches. However, there can be a case where you want to delete a branch that is not merged yet. In this case, you can delete the branch with `git branch -d|-D`. diff --git a/docs/av-stack-restack.1.md b/docs/av-stack-restack.1.md index f1a45dc6..3900e48e 100644 --- a/docs/av-stack-restack.1.md +++ b/docs/av-stack-restack.1.md @@ -47,4 +47,4 @@ the branches. ## SEE ALSO -`av-stack-sync`(1) for syncing with the remote repository. +`av-sync`(1) for syncing with the remote repository. diff --git a/docs/av-stack-sync.1.md b/docs/av-stack-sync.1.md index d764c283..0a0d502a 100644 --- a/docs/av-stack-sync.1.md +++ b/docs/av-stack-sync.1.md @@ -1,19 +1,19 @@ -# av-stack-sync +# av-sync ## NAME -av-stack-sync - Synchronize stacked branches with GitHub +av-sync - Synchronize stacked branches with GitHub ## SYNOPSIS ```synopsis -av stack sync [--all | --current] [--push=(yes|no|ask)] [--prune=(yes|no|ask)] - [--rebase-to-trunk] [--continue | --abort | --skip] +av sync [--all | --current] [--push=(yes|no|ask)] [--prune=(yes|no|ask)] + [--rebase-to-trunk] [--continue | --abort | --skip] ``` ## DESCRIPTION -`av stack sync` is a command to fetch and push the changes to the remote GitHub +`av sync` is a command to fetch and push the changes to the remote GitHub repository. This command fetches from the remote, restacks the branches, and pushes the changes back to the remote. @@ -27,19 +27,19 @@ command prompts you if the merged branches should be deleted. ## REBASE CONFLICT Rebasing can cause a conflict. When a conflict happens, it prompts you to -resolve the conflict, and continue with `av stack sync --continue`. This is -similar to `git rebase --continue`, but it continues with syncing the rest of +resolve the conflict, and continue with `av sync --continue`. This is similar +to `git rebase --continue`, but it continues with syncing the rest of the branches. ## REBASING THE STACK ROOT TO TRUNK By default, the branches are conditionally rebased if needed: -* If a part of the stack is merged, the rest of the stack is rebased to the +- If a part of the stack is merged, the rest of the stack is rebased to the latest trunk commit. -* If a branch is a stack root (the first topic branch next to trunk), it's +- If a branch is a stack root (the first topic branch next to trunk), it's rebased if `--rebase-to-trunk` option is specified. -* If a branch is not a stack root, it's rebased to the parent branch. +- If a branch is not a stack root, it's rebased to the parent branch. While you are developing in a topic branch, it's possible that the trunk branch is updated by somebody else. In some cases, you may need to rebase onto that diff --git a/docs/av.1.md b/docs/av.1.md index dab55744..4f17a1ff 100644 --- a/docs/av.1.md +++ b/docs/av.1.md @@ -10,7 +10,7 @@ av - Aviator CLI ## SUBCOMMANDS -- av-auth-status(1): Show info about the logged in user +- av-auth(1): Show info about the logged in user - av-commit-amend(1): Amend a commit - av-commit-create(1): Record changes to the repository with commits - av-commit-split(1): Split a commit into multiple commits @@ -20,8 +20,8 @@ av - Aviator CLI - av-pr-queue(1): Queue an existing pull request for the current branch - av-pr-status(1): Get the status of the associated pull request - av-stack-adopt(1): Adopt branches that are not managed by `av` -- av-stack-branch(1): Create or rename a branch in the stack - av-stack-branch-commit(1): Create a new branch in the stack with the staged changes +- av-stack-branch(1): Create or rename a branch in the stack - av-stack-diff(1): Show the diff between working tree and parent branch - av-stack-next(1): Checkout the next branch in the stack - av-stack-orphan(1): Orphan branches that are managed by `av` @@ -31,9 +31,9 @@ av - Aviator CLI - av-stack-restack(1): Rebase the stacked branches - av-stack-submit(1): Create pull requests for every branch in the stack - av-stack-switch(1): Interactively switch to a different branch -- av-stack-sync(1): Synchronize stacked branches with GitHub - av-stack-tidy(1): Tidy stacked branches - av-stack-tree(1): Show the tree of stacked branches +- av-sync(1): Synchronize stacked branches with GitHub ## FURTHER DOCUMENTATION diff --git a/e2e_tests/stack_sync_all_test.go b/e2e_tests/stack_sync_all_test.go index e7b36a37..f0daade9 100644 --- a/e2e_tests/stack_sync_all_test.go +++ b/e2e_tests/stack_sync_all_test.go @@ -28,7 +28,7 @@ func TestStackSyncAll(t *testing.T) { // stack-1: \ -> 1a // stack-2: \ -> 2a - RequireAv(t, "stack", "sync", "--all") + RequireAv(t, "sync", "--all") // main: X -> X2 // stack-1: \ -> 1a diff --git a/e2e_tests/stack_sync_amend_test.go b/e2e_tests/stack_sync_amend_test.go index 76a0ca08..25322368 100644 --- a/e2e_tests/stack_sync_amend_test.go +++ b/e2e_tests/stack_sync_amend_test.go @@ -43,7 +43,7 @@ func TestSyncAfterAmendingCommit(t *testing.T) { // Now we amend commit 1b and make sure the sync after succeeds repo.CheckoutBranch(t, "refs/heads/stack-1") repo.CommitFile(t, "my-file", "1a\n1c\n1b\n", gittest.WithAmend()) - RequireAv(t, "stack", "sync") + RequireAv(t, "sync") repo.CheckoutBranch(t, "refs/heads/stack-3") contents, err := os.ReadFile("my-file") require.NoError(t, err) diff --git a/e2e_tests/stack_sync_delete_merged_test.go b/e2e_tests/stack_sync_delete_merged_test.go index a837659f..fad1f763 100644 --- a/e2e_tests/stack_sync_delete_merged_test.go +++ b/e2e_tests/stack_sync_delete_merged_test.go @@ -33,7 +33,7 @@ func TestStackSyncDeleteMerged(t *testing.T) { ) // Everything up to date now, so this should be a no-op. - RequireAv(t, "stack", "sync") + RequireAv(t, "sync") // We simulate the pull branches on the remote. repo.Git(t, "push", "origin", "stack-1:refs/pull/42/head") @@ -76,7 +76,7 @@ func TestStackSyncDeleteMerged(t *testing.T) { require.NoError(t, tx.Commit()) repo.Git(t, "switch", "stack-1") - RequireAv(t, "stack", "sync", "--prune=yes") + RequireAv(t, "sync", "--prune=yes") require.Equal(t, 1, Cmd(t, "git", "show-ref", "refs/heads/stack-1").ExitCode, @@ -131,7 +131,7 @@ func TestStackSyncDeleteMerged_NoMain(t *testing.T) { repo.Git(t, "switch", "stack-1") repo.Git(t, "branch", "-D", "main") - RequireAv(t, "stack", "sync", "--prune=yes") + RequireAv(t, "sync", "--prune=yes") require.Equal(t, 1, Cmd(t, "git", "show-ref", "refs/heads/stack-1").ExitCode, diff --git a/e2e_tests/stack_sync_merge_commit_test.go b/e2e_tests/stack_sync_merge_commit_test.go index 4a2c13f3..f17e8390 100644 --- a/e2e_tests/stack_sync_merge_commit_test.go +++ b/e2e_tests/stack_sync_merge_commit_test.go @@ -47,7 +47,7 @@ func TestStackSyncMergeCommit(t *testing.T) { ) // Everything up to date now, so this should be a no-op. - RequireAv(t, "stack", "sync") + RequireAv(t, "sync") // We simulate a merge here so that our history looks like: // main: X / -> 1S @@ -99,7 +99,7 @@ func TestStackSyncMergeCommit(t *testing.T) { "squash commit of stack-1 should not be an ancestor of HEAD of stack-1 before running sync", ) - RequireAv(t, "stack", "sync") + RequireAv(t, "sync") repo.Git(t, "merge-base", "--is-ancestor", squashCommit.String(), "stack-3") assert.Equal(t, diff --git a/e2e_tests/stack_sync_merged_parent_test.go b/e2e_tests/stack_sync_merged_parent_test.go index 02e13f62..a4f79940 100644 --- a/e2e_tests/stack_sync_merged_parent_test.go +++ b/e2e_tests/stack_sync_merged_parent_test.go @@ -47,7 +47,7 @@ func TestStackSyncMergedParent(t *testing.T) { ) // Everything up to date now, so this should be a no-op. - RequireAv(t, "stack", "sync") + RequireAv(t, "sync") // We simulate a merge here so that our history looks like: // main: X @@ -92,7 +92,7 @@ func TestStackSyncMergedParent(t *testing.T) { "squash commit of stack-1 should not be an ancestor of HEAD of stack-1 before running sync", ) - RequireAv(t, "stack", "sync") + RequireAv(t, "sync") assert.Equal(t, meta.BranchState{ diff --git a/e2e_tests/stack_sync_test.go b/e2e_tests/stack_sync_test.go index 1e6d68f0..8d7679cd 100644 --- a/e2e_tests/stack_sync_test.go +++ b/e2e_tests/stack_sync_test.go @@ -47,7 +47,7 @@ func TestStackSync(t *testing.T) { repo.Git(t, "checkout", "stack-3") // Everything up to date now, so this should be a no-op. - RequireAv(t, "stack", "sync") + RequireAv(t, "sync") // We're going to add a commit to the first branch in the stack. // Our stack looks like: @@ -88,7 +88,7 @@ func TestStackSync(t *testing.T) { // Since both commits updated my-file in ways that conflict, we should get // a merge/rebase conflict here. - syncConflict := Av(t, "stack", "sync") + syncConflict := Av(t, "sync") require.NotEqual( t, 0, syncConflict.ExitCode, "stack sync should return non-zero exit code if conflicts", @@ -98,15 +98,15 @@ func TestStackSync(t *testing.T) { "error: could not apply", "stack sync should include error message on rebase", ) require.Contains( - t, syncConflict.Stdout, "av stack sync --continue", + t, syncConflict.Stdout, "av sync --continue", "stack sync should print a message with instructions to continue", ) - syncContinueWithoutResolving := Av(t, "stack", "sync", "--continue") + syncContinueWithoutResolving := Av(t, "sync", "--continue") require.NotEqual( t, 0, syncContinueWithoutResolving.ExitCode, - "stack sync --continue should return non-zero exit code if conflicts have not been resolved", + "sync --continue should return non-zero exit code if conflicts have not been resolved", ) // resolve the conflict err := os.WriteFile(filepath.Join(repo.RepoDir, "my-file"), []byte("1a\n1b\n2a\n"), 0644) @@ -114,7 +114,7 @@ func TestStackSync(t *testing.T) { repo.Git(t, "add", "my-file") require.NoError(t, err, "failed to stage file") // stack sync --continue should return zero exit code after resolving conflicts - RequireAv(t, "stack", "sync", "--continue") + RequireAv(t, "sync", "--continue") // Make sure we've handled the rebase of stack-3 correctly (see the long // comment above). @@ -134,7 +134,7 @@ func TestStackSync(t *testing.T) { require.Equal(t, mergeBases[0], stack1Head, "stack-2 should be up-to-date with stack-1") // Further sync attempts should yield no-ops - syncNoop := RequireAv(t, "stack", "sync") + syncNoop := RequireAv(t, "sync") require.Contains(t, syncNoop.Stdout, "Restack is done") // Make sure we've not introduced any extra commits @@ -188,7 +188,7 @@ func TestStackSyncAbort(t *testing.T) { repo.CommitFile(t, "my-file", "1a\n1b\n", gittest.WithMessage("Commit 1b")) // ... and make sure we get a conflict on sync... - syncConflict := Av(t, "stack", "sync") + syncConflict := Av(t, "sync") require.NotEqual( t, 0, @@ -202,7 +202,7 @@ func TestStackSyncAbort(t *testing.T) { ) // ... and then abort the sync... - RequireAv(t, "stack", "sync", "--abort") + RequireAv(t, "sync", "--abort") require.NoFileExists( t, filepath.Join(repo.GitDir, "REBASE_HEAD"), @@ -266,7 +266,7 @@ func TestStackSyncWithLotsOfConflicts(t *testing.T) { ) }) - sync := Av(t, "stack", "sync") + sync := Av(t, "sync") require.NotEqual( t, 0, @@ -279,7 +279,7 @@ func TestStackSyncWithLotsOfConflicts(t *testing.T) { // Commit 2b should be able to be applied normally, then we should have a // conflict with 3a - sync = Av(t, "stack", "sync", "--continue") + sync = Av(t, "sync", "--continue") require.NotEqual( t, 0, @@ -292,5 +292,5 @@ func TestStackSyncWithLotsOfConflicts(t *testing.T) { // And finally, 3b should be able to be applied without conflict and our stack // sync should be over. - RequireAv(t, "stack", "sync", "--continue") + RequireAv(t, "sync", "--continue") } diff --git a/e2e_tests/stack_sync_trunk_test.go b/e2e_tests/stack_sync_trunk_test.go index 23f01904..c3686386 100644 --- a/e2e_tests/stack_sync_trunk_test.go +++ b/e2e_tests/stack_sync_trunk_test.go @@ -32,7 +32,7 @@ func TestStackSyncTrunk(t *testing.T) { ) // Everything up to date now, so this should be a no-op. - RequireAv(t, "stack", "sync") + RequireAv(t, "sync") // We simulate a merge here so that our history looks like: // main: X --------------> 1S -> 3a @@ -73,7 +73,7 @@ func TestStackSyncTrunk(t *testing.T) { "squash commit of stack-1 should not be an ancestor of HEAD of stack-2 before running sync", ) - RequireAv(t, "stack", "sync") + RequireAv(t, "sync") // At this point, the stack should be: // // main: X --------------> 1S -> 3a diff --git a/internal/actions/pr.go b/internal/actions/pr.go index 9cb9637b..ba4940b4 100644 --- a/internal/actions/pr.go +++ b/internal/actions/pr.go @@ -272,7 +272,7 @@ func CreatePullRequest( // Check if a parent branch has already been merged or not if parentMeta.MergeCommit != "" { return nil, errors.Errorf( - "failed to create a pull request. The parent branch %q has already been merged\nPlease run av stack sync to rebase the branch first.", + "failed to create a pull request. The parent branch %q has already been merged\nPlease run av sync to rebase the branch first.", parentMeta.Name, ) } diff --git a/internal/git/rebase.go b/internal/git/rebase.go index ec761c85..8b3955ce 100644 --- a/internal/git/rebase.go +++ b/internal/git/rebase.go @@ -106,7 +106,7 @@ func normalizeRebaseHint(stderr []byte) string { res := string(stderr) res = carriageReturnRegex.ReplaceAllString(res, "") res = hintRegex.ReplaceAllString(res, "") - res = strings.ReplaceAll(res, "git rebase", "av stack sync") + res = strings.ReplaceAll(res, "git rebase", "av sync") return res }