1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-04-11 11:42:12 +02:00

Add ability to start an interactive rebase onto an appropriate base

A common issue I have is that I want to move a commit from the top of my branch
all the way down to the first commit on the branch. To do that, I need to navigate
down to the first commit on my branch, press 'e' to start an interactive rebase,
then navigate back up to the top of the branch, then move my commit back down to
the base. This is annoying.

Similarly annoying is moving the commit one-by-one without explicitly starting
an interactive rebase, because then each individual step is its own rebase which
takes a while in aggregate.

This PR allows you to press 'i' from the commits view to start an interactive
rebase from an 'appropriate' base. By appropriate, we mean that we want to start
from the HEAD and stop when we reach the first merge commit or commit on the main
branch. This may end up including more commits than you need, but it doesn't make
a difference.
This commit is contained in:
Jesse Duffield 2024-01-12 10:32:20 +11:00
parent e9962b98a7
commit 53a8bd2e3f
15 changed files with 247 additions and 3 deletions

View File

@ -79,6 +79,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>R</kbd>: Reword commit with editor
<kbd>d</kbd>: Delete commit
<kbd>e</kbd>: Edit commit
<kbd>i</kbd>: Start interactive rebase
<kbd>p</kbd>: Pick commit (when mid-rebase)
<kbd>F</kbd>: Create fixup commit for this commit
<kbd>S</kbd>: Squash all 'fixup!' commits above selected commit (autosquash)

View File

@ -98,6 +98,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>R</kbd>: エディタでコミットメッセージを編集
<kbd>d</kbd>: コミットを削除
<kbd>e</kbd>: コミットを編集
<kbd>i</kbd>: Start interactive rebase
<kbd>p</kbd>: Pick commit (when mid-rebase)
<kbd>F</kbd>: このコミットに対するfixupコミットを作成
<kbd>S</kbd>: Squash all 'fixup!' commits above selected commit (autosquash)

View File

@ -263,6 +263,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>R</kbd>: 에디터에서 커밋메시지 수정
<kbd>d</kbd>: 커밋 삭제
<kbd>e</kbd>: 커밋을 편집
<kbd>i</kbd>: Start interactive rebase
<kbd>p</kbd>: Pick commit (when mid-rebase)
<kbd>F</kbd>: Create fixup commit for this commit
<kbd>S</kbd>: Squash all 'fixup!' commits above selected commit (autosquash)

View File

@ -142,6 +142,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>R</kbd>: Hernoem commit met editor
<kbd>d</kbd>: Verwijder commit
<kbd>e</kbd>: Wijzig commit
<kbd>i</kbd>: Start interactive rebase
<kbd>p</kbd>: Kies commit (wanneer midden in rebase)
<kbd>F</kbd>: Creëer fixup commit
<kbd>S</kbd>: Squash bovenstaande commits

View File

@ -63,6 +63,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>R</kbd>: Zmień nazwę commita w edytorze
<kbd>d</kbd>: Usuń commit
<kbd>e</kbd>: Edytuj commit
<kbd>i</kbd>: Start interactive rebase
<kbd>p</kbd>: Wybierz commit (podczas zmiany bazy)
<kbd>F</kbd>: Utwórz commit naprawczy dla tego commita
<kbd>S</kbd>: Spłaszcz wszystkie commity naprawcze powyżej zaznaczonych commitów (autosquash)

View File

@ -146,6 +146,7 @@ _Связки клавиш_
<kbd>R</kbd>: Переписать коммит с помощью редактора
<kbd>d</kbd>: Удалить коммит
<kbd>e</kbd>: Изменить коммит
<kbd>i</kbd>: Start interactive rebase
<kbd>p</kbd>: Выбрать коммит (в середине перебазирования)
<kbd>F</kbd>: Создать fixup коммит для этого коммита
<kbd>S</kbd>: Объединить все 'fixup!' коммиты выше в выбранный коммит (автосохранение)

View File

@ -144,6 +144,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>R</kbd>: 使用编辑器重命名提交
<kbd>d</kbd>: 删除提交
<kbd>e</kbd>: 编辑提交
<kbd>i</kbd>: Start interactive rebase
<kbd>p</kbd>: 选择提交(变基过程中)
<kbd>F</kbd>: 创建修正提交
<kbd>S</kbd>: 压缩在所选提交之上的所有“fixup!”提交(自动压缩)

View File

@ -187,6 +187,7 @@ _說明:`<c-b>` 表示 Ctrl+B、`<a-b>` 表示 Alt+B,`B`表示 Shift+B_
<kbd>R</kbd>: 使用編輯器改寫提交
<kbd>d</kbd>: 刪除提交
<kbd>e</kbd>: 編輯提交
<kbd>i</kbd>: Start interactive rebase
<kbd>p</kbd>: 挑選提交 (於變基過程中)
<kbd>F</kbd>: 為此提交建立修復提交
<kbd>S</kbd>: 壓縮上方所有的“fixup!”提交 (自動壓縮)

View File

@ -428,6 +428,7 @@ type KeybindingCommitsConfig struct {
OpenLogMenu string `yaml:"openLogMenu"`
OpenInBrowser string `yaml:"openInBrowser"`
ViewBisectOptions string `yaml:"viewBisectOptions"`
StartInteractiveRebase string `yaml:"startInteractiveRebase"`
}
type KeybindingStashConfig struct {
@ -822,6 +823,7 @@ func GetDefaultConfig() *UserConfig {
OpenLogMenu: "<c-l>",
OpenInBrowser: "o",
ViewBisectOptions: "b",
StartInteractiveRebase: "i",
},
Stash: KeybindingStashConfig{
PopStash: "g",

View File

@ -4,11 +4,13 @@ import (
"fmt"
"github.com/fsmiamoto/git-todo-parser/todo"
"github.com/go-errors/errors"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
@ -42,6 +44,8 @@ func NewLocalCommitsController(
}
func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
editCommitKey := opts.Config.Universal.Edit
outsideFilterModeBindings := []*types.Binding{
{
Key: opts.GetKey(opts.Config.Commits.SquashDown),
@ -74,11 +78,23 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
Description: self.c.Tr.DeleteCommit,
},
{
Key: opts.GetKey(opts.Config.Universal.Edit),
Key: opts.GetKey(editCommitKey),
Handler: self.checkSelected(self.edit),
GetDisabledReason: self.getDisabledReasonForRebaseCommandWithSelectedCommit(todo.Edit),
Description: self.c.Tr.EditCommit,
},
{
// The user-facing description here is 'Start interactive rebase' but internally
// we're calling it 'quick-start interactive rebase' to differentiate it from
// when you manually select the base commit.
Key: opts.GetKey(opts.Config.Commits.StartInteractiveRebase),
Handler: self.checkSelected(self.quickStartInteractiveRebase),
GetDisabledReason: self.require(self.notMidRebase, self.canFindCommitForQuickStart),
Description: self.c.Tr.QuickStartInteractiveRebase,
Tooltip: utils.ResolvePlaceholderString(self.c.Tr.QuickStartInteractiveRebaseTooltip, map[string]string{
"editKey": keybindings.Label(editCommitKey),
}),
},
{
Key: opts.GetKey(opts.Config.Commits.PickCommit),
Handler: self.checkSelected(self.pick),
@ -414,14 +430,33 @@ func (self *LocalCommitsController) edit(commit *models.Commit) error {
return nil
}
return self.startInteractiveRebaseWithEdit(commit, commit)
}
func (self *LocalCommitsController) quickStartInteractiveRebase(selectedCommit *models.Commit) error {
commitToEdit, err := self.findCommitForQuickStartInteractiveRebase()
if err != nil {
return self.c.Error(err)
}
return self.startInteractiveRebaseWithEdit(commitToEdit, selectedCommit)
}
func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
commitToEdit *models.Commit,
selectedCommit *models.Commit,
) error {
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.EditCommit)
err := self.c.Git().Rebase.EditRebase(commit.Sha)
err := self.c.Git().Rebase.EditRebase(commitToEdit.Sha)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err,
types.RefreshOptions{Mode: types.BLOCK_UI, Then: func() {
// We need to select the same commit again because after starting a rebase,
// new lines can be added for update-ref commands in the TODO file, due to
// stacked branches. So the commit may be in a different position in the list.
_, index, ok := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool {
return c.Sha == commit.Sha
return c.Sha == selectedCommit.Sha
})
if ok {
self.context().SetSelectedLineIdx(index)
@ -430,6 +465,22 @@ func (self *LocalCommitsController) edit(commit *models.Commit) error {
})
}
func (self *LocalCommitsController) findCommitForQuickStartInteractiveRebase() (*models.Commit, error) {
commit, index, ok := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool {
return c.IsMerge() || c.Status == models.StatusMerged
})
if !ok || index == 0 {
errorMsg := utils.ResolvePlaceholderString(self.c.Tr.CannotQuickStartInteractiveRebase, map[string]string{
"editKey": keybindings.Label(self.c.UserConfig.Keybinding.Universal.Edit),
})
return nil, errors.New(errorMsg)
}
return commit, nil
}
func (self *LocalCommitsController) pick(commit *models.Commit) error {
applied, err := self.handleMidRebaseCommand(todo.Pick, commit)
if err != nil {
@ -827,6 +878,24 @@ func (self *LocalCommitsController) getDisabledReasonForSquashAllAboveFixupCommi
return ""
}
// For getting disabled reason
func (self *LocalCommitsController) notMidRebase() string {
if self.c.Git().Status.WorkingTreeState() != enums.REBASE_MODE_NONE {
return self.c.Tr.AlreadyRebasing
}
return ""
}
// For getting disabled reason
func (self *LocalCommitsController) canFindCommitForQuickStart() string {
if _, err := self.findCommitForQuickStartInteractiveRebase(); err != nil {
return err.Error()
}
return ""
}
func (self *LocalCommitsController) createTag(commit *models.Commit) error {
return self.c.Helpers().Tags.OpenCreateTagPrompt(commit.Sha, func() {})
}
@ -1030,6 +1099,19 @@ func (self *LocalCommitsController) isHeadCommit() bool {
return models.IsHeadCommit(self.c.Model().Commits, self.context().GetSelectedLineIdx())
}
// Convenience function for composing multiple disabled reason functions
func (self *LocalCommitsController) require(callbacks ...func() string) func() string {
return func() string {
for _, callback := range callbacks {
if disabledReason := callback(); disabledReason != "" {
return disabledReason
}
}
return ""
}
}
func isChangeOfRebaseTodoAllowed(action todo.TodoCommand) bool {
allowedActions := []todo.TodoCommand{
todo.Pick,

View File

@ -648,6 +648,9 @@ type TranslationSet struct {
DisabledMenuItemPrefix string
NoCommitSelected string
NoCopiedCommits string
QuickStartInteractiveRebase string
QuickStartInteractiveRebaseTooltip string
CannotQuickStartInteractiveRebase string
Actions Actions
Bisect Bisect
Log Log
@ -1477,6 +1480,9 @@ func EnglishTranslationSet() TranslationSet {
DisabledMenuItemPrefix: "Disabled: ",
NoCommitSelected: "No commit selected",
NoCopiedCommits: "No copied commits",
QuickStartInteractiveRebase: "Start interactive rebase",
QuickStartInteractiveRebaseTooltip: "Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.\nIf you would instead like to start an interactive rebase from the selected commit, press `{{.editKey}}`.",
CannotQuickStartInteractiveRebase: "Cannot start interactive rebase: the HEAD commit is a merge commit or is present on the main branch, so there is no appropriate base commit to start the rebase from. You can start an interactive rebase from a specific commit by selecting the commit and pressing `{{.editKey}}`.",
Actions: Actions{
// TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
CheckoutCommit: "Checkout commit",

View File

@ -18,6 +18,24 @@ func (self *Common) ContinueRebase() {
self.ContinueMerge()
}
func (self *Common) AbortRebase() {
self.t.GlobalPress(self.t.keys.Universal.CreateRebaseOptionsMenu)
self.t.ExpectPopup().Menu().
Title(Equals("Rebase options")).
Select(Contains("abort")).
Confirm()
}
func (self *Common) AbortMerge() {
self.t.GlobalPress(self.t.keys.Universal.CreateRebaseOptionsMenu)
self.t.ExpectPopup().Menu().
Title(Equals("Merge options")).
Select(Contains("abort")).
Confirm()
}
func (self *Common) AcknowledgeConflicts() {
self.t.ExpectPopup().Menu().
Title(Equals("Conflicts!")).

View File

@ -0,0 +1,123 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var QuickStart = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Quick-starts an interactive rebase in several contexts",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
// we're going to test the following:
// * quick start from main fails
// * quick start from feature branch starts from main
// * quick start from branch with merge commit starts from merge commit
shell.NewBranch("main")
shell.EmptyCommit("initial commit")
shell.EmptyCommit("last main commit")
shell.NewBranch("feature-branch")
shell.NewBranch("branch-to-merge")
shell.NewBranch("branch-with-merge-commit")
shell.Checkout("feature-branch")
shell.EmptyCommit("feature-branch one")
shell.EmptyCommit("feature-branch two")
shell.Checkout("branch-to-merge")
shell.EmptyCommit("branch-to-merge one")
shell.EmptyCommit("branch-to-merge two")
shell.Checkout("branch-with-merge-commit")
shell.EmptyCommit("branch-with-merge one")
shell.EmptyCommit("branch-with-merge two")
shell.Merge("branch-to-merge")
shell.EmptyCommit("branch-with-merge three")
shell.Checkout("main")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("last main commit"),
Contains("initial commit"),
).
// Verify we can't quick start from main
Press(keys.Commits.StartInteractiveRebase).
Tap(func() {
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("Cannot start interactive rebase: the HEAD commit is a merge commit or is present on the main branch, so there is no appropriate base commit to start the rebase from. You can start an interactive rebase from a specific commit by selecting the commit and pressing `e`.")).
Confirm()
})
t.Views().Branches().
Focus().
NavigateToLine(Contains("feature-branch")).
Press(keys.Universal.Select)
t.Views().Commits().
Focus().
Lines(
Contains("feature-branch two").IsSelected(),
Contains("feature-branch one"),
Contains("last main commit"),
Contains("initial commit"),
).
// Verify quick start picks the last commit on the main branch
Press(keys.Commits.StartInteractiveRebase).
Lines(
Contains("feature-branch two").IsSelected(),
Contains("feature-branch one"),
Contains("last main commit").Contains("YOU ARE HERE"),
Contains("initial commit"),
).
// Try again, verify we fail because we're already rebasing
Press(keys.Commits.StartInteractiveRebase).
Tap(func() {
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(Contains("Can't perform this action during a rebase")).
Confirm()
t.Common().AbortRebase()
})
// Verify if a merge commit is present on the branch we start from there
t.Views().Branches().
Focus().
NavigateToLine(Contains("branch-with-merge-commit")).
Press(keys.Universal.Select)
t.Views().Commits().
Focus().
Lines(
Contains("branch-with-merge three").IsSelected(),
Contains("Merge branch 'branch-to-merge'"),
Contains("branch-to-merge two"),
Contains("branch-to-merge one"),
Contains("branch-with-merge two"),
Contains("branch-with-merge one"),
Contains("last main commit"),
Contains("initial commit"),
).
Press(keys.Commits.StartInteractiveRebase).
Lines(
Contains("branch-with-merge three").IsSelected(),
Contains("Merge branch 'branch-to-merge'").Contains("YOU ARE HERE"),
Contains("branch-to-merge two"),
Contains("branch-to-merge one"),
Contains("branch-with-merge two"),
Contains("branch-with-merge one"),
Contains("last main commit"),
Contains("initial commit"),
)
},
})

View File

@ -164,6 +164,7 @@ var tests = []*components.IntegrationTest{
interactive_rebase.MoveInRebase,
interactive_rebase.MoveWithCustomCommentChar,
interactive_rebase.PickRescheduled,
interactive_rebase.QuickStart,
interactive_rebase.Rebase,
interactive_rebase.RewordCommitWithEditorAndFail,
interactive_rebase.RewordFirstCommit,

View File

@ -1143,6 +1143,10 @@
"viewBisectOptions": {
"type": "string",
"default": "b"
},
"startInteractiveRebase": {
"type": "string",
"default": "i"
}
},
"additionalProperties": false,