Add new command "Move commits to new branch"

This commit is contained in:
Stefan Haller 2024-08-30 12:27:50 +02:00
parent 4bf11eae4b
commit 30868eead8
11 changed files with 423 additions and 4 deletions

View file

@ -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 {

View file

@ -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",

View file

@ -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 {

View file

@ -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),

View file

@ -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),

View file

@ -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 {

View file

@ -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)",

View file

@ -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"),
)
},
})

View file

@ -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"),
)
},
})

View file

@ -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"),
)
},
})

View file

@ -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,