Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
0c98f79
Add support for handling empty cherry-pick commits with user prompts
kosiew Sep 27, 2025
c162281
Implement handling for empty cherry-pick commits and add integration …
kosiew Sep 27, 2025
465a8ac
Add CherryPickEmptyCreateCommit to integration tests
kosiew Sep 27, 2025
632560e
Enhance cherry-pick handling: add support for empty commits and integ…
kosiew Sep 29, 2025
c7e5f33
Improve handling of empty cherry-pick scenarios by checking the worki…
kosiew Sep 29, 2025
5fb44a1
Enhance cherry-pick handling: support additional error messages for e…
kosiew Sep 29, 2025
6bcc5f8
Enhance cherry-pick tests: add SetupConfig for empty commit scenarios
kosiew Sep 29, 2025
5bcf7ae
Enhance cherry-pick functionality: implement post-paste cleanup and i…
kosiew Sep 29, 2025
d608aad
Enhance cherry-pick handling: add test for empty cherry-pick followed…
kosiew Sep 29, 2025
8eac0da
Enhance cherry-pick and revert handling: add support for empty commit…
kosiew Sep 29, 2025
1f23cae
Enhance rebase handling: add check for working tree state before cont…
kosiew Sep 29, 2025
8110f9a
Enhance merge and rebase handling: improve error checking for empty c…
kosiew Sep 29, 2025
150228c
Refactor CheckMergeOrRebaseWithRefreshOptions: streamline empty commi…
kosiew Sep 29, 2025
8f1310e
Enhance CheckMergeOrRebaseWithRefreshOptions: streamline conflict han…
kosiew Sep 29, 2025
9fa9ec4
Refactor CheckMergeOrRebaseWithRefreshOptions: reorder conflict check…
kosiew Sep 30, 2025
300e8cc
Enhance rebase test: add confirmation step for skipping rebase when c…
kosiew Sep 30, 2025
85e3c06
Enhance CheckMergeOrRebaseWithRefreshOptions: add prompt for conflict…
kosiew Sep 30, 2025
af8ab9d
Refactor CheckMergeOrRebaseWithRefreshOptions: reorder conflict handl…
kosiew Sep 30, 2025
344edc3
Enhance conflict handling: streamline prompt for merge conflicts and …
kosiew Sep 30, 2025
f242f4a
Fix CherryPickCommits: update argument for empty commits handling bas…
kosiew Sep 30, 2025
e6f7cdb
Enhance CherryPickCommits: update arguments for empty commits handlin…
kosiew Sep 30, 2025
5e07ae3
Refactor CherryPickCommits: remove unnecessary --allow-empty argument…
kosiew Sep 30, 2025
5dd53f9
Refactor CherryPickCommits: remove support for --keep-redundant-commi…
kosiew Sep 30, 2025
2b555cc
Enhance CherryPick integration tests: add handling for empty cherry-p…
kosiew Sep 30, 2025
43ff7b1
Enhance CherryPick integration tests: add handling for duplicate empt…
kosiew Sep 30, 2025
f72a030
Enhance CherryPick functionality: update selection handling after pas…
kosiew Sep 30, 2025
744f32e
Enhance CherryPick functionality: adjust selection handling after pas…
kosiew Sep 30, 2025
9824178
Enhance CherryPick functionality: improve post-paste selection handli…
kosiew Sep 30, 2025
cfe659b
Enhance CherryPick functionality: finalize cherry-pick cleanup proces…
kosiew Sep 30, 2025
b1abc26
Enhance CherryPick functionality: add method to check restoration of …
kosiew Sep 30, 2025
c013c06
Enhance MoveCommitsToNewBranch test: add handling for empty cherry-pi…
kosiew Sep 30, 2025
e655069
Refactor completeCherryPickAfterEmptyResolution: simplify post-paste …
kosiew Sep 30, 2025
ff98323
Enhance completeCherryPickAfterEmptyResolution: conditionally disable…
kosiew Sep 30, 2025
361bbb1
Enhance cherry-pick handling: conditionally disable post-paste resele…
kosiew Sep 30, 2025
53b092f
Enhance cherry-pick conflict handling: add confirmation popup for ski…
kosiew Sep 30, 2025
d97a54f
Enhance cherry-pick handling: update completeCherryPickAfterEmptyReso…
kosiew Sep 30, 2025
1dd9711
Enhance cherry-pick handling: update empty cherry-pick resolution to …
kosiew Sep 30, 2025
aa3d1ae
Enhance cherry-pick handling: add confirmation popups for empty cherr…
kosiew Sep 30, 2025
2ae7bb2
Enhance cherry-pick handling: add confirmation popup for empty cherry…
kosiew Sep 30, 2025
fa0d1d2
Enhance cherry-pick conflict resolution: update selection logic to co…
kosiew Sep 30, 2025
f587a59
feat: enhance cherry-pick post-paste behavior
kosiew Sep 30, 2025
2677cfd
feat: enhance cherry-pick functionality with pre-paste head hash trac…
kosiew Oct 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ This demo also uses shift+down to select a range of commits to move and fixup.

### Cherry-pick

Press `shift+c` on a commit to copy it and press `shift+v` to paste (cherry-pick) it.
Press `shift+c` on a commit to copy it and press `shift+v` to paste (cherry-pick) it. If a cherry-picked commit no longer
changes the working tree, lazygit now prompts you to either skip it or create an empty commit so you can decide how to proceed.

![cherry_pick](../assets/demo/cherry_pick-compressed.gif)

Expand Down
14 changes: 11 additions & 3 deletions pkg/commands/git_commands/rebase.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,8 @@ func (self *RebaseCommands) AbortRebase() error {
func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, command string) error {
err := self.runSkipEditorCommand(self.GenericMergeOrRebaseActionCmdObj(commandType, command))
if err != nil {
if !strings.Contains(err.Error(), "no rebase in progress") {
errStr := err.Error()
if !strings.Contains(errStr, "no rebase in progress") && !strings.Contains(errStr, "no cherry-pick or revert in progress") {
return err
}
self.Log.Warn(err)
Expand Down Expand Up @@ -512,6 +513,12 @@ func (self *RebaseCommands) runSkipEditorCommand(cmdObj *oscommands.CmdObj) erro
Run()
}

func (self *RebaseCommands) CommitAllowEmpty() error {
cmdArgs := NewGitCmd("commit").Arg("--allow-empty").ToArgv()

return self.runSkipEditorCommand(self.cmd.New(cmdArgs))
}

// DiscardOldFileChanges discards changes to a file from an old commit
func (self *RebaseCommands) DiscardOldFileChanges(commits []*models.Commit, commitIndex int, filePaths []string) error {
if err := self.BeginInteractiveRebaseForCommit(commits, commitIndex, false); err != nil {
Expand Down Expand Up @@ -547,9 +554,10 @@ func (self *RebaseCommands) DiscardOldFileChanges(commits []*models.Commit, comm
// CherryPickCommits begins an interactive rebase with the given hashes being cherry picked onto HEAD
func (self *RebaseCommands) CherryPickCommits(commits []*models.Commit) error {
hasMergeCommit := lo.SomeBy(commits, func(c *models.Commit) bool { return c.IsMerge() })
isAtLeast245 := self.version.IsAtLeast(2, 45, 0)

cmdArgs := NewGitCmd("cherry-pick").
Arg("--allow-empty").
ArgIf(self.version.IsAtLeast(2, 45, 0), "--empty=keep", "--keep-redundant-commits").
ArgIf(isAtLeast245, "--empty=stop").
ArgIf(hasMergeCommit, "-m1").
Arg(lo.Reverse(lo.Map(commits, func(c *models.Commit, _ int) string { return c.Hash() }))...).
ToArgv()
Expand Down
27 changes: 27 additions & 0 deletions pkg/commands/git_commands/status.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package git_commands

import (
"bufio"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -82,6 +83,32 @@ func (self *StatusCommands) IsInRevert() (bool, error) {
return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "REVERT_HEAD"))
}

func (self *StatusCommands) HasPendingSequencerTodos() (bool, error) {
bytesContent, err := os.ReadFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "sequencer", "todo"))
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}

scanner := bufio.NewScanner(strings.NewReader(string(bytesContent)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}

return true, nil
}

if err := scanner.Err(); err != nil {
return false, err
}

return false, nil
}

// Full ref (e.g. "refs/heads/mybranch") of the branch that is currently
// being rebased, or empty string when we're not in a rebase
func (self *StatusCommands) BranchBeingRebased() string {
Expand Down
232 changes: 211 additions & 21 deletions pkg/gui/controllers/helpers/cherry_pick_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package helpers

import (
"strconv"
"strings"

"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking"
"github.com/jesseduffield/lazygit/pkg/gui/types"
Expand All @@ -14,6 +16,22 @@ type CherryPickHelper struct {
c *HelperCommon

rebaseHelper *MergeAndRebaseHelper

postPasteCleanup func(markDidPaste bool) error
postPasteShouldMarkDidPaste bool

postPasteSelection *postPasteSelection

prePasteHeadHash string
pasteProducedCommits bool

deferPostPasteCleanup bool
}

type postPasteSelection struct {
hash string
idx int
shouldReselect bool
}

// I'm using the analogy of copy+paste in the terminology here because it's intuitively what's going on,
Expand All @@ -23,10 +41,14 @@ func NewCherryPickHelper(
c *HelperCommon,
rebaseHelper *MergeAndRebaseHelper,
) *CherryPickHelper {
return &CherryPickHelper{
helper := &CherryPickHelper{
c: c,
rebaseHelper: rebaseHelper,
}

rebaseHelper.SetCherryPickHelper(helper)

return helper
}

func (self *CherryPickHelper) getData() *cherrypicking.CherryPicking {
Expand Down Expand Up @@ -88,21 +110,36 @@ func (self *CherryPickHelper) Paste() error {
}

cherryPickedCommits := self.getData().CherryPickedCommits
self.preparePostPasteSelection(cherryPickedCommits)
self.capturePrePasteHeadHash()

self.setPostPasteCleanup(func(markDidPaste bool) error {
if markDidPaste {
self.getData().DidPaste = true
}
self.rerender()

if mustStash {
if err := self.c.Git().Stash.Pop(0); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.STASH, types.FILES},
})
}

self.restorePostPasteSelection()

return nil
})

result := self.c.Git().Rebase.CherryPickCommits(cherryPickedCommits)
err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.SYNC})
if err != nil {
return result
return err
}

// Move the selection down by the number of commits we just
// cherry-picked, to keep the same commit selected as before.
// Don't do this if a rebase todo is selected, because in this
// case we are in a rebase and the cherry-picked commits end up
// below the selection.
if commit := self.c.Contexts().LocalCommits.GetSelected(); commit != nil && !commit.IsTODO() {
self.c.Contexts().LocalCommits.MoveSelection(len(cherryPickedCommits))
self.c.Contexts().LocalCommits.FocusLine()
}
self.markPasteProducedCommitsIfHeadChanged()

// If we're in the cherry-picking state at this point, it must
// be because there were conflicts. Don't clear the copied
Expand All @@ -113,16 +150,8 @@ func (self *CherryPickHelper) Paste() error {
return result
}
if !isInCherryPick {
self.getData().DidPaste = true
self.rerender()

if mustStash {
if err := self.c.Git().Stash.Pop(0); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.STASH, types.FILES},
})
if err := self.runPostPasteCleanup(true); err != nil {
return err
}
}

Expand All @@ -141,6 +170,12 @@ func (self *CherryPickHelper) CanPaste() bool {
func (self *CherryPickHelper) Reset() error {
self.getData().ContextKey = ""
self.getData().CherryPickedCommits = nil
self.postPasteCleanup = nil
self.postPasteShouldMarkDidPaste = false
self.postPasteSelection = nil
self.prePasteHeadHash = ""
self.pasteProducedCommits = false
self.deferPostPasteCleanup = false

self.rerender()
return nil
Expand Down Expand Up @@ -168,3 +203,158 @@ func (self *CherryPickHelper) rerender() {
self.c.PostRefreshUpdate(context)
}
}

func (self *CherryPickHelper) preparePostPasteSelection(commits []*models.Commit) {
selectedCommit := self.c.Contexts().LocalCommits.GetSelected()
selectedIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()

self.postPasteSelection = nil

if selectedCommit == nil {
return
}

self.postPasteSelection = &postPasteSelection{
hash: selectedCommit.Hash(),
idx: selectedIdx,
shouldReselect: !selectedCommit.IsTODO() && len(commits) > 0,
}
}

func (self *CherryPickHelper) restorePostPasteSelection() {
if self.postPasteSelection == nil || !self.postPasteSelection.shouldReselect {
return
}

localCommits := self.c.Contexts().LocalCommits

if self.postPasteSelection.hash != "" && localCommits.SelectCommitByHash(self.postPasteSelection.hash) {
localCommits.FocusLine()
return
}

if self.postPasteSelection.idx >= 0 {
localCommits.SetSelectedLineIdx(self.postPasteSelection.idx)
localCommits.FocusLine()
}
}

func (self *CherryPickHelper) DisablePostPasteReselect() {
if self.postPasteSelection == nil {
return
}

self.postPasteSelection.shouldReselect = false
}

func (self *CherryPickHelper) ShouldRestorePostPasteSelection() bool {
return self.postPasteSelection != nil && self.postPasteSelection.shouldReselect
}

func (self *CherryPickHelper) setPostPasteCleanup(cleanup func(markDidPaste bool) error) {
self.postPasteCleanup = cleanup
self.postPasteShouldMarkDidPaste = true
}

func (self *CherryPickHelper) runPostPasteCleanup(markDidPaste bool) error {
if self.postPasteCleanup == nil {
return nil
}

cleanup := self.postPasteCleanup
self.postPasteCleanup = nil
defer func() {
self.postPasteShouldMarkDidPaste = false
self.postPasteSelection = nil
self.prePasteHeadHash = ""
self.pasteProducedCommits = false
self.deferPostPasteCleanup = false
}()

return cleanup(markDidPaste && self.postPasteShouldMarkDidPaste)
}

func (self *CherryPickHelper) setPostPasteShouldMarkDidPaste(mark bool) {
if self.postPasteCleanup == nil {
return
}

self.postPasteShouldMarkDidPaste = mark
}

func (self *CherryPickHelper) capturePrePasteHeadHash() {
headHash, err := self.getHeadHash()
if err != nil {
self.prePasteHeadHash = ""
return
}

self.prePasteHeadHash = headHash
self.pasteProducedCommits = false
}

func (self *CherryPickHelper) PasteProducedCommits() bool {
if self.pasteProducedCommits {
return true
}

if self.prePasteHeadHash == "" {
return true
}

headHash, err := self.getHeadHash()
if err != nil {
return true
}

if headHash != self.prePasteHeadHash {
self.pasteProducedCommits = true
return true
}

return false
}

func (self *CherryPickHelper) DeferPostPasteCleanup() {
self.deferPostPasteCleanup = true
}

func (self *CherryPickHelper) ClearDeferredPostPasteCleanup() {
self.deferPostPasteCleanup = false
}

func (self *CherryPickHelper) ShouldDeferPostPasteCleanup() bool {
return self.deferPostPasteCleanup
}

func (self *CherryPickHelper) markPasteProducedCommitsIfHeadChanged() {
if self.pasteProducedCommits {
return
}

if self.prePasteHeadHash == "" {
self.pasteProducedCommits = true
return
}

headHash, err := self.getHeadHash()
if err != nil {
self.pasteProducedCommits = true
return
}

if headHash != self.prePasteHeadHash {
self.pasteProducedCommits = true
}
}

func (self *CherryPickHelper) getHeadHash() (string, error) {
output, err := self.c.OS().Cmd.New(
git_commands.NewGitCmd("rev-parse").Arg("HEAD").ToArgv(),
).DontLog().RunWithOutput()
if err != nil {
return "", err
}

return strings.TrimSpace(output), nil
}
8 changes: 6 additions & 2 deletions pkg/gui/controllers/helpers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,20 @@ type Helpers struct {
}

func NewStubHelpers() *Helpers {
rebaseHelper := &MergeAndRebaseHelper{}
cherryPickHelper := &CherryPickHelper{rebaseHelper: rebaseHelper}
rebaseHelper.SetCherryPickHelper(cherryPickHelper)

return &Helpers{
Refs: &RefsHelper{},
Bisect: &BisectHelper{},
Suggestions: &SuggestionsHelper{},
Files: &FilesHelper{},
WorkingTree: &WorkingTreeHelper{},
Tags: &TagsHelper{},
MergeAndRebase: &MergeAndRebaseHelper{},
MergeAndRebase: rebaseHelper,
MergeConflicts: &MergeConflictsHelper{},
CherryPick: &CherryPickHelper{},
CherryPick: cherryPickHelper,
Host: &HostHelper{},
PatchBuilding: &PatchBuildingHelper{},
Staging: &StagingHelper{},
Expand Down
Loading