diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 72473bc14..017792a68 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -40,6 +40,15 @@ func (self *BranchCommands) NewWithoutTracking(name string, base string) error { return self.cmd.New(cmdArgs).Run() } +// NewWithoutCheckout creates a new branch without checking it out +func (self *BranchCommands) NewWithoutCheckout(name string, base string) error { + cmdArgs := NewGitCmd("branch"). + Arg(name, base). + ToArgv() + + return self.cmd.New(cmdArgs).Run() +} + // CreateWithUpstream creates a new branch with a given upstream, but without // checking it out func (self *BranchCommands) CreateWithUpstream(name string, upstream string) error { diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 5e6b150b5..d35e35772 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -482,6 +482,7 @@ type KeybindingBranchesConfig struct { RebaseBranch string `yaml:"rebaseBranch"` RenameBranch string `yaml:"renameBranch"` MergeIntoCurrentBranch string `yaml:"mergeIntoCurrentBranch"` + MoveCommitsToNewBranch string `yaml:"moveCommitsToNewBranch"` ViewGitFlowOptions string `yaml:"viewGitFlowOptions"` FastForward string `yaml:"fastForward"` CreateTag string `yaml:"createTag"` @@ -962,6 +963,7 @@ func GetDefaultConfig() *UserConfig { RebaseBranch: "r", RenameBranch: "R", MergeIntoCurrentBranch: "M", + MoveCommitsToNewBranch: "N", ViewGitFlowOptions: "i", FastForward: "f", CreateTag: "T", diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 14c8882b9..abbb62155 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -27,12 +27,11 @@ func (gui *Gui) resetHelpersAndControllers() { helperCommon := gui.c recordDirectoryHelper := helpers.NewRecordDirectoryHelper(helperCommon) reposHelper := helpers.NewRecentReposHelper(helperCommon, recordDirectoryHelper, gui.onNewRepo) - refsHelper := helpers.NewRefsHelper(helperCommon) + rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon) + refsHelper := helpers.NewRefsHelper(helperCommon, rebaseHelper) suggestionsHelper := helpers.NewSuggestionsHelper(helperCommon) worktreeHelper := helpers.NewWorktreeHelper(helperCommon, reposHelper, refsHelper, suggestionsHelper) - rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon) - setCommitSummary := gui.getCommitMessageSetTextareaTextFn(func() *gocui.View { return gui.Views.CommitMessage }) setCommitDescription := gui.getCommitMessageSetTextareaTextFn(func() *gocui.View { return gui.Views.CommitDescription }) getCommitSummary := func() string { diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go index 76dd55006..89f0905e5 100644 --- a/pkg/gui/controllers/basic_commits_controller.go +++ b/pkg/gui/controllers/basic_commits_controller.go @@ -80,6 +80,17 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CreateNewBranchFromCommit, }, + { + // Putting this in BasicCommitsController even though we really only want it in the commits + // panel. But I find it important that this ends up next to "New Branch", and I couldn't + // find another way to achieve this. It's not such a big deal to have it in subcommits and + // reflog too, I'd say. + Key: opts.GetKey(opts.Config.Branches.MoveCommitsToNewBranch), + Handler: self.c.Helpers().Refs.MoveCommitsToNewBranch, + GetDisabledReason: self.c.Helpers().Refs.CanMoveCommitsToNewBranch, + Description: self.c.Tr.MoveCommitsToNewBranch, + Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip, + }, { Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), Handler: self.withItem(self.createResetMenu), diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index b164407e7..b3ce28ab8 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -57,6 +57,13 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty Description: self.c.Tr.NewBranch, DisplayOnScreen: true, }, + { + Key: opts.GetKey(opts.Config.Branches.MoveCommitsToNewBranch), + Handler: self.c.Helpers().Refs.MoveCommitsToNewBranch, + GetDisabledReason: self.c.Helpers().Refs.CanMoveCommitsToNewBranch, + Description: self.c.Tr.MoveCommitsToNewBranch, + Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip, + }, { Key: opts.GetKey(opts.Config.Branches.CreatePullRequest), Handler: self.withItem(self.handleCreatePullRequest), diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 296d66aca..9cc10983b 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -17,13 +17,17 @@ import ( type RefsHelper struct { c *HelperCommon + + rebaseHelper *MergeAndRebaseHelper } func NewRefsHelper( c *HelperCommon, + rebaseHelper *MergeAndRebaseHelper, ) *RefsHelper { return &RefsHelper{ - c: c, + c: c, + rebaseHelper: rebaseHelper, } } @@ -388,6 +392,174 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest return nil } +func (self *RefsHelper) MoveCommitsToNewBranch() error { + currentBranch := self.c.Model().Branches[0] + baseBranchRef, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(currentBranch, self.c.Model().MainBranches) + if err != nil { + return err + } + + withNewBranchNamePrompt := func(baseBranchName string, f func(string, string) error) { + prompt := utils.ResolvePlaceholderString( + self.c.Tr.NewBranchNameBranchOff, + map[string]string{ + "branchName": baseBranchName, + }, + ) + + self.c.Prompt(types.PromptOpts{ + Title: prompt, + HandleConfirm: func(response string) error { + self.c.LogAction(self.c.Tr.MoveCommitsToNewBranch) + newBranchName := SanitizedBranchName(response) + return self.c.WithWaitingStatus(self.c.Tr.MovingCommitsToNewBranchStatus, func(gocui.Task) error { + return f(currentBranch.Name, newBranchName) + }) + }, + }) + } + + isMainBranch := lo.Contains(self.c.UserConfig().Git.MainBranches, currentBranch.Name) + if isMainBranch { + prompt := utils.ResolvePlaceholderString( + self.c.Tr.MoveCommitsToNewBranchFromMainPrompt, + map[string]string{ + "baseBranchName": currentBranch.Name, + }, + ) + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.MoveCommitsToNewBranch, + Prompt: prompt, + HandleConfirm: func() error { + withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch) + return nil + }, + }) + return nil + } + + shortBaseBranchName := ShortBranchName(baseBranchRef) + prompt := utils.ResolvePlaceholderString( + self.c.Tr.MoveCommitsToNewBranchMenuPrompt, + map[string]string{ + "baseBranchName": shortBaseBranchName, + }, + ) + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.MoveCommitsToNewBranch, + Prompt: prompt, + Items: []*types.MenuItem{ + { + Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, shortBaseBranchName), + OnPress: func() error { + withNewBranchNamePrompt(shortBaseBranchName, func(currentBranch string, newBranchName string) error { + return self.moveCommitsToNewBranchOffOfMainBranch(currentBranch, newBranchName, baseBranchRef) + }) + return nil + }, + }, + { + Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchStackedItem, currentBranch.Name), + OnPress: func() error { + withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch) + return nil + }, + }, + }, + }) +} + +func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(currentBranch string, newBranchName string) error { + if err := self.c.Git().Branch.NewWithoutCheckout(newBranchName, "HEAD"); err != nil { + return err + } + + mustStash := IsWorkingTreeDirty(self.c.Model().Files) + if mustStash { + if err := self.c.Git().Stash.Push(self.c.Tr.StashPrefix + currentBranch); err != nil { + return err + } + } + + if err := self.c.Git().Commit.ResetToCommit("@{u}", "hard", []string{}); err != nil { + return err + } + + if err := self.c.Git().Branch.Checkout(newBranchName, git_commands.CheckoutOptions{}); err != nil { + return err + } + + if mustStash { + if err := self.c.Git().Stash.Pop(0); err != nil { + return err + } + } + + self.c.Contexts().LocalCommits.SetSelection(0) + self.c.Contexts().Branches.SetSelection(0) + + return self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) +} + +func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(currentBranch string, newBranchName string, baseBranchRef string) error { + commitsToCherryPick := lo.Filter(self.c.Model().Commits, func(commit *models.Commit, _ int) bool { + return commit.Status == models.StatusUnpushed + }) + + mustStash := IsWorkingTreeDirty(self.c.Model().Files) + if mustStash { + if err := self.c.Git().Stash.Push(self.c.Tr.StashPrefix + currentBranch); err != nil { + return err + } + } + + if err := self.c.Git().Commit.ResetToCommit("@{u}", "hard", []string{}); err != nil { + return err + } + + if err := self.c.Git().Branch.NewWithoutTracking(newBranchName, baseBranchRef); err != nil { + return err + } + + err := self.c.Git().Rebase.CherryPickCommits(commitsToCherryPick) + err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{Mode: types.SYNC}) + if err != nil { + return err + } + + if mustStash { + if err := self.c.Git().Stash.Pop(0); err != nil { + return err + } + } + + self.c.Contexts().LocalCommits.SetSelection(0) + self.c.Contexts().Branches.SetSelection(0) + + return self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) +} + +func (self *RefsHelper) CanMoveCommitsToNewBranch() *types.DisabledReason { + if len(self.c.Model().Branches) == 0 { + return &types.DisabledReason{Text: self.c.Tr.NoBranchesThisRepo} + } + currentBranch := self.GetCheckedOutRef() + if currentBranch.DetachedHead { + return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsFromDetachedHead, ShowErrorInPanel: true} + } + if !currentBranch.RemoteBranchStoredLocally() { + return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsNoUpstream, ShowErrorInPanel: true} + } + if currentBranch.IsBehindForPull() { + return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsBehindUpstream, ShowErrorInPanel: true} + } + if !currentBranch.IsAheadForPull() { + return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsNoUnpushedCommits, ShowErrorInPanel: true} + } + + return nil +} + // SanitizedBranchName will remove all spaces in favor of a dash "-" to meet // git's branch naming requirement. func SanitizedBranchName(input string) string { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 3b961e7d1..baf3e20de 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -151,6 +151,16 @@ type TranslationSet struct { CheckoutTypeDetachedHeadTooltip string NewBranch string NewBranchFromStashTooltip string + MoveCommitsToNewBranch string + MoveCommitsToNewBranchTooltip string + MoveCommitsToNewBranchFromMainPrompt string + MoveCommitsToNewBranchMenuPrompt string + MoveCommitsToNewBranchFromBaseItem string + MoveCommitsToNewBranchStackedItem string + CannotMoveCommitsFromDetachedHead string + CannotMoveCommitsNoUpstream string + CannotMoveCommitsBehindUpstream string + CannotMoveCommitsNoUnpushedCommits string NoBranchesThisRepo string CommitWithoutMessageErr string Close string @@ -413,6 +423,7 @@ type TranslationSet struct { RewordingStatus string RevertingStatus string CreatingFixupCommitStatus string + MovingCommitsToNewBranchStatus string CommitFiles string SubCommitsDynamicTitle string CommitFilesDynamicTitle string @@ -1217,6 +1228,16 @@ func EnglishTranslationSet() *TranslationSet { CheckoutTypeDetachedHeadTooltip: "Checkout the remote branch as a detached head, which can be useful if you just want to test the branch but not work on it yourself. You can still create a local branch from it later.", NewBranch: "New branch", NewBranchFromStashTooltip: "Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit.", + MoveCommitsToNewBranch: "Move commits to new branch", + MoveCommitsToNewBranchTooltip: "Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.\n\nNote that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which).", + MoveCommitsToNewBranchFromMainPrompt: "This will take all unpushed commits and move them to a new branch (off of {{.baseBranchName}}). It will then hard-reset the current branch its the upstream branch. Do you want to continue?", + MoveCommitsToNewBranchMenuPrompt: "This will take all unpushed commits and move them to a new branch. This new branch can either be created from the main branch ({{.baseBranchName}}) or stacked on top of the current branch. Which of these would you like to do?", + MoveCommitsToNewBranchFromBaseItem: "New branch from base branch (%s)", + MoveCommitsToNewBranchStackedItem: "New branch stacked on current branch (%s)", + CannotMoveCommitsFromDetachedHead: "Cannot move commits from a detached head", + CannotMoveCommitsNoUpstream: "Cannot move commits from a branch that has no upstream branch", + CannotMoveCommitsBehindUpstream: "Cannot move commits from a branch that is behind its upstream branch", + CannotMoveCommitsNoUnpushedCommits: "There are no unpushed commits to move to a new branch", NoBranchesThisRepo: "No branches for this repo", CommitWithoutMessageErr: "You cannot commit without a commit message", Close: "Close", @@ -1488,6 +1509,7 @@ func EnglishTranslationSet() *TranslationSet { RewordingStatus: "Rewording", RevertingStatus: "Reverting", CreatingFixupCommitStatus: "Creating fixup commit", + MovingCommitsToNewBranchStatus: "Moving commits to new branch", CommitFiles: "Commit files", SubCommitsDynamicTitle: "Commits (%s)", CommitFilesDynamicTitle: "Diff files (%s)", diff --git a/pkg/integration/tests/branch/move_commits_to_new_branch_from_base_branch.go b/pkg/integration/tests/branch/move_commits_to_new_branch_from_base_branch.go new file mode 100644 index 000000000..0b6bd71aa --- /dev/null +++ b/pkg/integration/tests/branch/move_commits_to_new_branch_from_base_branch.go @@ -0,0 +1,66 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var MoveCommitsToNewBranchFromBaseBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Create a new branch from the commits that you accidentally made on the wrong branch; choosing base branch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + shell.CloneIntoRemote("origin") + shell.PushBranchAndSetUpstream("origin", "master") + shell.NewBranch("feature") + shell.EmptyCommit("feature branch commit") + shell.PushBranchAndSetUpstream("origin", "feature") + shell.CreateFileAndAdd("file1", "file1 content") + shell.Commit("new commit 1") + shell.EmptyCommit("new commit 2") + shell.UpdateFile("file1", "file1 changed") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Lines( + Contains("M file1"), + ) + t.Views().Branches(). + Focus(). + Lines( + Contains("feature ↑2").IsSelected(), + Contains("master ✓"), + ). + Press(keys.Branches.MoveCommitsToNewBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Move commits to new branch")). + Select(Contains("New branch from base branch (origin/master)")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("New branch name (branch is off of 'origin/master')")). + Type("new branch"). + Confirm() + + t.Views().Branches(). + Lines( + Contains("new-branch").DoesNotContain("↑").IsSelected(), + Contains("feature ✓"), + Contains("master ✓"), + ) + + t.Views().Commits(). + Lines( + Contains("new commit 2").IsSelected(), + Contains("new commit 1"), + Contains("initial commit"), + ) + t.Views().Files(). + Lines( + Contains("M file1"), + ) + }, +}) diff --git a/pkg/integration/tests/branch/move_commits_to_new_branch_from_main_branch.go b/pkg/integration/tests/branch/move_commits_to_new_branch_from_main_branch.go new file mode 100644 index 000000000..3373064eb --- /dev/null +++ b/pkg/integration/tests/branch/move_commits_to_new_branch_from_main_branch.go @@ -0,0 +1,61 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var MoveCommitsToNewBranchFromMainBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Create a new branch from the commits that you accidentally made on master", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + shell.CloneIntoRemote("origin") + shell.PushBranchAndSetUpstream("origin", "master") + shell.CreateFileAndAdd("file1", "file1 content") + shell.Commit("new commit 1") + shell.EmptyCommit("new commit 2") + shell.UpdateFile("file1", "file1 changed") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Lines( + Contains("M file1"), + ) + t.Views().Branches(). + Focus(). + Lines( + Contains("master ↑2").IsSelected(), + ). + Press(keys.Branches.MoveCommitsToNewBranch) + + t.ExpectPopup().Confirmation(). + Title(Equals("Move commits to new branch")). + Content(Contains("This will take all unpushed commits and move them to a new branch (off of master).")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("New branch name (branch is off of 'master')")). + Type("new branch"). + Confirm() + + t.Views().Branches(). + Lines( + Contains("new-branch").DoesNotContain("↑").IsSelected(), + Contains("master ✓"), + ) + + t.Views().Commits(). + Lines( + Contains("new commit 2").IsSelected(), + Contains("new commit 1"), + Contains("initial commit"), + ) + t.Views().Files(). + Lines( + Contains("M file1"), + ) + }, +}) diff --git a/pkg/integration/tests/branch/move_commits_to_new_branch_keep_stacked.go b/pkg/integration/tests/branch/move_commits_to_new_branch_keep_stacked.go new file mode 100644 index 000000000..0b9828ef1 --- /dev/null +++ b/pkg/integration/tests/branch/move_commits_to_new_branch_keep_stacked.go @@ -0,0 +1,67 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var MoveCommitsToNewBranchKeepStacked = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Create a new branch from the commits that you accidentally made on the wrong branch; choosing stacked on current branch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + shell.CloneIntoRemote("origin") + shell.PushBranchAndSetUpstream("origin", "master") + shell.NewBranch("feature") + shell.EmptyCommit("feature branch commit") + shell.PushBranchAndSetUpstream("origin", "feature") + shell.CreateFileAndAdd("file1", "file1 content") + shell.Commit("new commit 1") + shell.EmptyCommit("new commit 2") + shell.UpdateFile("file1", "file1 changed") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Lines( + Contains("M file1"), + ) + t.Views().Branches(). + Focus(). + Lines( + Contains("feature ↑2").IsSelected(), + Contains("master ✓"), + ). + Press(keys.Branches.MoveCommitsToNewBranch) + + t.ExpectPopup().Menu(). + Title(Equals("Move commits to new branch")). + Select(Contains("New branch stacked on current branch (feature)")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("New branch name (branch is off of 'feature')")). + Type("new branch"). + Confirm() + + t.Views().Branches(). + Lines( + Contains("new-branch").DoesNotContain("↑").IsSelected(), + Contains("feature ✓"), + Contains("master ✓"), + ) + + t.Views().Commits(). + Lines( + Contains("new commit 2").IsSelected(), + Contains("new commit 1"), + Contains("* feature branch commit"), + Contains("initial commit"), + ) + t.Views().Files(). + Lines( + Contains("M file1"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 96a895798..03d995d92 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -47,6 +47,9 @@ var tests = []*components.IntegrationTest{ branch.DeleteRemoteBranchWithDifferentName, branch.DeleteWhileFiltering, branch.DetachedHead, + branch.MoveCommitsToNewBranchFromBaseBranch, + branch.MoveCommitsToNewBranchFromMainBranch, + branch.MoveCommitsToNewBranchKeepStacked, branch.NewBranchAutostash, branch.NewBranchFromRemoteTrackingDifferentName, branch.NewBranchFromRemoteTrackingSameName,