Skip to content

Commit 30868ee

Browse files
committed
Add new command "Move commits to new branch"
1 parent 4bf11ea commit 30868ee

11 files changed

+423
-4
lines changed

pkg/commands/git_commands/branch.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ func (self *BranchCommands) NewWithoutTracking(name string, base string) error {
4040
return self.cmd.New(cmdArgs).Run()
4141
}
4242

43+
// NewWithoutCheckout creates a new branch without checking it out
44+
func (self *BranchCommands) NewWithoutCheckout(name string, base string) error {
45+
cmdArgs := NewGitCmd("branch").
46+
Arg(name, base).
47+
ToArgv()
48+
49+
return self.cmd.New(cmdArgs).Run()
50+
}
51+
4352
// CreateWithUpstream creates a new branch with a given upstream, but without
4453
// checking it out
4554
func (self *BranchCommands) CreateWithUpstream(name string, upstream string) error {

pkg/config/user_config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ type KeybindingBranchesConfig struct {
482482
RebaseBranch string `yaml:"rebaseBranch"`
483483
RenameBranch string `yaml:"renameBranch"`
484484
MergeIntoCurrentBranch string `yaml:"mergeIntoCurrentBranch"`
485+
MoveCommitsToNewBranch string `yaml:"moveCommitsToNewBranch"`
485486
ViewGitFlowOptions string `yaml:"viewGitFlowOptions"`
486487
FastForward string `yaml:"fastForward"`
487488
CreateTag string `yaml:"createTag"`
@@ -962,6 +963,7 @@ func GetDefaultConfig() *UserConfig {
962963
RebaseBranch: "r",
963964
RenameBranch: "R",
964965
MergeIntoCurrentBranch: "M",
966+
MoveCommitsToNewBranch: "N",
965967
ViewGitFlowOptions: "i",
966968
FastForward: "f",
967969
CreateTag: "T",

pkg/gui/controllers.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,11 @@ func (gui *Gui) resetHelpersAndControllers() {
2727
helperCommon := gui.c
2828
recordDirectoryHelper := helpers.NewRecordDirectoryHelper(helperCommon)
2929
reposHelper := helpers.NewRecentReposHelper(helperCommon, recordDirectoryHelper, gui.onNewRepo)
30-
refsHelper := helpers.NewRefsHelper(helperCommon)
30+
rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon)
31+
refsHelper := helpers.NewRefsHelper(helperCommon, rebaseHelper)
3132
suggestionsHelper := helpers.NewSuggestionsHelper(helperCommon)
3233
worktreeHelper := helpers.NewWorktreeHelper(helperCommon, reposHelper, refsHelper, suggestionsHelper)
3334

34-
rebaseHelper := helpers.NewMergeAndRebaseHelper(helperCommon)
35-
3635
setCommitSummary := gui.getCommitMessageSetTextareaTextFn(func() *gocui.View { return gui.Views.CommitMessage })
3736
setCommitDescription := gui.getCommitMessageSetTextareaTextFn(func() *gocui.View { return gui.Views.CommitDescription })
3837
getCommitSummary := func() string {

pkg/gui/controllers/basic_commits_controller.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
8080
GetDisabledReason: self.require(self.singleItemSelected()),
8181
Description: self.c.Tr.CreateNewBranchFromCommit,
8282
},
83+
{
84+
// Putting this in BasicCommitsController even though we really only want it in the commits
85+
// panel. But I find it important that this ends up next to "New Branch", and I couldn't
86+
// find another way to achieve this. It's not such a big deal to have it in subcommits and
87+
// reflog too, I'd say.
88+
Key: opts.GetKey(opts.Config.Branches.MoveCommitsToNewBranch),
89+
Handler: self.c.Helpers().Refs.MoveCommitsToNewBranch,
90+
GetDisabledReason: self.c.Helpers().Refs.CanMoveCommitsToNewBranch,
91+
Description: self.c.Tr.MoveCommitsToNewBranch,
92+
Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip,
93+
},
8394
{
8495
Key: opts.GetKey(opts.Config.Commits.ViewResetOptions),
8596
Handler: self.withItem(self.createResetMenu),

pkg/gui/controllers/branches_controller.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
5757
Description: self.c.Tr.NewBranch,
5858
DisplayOnScreen: true,
5959
},
60+
{
61+
Key: opts.GetKey(opts.Config.Branches.MoveCommitsToNewBranch),
62+
Handler: self.c.Helpers().Refs.MoveCommitsToNewBranch,
63+
GetDisabledReason: self.c.Helpers().Refs.CanMoveCommitsToNewBranch,
64+
Description: self.c.Tr.MoveCommitsToNewBranch,
65+
Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip,
66+
},
6067
{
6168
Key: opts.GetKey(opts.Config.Branches.CreatePullRequest),
6269
Handler: self.withItem(self.handleCreatePullRequest),

pkg/gui/controllers/helpers/refs_helper.go

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@ import (
1717

1818
type RefsHelper struct {
1919
c *HelperCommon
20+
21+
rebaseHelper *MergeAndRebaseHelper
2022
}
2123

2224
func NewRefsHelper(
2325
c *HelperCommon,
26+
rebaseHelper *MergeAndRebaseHelper,
2427
) *RefsHelper {
2528
return &RefsHelper{
26-
c: c,
29+
c: c,
30+
rebaseHelper: rebaseHelper,
2731
}
2832
}
2933

@@ -388,6 +392,174 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
388392
return nil
389393
}
390394

395+
func (self *RefsHelper) MoveCommitsToNewBranch() error {
396+
currentBranch := self.c.Model().Branches[0]
397+
baseBranchRef, err := self.c.Git().Loaders.BranchLoader.GetBaseBranch(currentBranch, self.c.Model().MainBranches)
398+
if err != nil {
399+
return err
400+
}
401+
402+
withNewBranchNamePrompt := func(baseBranchName string, f func(string, string) error) {
403+
prompt := utils.ResolvePlaceholderString(
404+
self.c.Tr.NewBranchNameBranchOff,
405+
map[string]string{
406+
"branchName": baseBranchName,
407+
},
408+
)
409+
410+
self.c.Prompt(types.PromptOpts{
411+
Title: prompt,
412+
HandleConfirm: func(response string) error {
413+
self.c.LogAction(self.c.Tr.MoveCommitsToNewBranch)
414+
newBranchName := SanitizedBranchName(response)
415+
return self.c.WithWaitingStatus(self.c.Tr.MovingCommitsToNewBranchStatus, func(gocui.Task) error {
416+
return f(currentBranch.Name, newBranchName)
417+
})
418+
},
419+
})
420+
}
421+
422+
isMainBranch := lo.Contains(self.c.UserConfig().Git.MainBranches, currentBranch.Name)
423+
if isMainBranch {
424+
prompt := utils.ResolvePlaceholderString(
425+
self.c.Tr.MoveCommitsToNewBranchFromMainPrompt,
426+
map[string]string{
427+
"baseBranchName": currentBranch.Name,
428+
},
429+
)
430+
self.c.Confirm(types.ConfirmOpts{
431+
Title: self.c.Tr.MoveCommitsToNewBranch,
432+
Prompt: prompt,
433+
HandleConfirm: func() error {
434+
withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch)
435+
return nil
436+
},
437+
})
438+
return nil
439+
}
440+
441+
shortBaseBranchName := ShortBranchName(baseBranchRef)
442+
prompt := utils.ResolvePlaceholderString(
443+
self.c.Tr.MoveCommitsToNewBranchMenuPrompt,
444+
map[string]string{
445+
"baseBranchName": shortBaseBranchName,
446+
},
447+
)
448+
return self.c.Menu(types.CreateMenuOptions{
449+
Title: self.c.Tr.MoveCommitsToNewBranch,
450+
Prompt: prompt,
451+
Items: []*types.MenuItem{
452+
{
453+
Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, shortBaseBranchName),
454+
OnPress: func() error {
455+
withNewBranchNamePrompt(shortBaseBranchName, func(currentBranch string, newBranchName string) error {
456+
return self.moveCommitsToNewBranchOffOfMainBranch(currentBranch, newBranchName, baseBranchRef)
457+
})
458+
return nil
459+
},
460+
},
461+
{
462+
Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchStackedItem, currentBranch.Name),
463+
OnPress: func() error {
464+
withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch)
465+
return nil
466+
},
467+
},
468+
},
469+
})
470+
}
471+
472+
func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(currentBranch string, newBranchName string) error {
473+
if err := self.c.Git().Branch.NewWithoutCheckout(newBranchName, "HEAD"); err != nil {
474+
return err
475+
}
476+
477+
mustStash := IsWorkingTreeDirty(self.c.Model().Files)
478+
if mustStash {
479+
if err := self.c.Git().Stash.Push(self.c.Tr.StashPrefix + currentBranch); err != nil {
480+
return err
481+
}
482+
}
483+
484+
if err := self.c.Git().Commit.ResetToCommit("@{u}", "hard", []string{}); err != nil {
485+
return err
486+
}
487+
488+
if err := self.c.Git().Branch.Checkout(newBranchName, git_commands.CheckoutOptions{}); err != nil {
489+
return err
490+
}
491+
492+
if mustStash {
493+
if err := self.c.Git().Stash.Pop(0); err != nil {
494+
return err
495+
}
496+
}
497+
498+
self.c.Contexts().LocalCommits.SetSelection(0)
499+
self.c.Contexts().Branches.SetSelection(0)
500+
501+
return self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true})
502+
}
503+
504+
func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(currentBranch string, newBranchName string, baseBranchRef string) error {
505+
commitsToCherryPick := lo.Filter(self.c.Model().Commits, func(commit *models.Commit, _ int) bool {
506+
return commit.Status == models.StatusUnpushed
507+
})
508+
509+
mustStash := IsWorkingTreeDirty(self.c.Model().Files)
510+
if mustStash {
511+
if err := self.c.Git().Stash.Push(self.c.Tr.StashPrefix + currentBranch); err != nil {
512+
return err
513+
}
514+
}
515+
516+
if err := self.c.Git().Commit.ResetToCommit("@{u}", "hard", []string{}); err != nil {
517+
return err
518+
}
519+
520+
if err := self.c.Git().Branch.NewWithoutTracking(newBranchName, baseBranchRef); err != nil {
521+
return err
522+
}
523+
524+
err := self.c.Git().Rebase.CherryPickCommits(commitsToCherryPick)
525+
err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{Mode: types.SYNC})
526+
if err != nil {
527+
return err
528+
}
529+
530+
if mustStash {
531+
if err := self.c.Git().Stash.Pop(0); err != nil {
532+
return err
533+
}
534+
}
535+
536+
self.c.Contexts().LocalCommits.SetSelection(0)
537+
self.c.Contexts().Branches.SetSelection(0)
538+
539+
return self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true})
540+
}
541+
542+
func (self *RefsHelper) CanMoveCommitsToNewBranch() *types.DisabledReason {
543+
if len(self.c.Model().Branches) == 0 {
544+
return &types.DisabledReason{Text: self.c.Tr.NoBranchesThisRepo}
545+
}
546+
currentBranch := self.GetCheckedOutRef()
547+
if currentBranch.DetachedHead {
548+
return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsFromDetachedHead, ShowErrorInPanel: true}
549+
}
550+
if !currentBranch.RemoteBranchStoredLocally() {
551+
return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsNoUpstream, ShowErrorInPanel: true}
552+
}
553+
if currentBranch.IsBehindForPull() {
554+
return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsBehindUpstream, ShowErrorInPanel: true}
555+
}
556+
if !currentBranch.IsAheadForPull() {
557+
return &types.DisabledReason{Text: self.c.Tr.CannotMoveCommitsNoUnpushedCommits, ShowErrorInPanel: true}
558+
}
559+
560+
return nil
561+
}
562+
391563
// SanitizedBranchName will remove all spaces in favor of a dash "-" to meet
392564
// git's branch naming requirement.
393565
func SanitizedBranchName(input string) string {

pkg/i18n/english.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,16 @@ type TranslationSet struct {
151151
CheckoutTypeDetachedHeadTooltip string
152152
NewBranch string
153153
NewBranchFromStashTooltip string
154+
MoveCommitsToNewBranch string
155+
MoveCommitsToNewBranchTooltip string
156+
MoveCommitsToNewBranchFromMainPrompt string
157+
MoveCommitsToNewBranchMenuPrompt string
158+
MoveCommitsToNewBranchFromBaseItem string
159+
MoveCommitsToNewBranchStackedItem string
160+
CannotMoveCommitsFromDetachedHead string
161+
CannotMoveCommitsNoUpstream string
162+
CannotMoveCommitsBehindUpstream string
163+
CannotMoveCommitsNoUnpushedCommits string
154164
NoBranchesThisRepo string
155165
CommitWithoutMessageErr string
156166
Close string
@@ -413,6 +423,7 @@ type TranslationSet struct {
413423
RewordingStatus string
414424
RevertingStatus string
415425
CreatingFixupCommitStatus string
426+
MovingCommitsToNewBranchStatus string
416427
CommitFiles string
417428
SubCommitsDynamicTitle string
418429
CommitFilesDynamicTitle string
@@ -1217,6 +1228,16 @@ func EnglishTranslationSet() *TranslationSet {
12171228
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.",
12181229
NewBranch: "New branch",
12191230
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.",
1231+
MoveCommitsToNewBranch: "Move commits to new branch",
1232+
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).",
1233+
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?",
1234+
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?",
1235+
MoveCommitsToNewBranchFromBaseItem: "New branch from base branch (%s)",
1236+
MoveCommitsToNewBranchStackedItem: "New branch stacked on current branch (%s)",
1237+
CannotMoveCommitsFromDetachedHead: "Cannot move commits from a detached head",
1238+
CannotMoveCommitsNoUpstream: "Cannot move commits from a branch that has no upstream branch",
1239+
CannotMoveCommitsBehindUpstream: "Cannot move commits from a branch that is behind its upstream branch",
1240+
CannotMoveCommitsNoUnpushedCommits: "There are no unpushed commits to move to a new branch",
12201241
NoBranchesThisRepo: "No branches for this repo",
12211242
CommitWithoutMessageErr: "You cannot commit without a commit message",
12221243
Close: "Close",
@@ -1488,6 +1509,7 @@ func EnglishTranslationSet() *TranslationSet {
14881509
RewordingStatus: "Rewording",
14891510
RevertingStatus: "Reverting",
14901511
CreatingFixupCommitStatus: "Creating fixup commit",
1512+
MovingCommitsToNewBranchStatus: "Moving commits to new branch",
14911513
CommitFiles: "Commit files",
14921514
SubCommitsDynamicTitle: "Commits (%s)",
14931515
CommitFilesDynamicTitle: "Diff files (%s)",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package branch
2+
3+
import (
4+
"github.com/jesseduffield/lazygit/pkg/config"
5+
. "github.com/jesseduffield/lazygit/pkg/integration/components"
6+
)
7+
8+
var MoveCommitsToNewBranchFromBaseBranch = NewIntegrationTest(NewIntegrationTestArgs{
9+
Description: "Create a new branch from the commits that you accidentally made on the wrong branch; choosing base branch",
10+
ExtraCmdArgs: []string{},
11+
Skip: false,
12+
SetupConfig: func(config *config.AppConfig) {},
13+
SetupRepo: func(shell *Shell) {
14+
shell.EmptyCommit("initial commit")
15+
shell.CloneIntoRemote("origin")
16+
shell.PushBranchAndSetUpstream("origin", "master")
17+
shell.NewBranch("feature")
18+
shell.EmptyCommit("feature branch commit")
19+
shell.PushBranchAndSetUpstream("origin", "feature")
20+
shell.CreateFileAndAdd("file1", "file1 content")
21+
shell.Commit("new commit 1")
22+
shell.EmptyCommit("new commit 2")
23+
shell.UpdateFile("file1", "file1 changed")
24+
},
25+
Run: func(t *TestDriver, keys config.KeybindingConfig) {
26+
t.Views().Files().
27+
Lines(
28+
Contains("M file1"),
29+
)
30+
t.Views().Branches().
31+
Focus().
32+
Lines(
33+
Contains("feature ↑2").IsSelected(),
34+
Contains("master ✓"),
35+
).
36+
Press(keys.Branches.MoveCommitsToNewBranch)
37+
38+
t.ExpectPopup().Menu().
39+
Title(Equals("Move commits to new branch")).
40+
Select(Contains("New branch from base branch (origin/master)")).
41+
Confirm()
42+
43+
t.ExpectPopup().Prompt().
44+
Title(Equals("New branch name (branch is off of 'origin/master')")).
45+
Type("new branch").
46+
Confirm()
47+
48+
t.Views().Branches().
49+
Lines(
50+
Contains("new-branch").DoesNotContain("↑").IsSelected(),
51+
Contains("feature ✓"),
52+
Contains("master ✓"),
53+
)
54+
55+
t.Views().Commits().
56+
Lines(
57+
Contains("new commit 2").IsSelected(),
58+
Contains("new commit 1"),
59+
Contains("initial commit"),
60+
)
61+
t.Views().Files().
62+
Lines(
63+
Contains("M file1"),
64+
)
65+
},
66+
})

0 commit comments

Comments
 (0)