diff --git a/README.md b/README.md index 09385d3fb04..19301fd1497 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index d882d9b1a9a..7f2487fa3be 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -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) @@ -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 { @@ -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() diff --git a/pkg/commands/git_commands/status.go b/pkg/commands/git_commands/status.go index ff09e22bc31..1bad8224132 100644 --- a/pkg/commands/git_commands/status.go +++ b/pkg/commands/git_commands/status.go @@ -1,6 +1,7 @@ package git_commands import ( + "bufio" "os" "path/filepath" "strings" @@ -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 { diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index 8f5b37a6693..7cfa073fe05 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -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" @@ -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, @@ -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 { @@ -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 @@ -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 } } @@ -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 @@ -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 +} diff --git a/pkg/gui/controllers/helpers/helpers.go b/pkg/gui/controllers/helpers/helpers.go index 4c9c79f3d81..f6b18754b5c 100644 --- a/pkg/gui/controllers/helpers/helpers.go +++ b/pkg/gui/controllers/helpers/helpers.go @@ -56,6 +56,10 @@ type Helpers struct { } func NewStubHelpers() *Helpers { + rebaseHelper := &MergeAndRebaseHelper{} + cherryPickHelper := &CherryPickHelper{rebaseHelper: rebaseHelper} + rebaseHelper.SetCherryPickHelper(cherryPickHelper) + return &Helpers{ Refs: &RefsHelper{}, Bisect: &BisectHelper{}, @@ -63,9 +67,9 @@ func NewStubHelpers() *Helpers { Files: &FilesHelper{}, WorkingTree: &WorkingTreeHelper{}, Tags: &TagsHelper{}, - MergeAndRebase: &MergeAndRebaseHelper{}, + MergeAndRebase: rebaseHelper, MergeConflicts: &MergeConflictsHelper{}, - CherryPick: &CherryPickHelper{}, + CherryPick: cherryPickHelper, Host: &HostHelper{}, PatchBuilding: &PatchBuildingHelper{}, Staging: &StagingHelper{}, diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index fbe59caa668..36bdcec11ef 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -18,6 +18,8 @@ import ( type MergeAndRebaseHelper struct { c *HelperCommon + + cherryPickHelper *CherryPickHelper } func NewMergeAndRebaseHelper( @@ -28,6 +30,10 @@ func NewMergeAndRebaseHelper( } } +func (self *MergeAndRebaseHelper) SetCherryPickHelper(helper *CherryPickHelper) { + self.cherryPickHelper = helper +} + type RebaseOption string const ( @@ -114,6 +120,15 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { if err := self.CheckMergeOrRebase(result); err != nil { return err } + + if self.cherryPickHelper != nil { + self.cherryPickHelper.markPasteProducedCommitsIfHeadChanged() + } + + if err := self.finalizeCherryPickIfDone(); err != nil { + return err + } + return nil } @@ -154,17 +169,210 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result er if result == nil { return nil - } else if strings.Contains(result.Error(), "No changes - did you forget to use") { + } + + errStr := result.Error() + + if isMergeConflictErr(errStr) { + return self.PromptForConflictHandling() + } + + isEmptyCommitErr := lo.SomeBy([]string{ + "The previous cherry-pick is now empty", + "git commit --allow-empty", + "git revert --skip", + }, func(str string) bool { + return strings.Contains(errStr, str) + }) + + if isEmptyCommitErr { + effectiveState := self.c.Git().Status.WorkingTreeState().Effective() + switch effectiveState { + case models.WORKING_TREE_STATE_CHERRY_PICKING: + return self.handleEmptyCherryPick() + case models.WORKING_TREE_STATE_REBASING, models.WORKING_TREE_STATE_REVERTING: + return self.handleEmptyRebaseOrRevert() + default: + return self.genericMergeCommand(REBASE_OPTION_SKIP) + } + } + + if strings.Contains(errStr, "No changes - did you forget to use") { return self.genericMergeCommand(REBASE_OPTION_SKIP) - } else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") { - return self.genericMergeCommand(REBASE_OPTION_CONTINUE) - } else if strings.Contains(result.Error(), "No rebase in progress?") { + } + + if strings.Contains(errStr, "No rebase in progress?") { // assume in this case that we're already done return nil } return self.CheckForConflicts(result) } +func (self *MergeAndRebaseHelper) handleEmptyCherryPick() error { + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.CherryPickEmptyTitle, + Prompt: self.c.Tr.CherryPickEmptyPrompt, + Items: []*types.MenuItem{ + { + Label: self.c.Tr.CherryPickEmptySkip, + Key: 's', + OnPress: func() error { + if self.cherryPickHelper != nil { + self.cherryPickHelper.DisablePostPasteReselect() + self.cherryPickHelper.DeferPostPasteCleanup() + } + if err := self.genericMergeCommand(REBASE_OPTION_SKIP); err != nil { + return err + } + + // Skipping an empty cherry-pick shouldn't re-enable post-paste reselection + // because no commit was applied. + return self.completeCherryPickAfterEmptyResolution(false) + }, + }, + { + Label: self.c.Tr.CherryPickEmptyCreateEmptyCommit, + Key: 'e', + OnPress: func() error { + if err := self.c.Git().Rebase.CommitAllowEmpty(); err != nil { + if strings.Contains(err.Error(), "no cherry-pick or revert in progress") { + self.c.Log.Warn(err) + } else { + return err + } + } + if err := self.genericMergeCommand(REBASE_OPTION_CONTINUE); err != nil { + if err.Error() != self.c.Tr.NotMergingOrRebasing { + return err + } + + if err := self.CheckMergeOrRebase( + self.c.Git().Rebase.GenericMergeOrRebaseAction("cherry-pick", REBASE_OPTION_CONTINUE), + ); err != nil { + return err + } + } + + return self.completeCherryPickAfterEmptyResolution(true) + }, + }, + }, + }) +} + +func (self *MergeAndRebaseHelper) completeCherryPickAfterEmptyResolution(preservePostPasteReselect bool) error { + isInCherryPick, err := self.c.Git().Status.IsInCherryPick() + if err != nil { + return err + } + + if self.cherryPickHelper == nil { + return nil + } + + defer self.cherryPickHelper.ClearDeferredPostPasteCleanup() + + if !preservePostPasteReselect { + self.cherryPickHelper.DisablePostPasteReselect() + } + + if isInCherryPick { + return nil + } + + self.cherryPickHelper.markPasteProducedCommitsIfHeadChanged() + + if !self.cherryPickHelper.PasteProducedCommits() { + self.cherryPickHelper.setPostPasteShouldMarkDidPaste(false) + } + + if err := self.cherryPickHelper.runPostPasteCleanup(true); err != nil { + return err + } + + return nil +} + +func (self *MergeAndRebaseHelper) finalizeCherryPickIfDone() error { + if self.cherryPickHelper == nil { + return nil + } + + if self.cherryPickHelper.ShouldDeferPostPasteCleanup() { + return nil + } + + hasTodos, err := self.c.Git().Status.HasPendingSequencerTodos() + if err != nil { + return err + } + + if hasTodos { + return nil + } + + isInCherryPick, err := self.c.Git().Status.IsInCherryPick() + if err != nil { + return err + } + + if isInCherryPick { + return nil + } + + self.cherryPickHelper.markPasteProducedCommitsIfHeadChanged() + + if !self.cherryPickHelper.ShouldRestorePostPasteSelection() { + self.cherryPickHelper.DisablePostPasteReselect() + } + + if err := self.cherryPickHelper.runPostPasteCleanup(true); err != nil { + return err + } + + return nil +} + +func (self *MergeAndRebaseHelper) handleEmptyRebaseOrRevert() error { + commandName := self.c.Git().Status.WorkingTreeState().CommandName() + + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.EmptyCommitTitle, + Prompt: self.c.Tr.EmptyCommitPrompt, + Items: []*types.MenuItem{ + { + Label: fmt.Sprintf(self.c.Tr.EmptyCommitSkip, commandName), + Key: 's', + OnPress: func() error { + return self.genericMergeCommand(REBASE_OPTION_SKIP) + }, + }, + { + Label: self.c.Tr.EmptyCommitCreateEmptyCommit, + Key: 'e', + OnPress: func() error { + if err := self.c.Git().Rebase.CommitAllowEmpty(); err != nil { + errStr := err.Error() + if strings.Contains(errStr, "no rebase in progress") || strings.Contains(errStr, "no cherry-pick or revert in progress") { + self.c.Log.Warn(err) + } else { + return err + } + } + + workingTreeState := self.c.Git().Status.WorkingTreeState().Effective() + if workingTreeState == models.WORKING_TREE_STATE_REBASING { + return self.genericMergeCommand(REBASE_OPTION_CONTINUE) + } + + self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + return nil + }, + }, + }, + }) +} + func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error { return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC}) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d162d20dc20..4da5a1ed609 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -369,6 +369,14 @@ type TranslationSet struct { PasteCommits string SureCherryPick string CherryPick string + CherryPickEmptyTitle string + CherryPickEmptyPrompt string + CherryPickEmptySkip string + CherryPickEmptyCreateEmptyCommit string + EmptyCommitTitle string + EmptyCommitPrompt string + EmptyCommitSkip string + EmptyCommitCreateEmptyCommit string CannotCherryPickNonCommit string Donate string AskQuestion string @@ -1452,6 +1460,14 @@ func EnglishTranslationSet() *TranslationSet { PasteCommits: "Paste (cherry-pick)", SureCherryPick: "Are you sure you want to cherry-pick the {{.numCommits}} copied commit(s) onto this branch?", CherryPick: "Cherry-pick", + CherryPickEmptyTitle: "Cherry-pick produced no changes", + CherryPickEmptyPrompt: "The commit can be skipped or kept as an empty commit.", + CherryPickEmptySkip: "Skip this cherry-pick", + CherryPickEmptyCreateEmptyCommit: "Create empty commit and continue", + EmptyCommitTitle: "Commit produced no changes", + EmptyCommitPrompt: "The commit can be skipped or kept as an empty commit.", + EmptyCommitSkip: "Skip this %s step", + EmptyCommitCreateEmptyCommit: "Create empty commit and continue", CannotCherryPickNonCommit: "Cannot cherry-pick this kind of todo item", Donate: "Donate", AskQuestion: "Ask Question", diff --git a/pkg/i18n/translations/ja.json b/pkg/i18n/translations/ja.json index 4f2d014d094..4468b0e852a 100644 --- a/pkg/i18n/translations/ja.json +++ b/pkg/i18n/translations/ja.json @@ -340,6 +340,14 @@ "PasteCommits": "ペースト(チェリーピック)", "SureCherryPick": "コピーした {{.numCommits}} 個のコミットをこのブランチにチェリーピックしてよろしいですか?", "CherryPick": "チェリーピック", + "CherryPickEmptyTitle": "Cherry-pick produced no changes", + "CherryPickEmptyPrompt": "The commit can be skipped or kept as an empty commit.", + "CherryPickEmptySkip": "Skip this cherry-pick", + "CherryPickEmptyCreateEmptyCommit": "Create empty commit and continue", + "EmptyCommitTitle": "Commit produced no changes", + "EmptyCommitPrompt": "The commit can be skipped or kept as an empty commit.", + "EmptyCommitSkip": "Skip this %s step", + "EmptyCommitCreateEmptyCommit": "Create empty commit and continue", "CannotCherryPickNonCommit": "この種類の TODO 項目はチェリーピックできません", "Donate": "寄付", "AskQuestion": "質問する", diff --git a/pkg/i18n/translations/ko.json b/pkg/i18n/translations/ko.json index 0027658937a..9c54295a4da 100644 --- a/pkg/i18n/translations/ko.json +++ b/pkg/i18n/translations/ko.json @@ -152,6 +152,14 @@ "CherryPickCopy": "커밋을 복사 (cherry-pick)", "PasteCommits": "커밋을 붙여넣기 (cherry-pick)", "CherryPick": "체리픽", + "CherryPickEmptyTitle": "Cherry-pick produced no changes", + "CherryPickEmptyPrompt": "The commit can be skipped or kept as an empty commit.", + "CherryPickEmptySkip": "Skip this cherry-pick", + "CherryPickEmptyCreateEmptyCommit": "Create empty commit and continue", + "EmptyCommitTitle": "Commit produced no changes", + "EmptyCommitPrompt": "The commit can be skipped or kept as an empty commit.", + "EmptyCommitSkip": "Skip this %s step", + "EmptyCommitCreateEmptyCommit": "Create empty commit and continue", "Donate": "후원", "AskQuestion": "질문하기", "PrevHunk": "이전 hunk를 선택", diff --git a/pkg/i18n/translations/nl.json b/pkg/i18n/translations/nl.json index 24b63f540a3..8eb2c771346 100644 --- a/pkg/i18n/translations/nl.json +++ b/pkg/i18n/translations/nl.json @@ -120,6 +120,14 @@ "CherryPickCopy": "Kopieer commit (cherry-pick)", "PasteCommits": "Plak commits (cherry-pick)", "CherryPick": "Cherry-Pick", + "CherryPickEmptyTitle": "Cherry-pick produced no changes", + "CherryPickEmptyPrompt": "The commit can be skipped or kept as an empty commit.", + "CherryPickEmptySkip": "Skip this cherry-pick", + "CherryPickEmptyCreateEmptyCommit": "Create empty commit and continue", + "EmptyCommitTitle": "Commit produced no changes", + "EmptyCommitPrompt": "The commit can be skipped or kept as an empty commit.", + "EmptyCommitSkip": "Skip this %s step", + "EmptyCommitCreateEmptyCommit": "Create empty commit and continue", "Donate": "Doneer", "PrevHunk": "Selecteer de vorige hunk", "NextHunk": "Selecteer de volgende hunk", diff --git a/pkg/i18n/translations/pl.json b/pkg/i18n/translations/pl.json index 976eb5a7678..751e912daf0 100644 --- a/pkg/i18n/translations/pl.json +++ b/pkg/i18n/translations/pl.json @@ -256,6 +256,14 @@ "CherryPickCopy": "Kopiuj (cherry-pick)", "CherryPickCopyTooltip": "Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `{{.paste}}`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć `{{.escape}}`, aby anulować zaznaczenie.", "PasteCommits": "Wklej (cherry-pick)", + "CherryPickEmptyTitle": "Cherry-pick produced no changes", + "CherryPickEmptyPrompt": "The commit can be skipped or kept as an empty commit.", + "CherryPickEmptySkip": "Skip this cherry-pick", + "CherryPickEmptyCreateEmptyCommit": "Create empty commit and continue", + "EmptyCommitTitle": "Commit produced no changes", + "EmptyCommitPrompt": "The commit can be skipped or kept as an empty commit.", + "EmptyCommitSkip": "Skip this %s step", + "EmptyCommitCreateEmptyCommit": "Create empty commit and continue", "CannotCherryPickNonCommit": "Nie można cherry-pick tego rodzaju wpisu TODO", "Donate": "Wesprzyj", "AskQuestion": "Zadaj pytanie", diff --git a/pkg/i18n/translations/pt.json b/pkg/i18n/translations/pt.json index 413299d3b18..c7c40b36785 100644 --- a/pkg/i18n/translations/pt.json +++ b/pkg/i18n/translations/pt.json @@ -322,6 +322,14 @@ "PasteCommits": "Colar (cherry-pick)", "SureCherryPick": "Tem certeza que deseja cherry-pick o(s) commit(s) {{.numCommits}} copiado(s) para este branch?", "CherryPick": "cherry-pick", + "CherryPickEmptyTitle": "Cherry-pick produced no changes", + "CherryPickEmptyPrompt": "The commit can be skipped or kept as an empty commit.", + "CherryPickEmptySkip": "Skip this cherry-pick", + "CherryPickEmptyCreateEmptyCommit": "Create empty commit and continue", + "EmptyCommitTitle": "Commit produced no changes", + "EmptyCommitPrompt": "The commit can be skipped or kept as an empty commit.", + "EmptyCommitSkip": "Skip this %s step", + "EmptyCommitCreateEmptyCommit": "Create empty commit and continue", "CannotCherryPickNonCommit": "Não é possível escolher este tipo de item de tarefa", "Donate": "Doar", "AskQuestion": "Faça perguntas", diff --git a/pkg/i18n/translations/ru.json b/pkg/i18n/translations/ru.json index ef93bf49816..a42048908d4 100644 --- a/pkg/i18n/translations/ru.json +++ b/pkg/i18n/translations/ru.json @@ -185,6 +185,14 @@ "CherryPickCopy": "Скопировать отобранные коммит (cherry-pick)", "PasteCommits": "Вставить отобранные коммиты (cherry-pick)", "CherryPick": "Выборочная отборка (Cherry-Pick)", + "CherryPickEmptyTitle": "Cherry-pick produced no changes", + "CherryPickEmptyPrompt": "The commit can be skipped or kept as an empty commit.", + "CherryPickEmptySkip": "Skip this cherry-pick", + "CherryPickEmptyCreateEmptyCommit": "Create empty commit and continue", + "EmptyCommitTitle": "Commit produced no changes", + "EmptyCommitPrompt": "The commit can be skipped or kept as an empty commit.", + "EmptyCommitSkip": "Skip this %s step", + "EmptyCommitCreateEmptyCommit": "Create empty commit and continue", "Donate": "Пожертвовать", "AskQuestion": "Задать вопрос", "PrevHunk": "Выбрать предыдущую часть", diff --git a/pkg/i18n/translations/zh-CN.json b/pkg/i18n/translations/zh-CN.json index ef566c61068..74352c61446 100644 --- a/pkg/i18n/translations/zh-CN.json +++ b/pkg/i18n/translations/zh-CN.json @@ -324,6 +324,14 @@ "PasteCommits": "粘贴提交(拣选)", "SureCherryPick": "确定要将复制的{{.numCommits}}个提交拣选到该分支上吗?", "CherryPick": "拣选(Cherry-Pick)", + "CherryPickEmptyTitle": "Cherry-pick produced no changes", + "CherryPickEmptyPrompt": "The commit can be skipped or kept as an empty commit.", + "CherryPickEmptySkip": "Skip this cherry-pick", + "CherryPickEmptyCreateEmptyCommit": "Create empty commit and continue", + "EmptyCommitTitle": "Commit produced no changes", + "EmptyCommitPrompt": "The commit can be skipped or kept as an empty commit.", + "EmptyCommitSkip": "Skip this %s step", + "EmptyCommitCreateEmptyCommit": "Create empty commit and continue", "CannotCherryPickNonCommit": "无法拣选TODO类型的提交", "Donate": "捐助", "AskQuestion": "提问咨询", diff --git a/pkg/i18n/translations/zh-TW.json b/pkg/i18n/translations/zh-TW.json index e01778eda6f..3e56e962738 100644 --- a/pkg/i18n/translations/zh-TW.json +++ b/pkg/i18n/translations/zh-TW.json @@ -223,6 +223,14 @@ "CherryPickCopy": "複製提交 (揀選)", "PasteCommits": "貼上提交 (揀選)", "CherryPick": "揀選 (Cherry-pick)", + "CherryPickEmptyTitle": "Cherry-pick produced no changes", + "CherryPickEmptyPrompt": "The commit can be skipped or kept as an empty commit.", + "CherryPickEmptySkip": "Skip this cherry-pick", + "CherryPickEmptyCreateEmptyCommit": "Create empty commit and continue", + "EmptyCommitTitle": "Commit produced no changes", + "EmptyCommitPrompt": "The commit can be skipped or kept as an empty commit.", + "EmptyCommitSkip": "Skip this %s step", + "EmptyCommitCreateEmptyCommit": "Create empty commit and continue", "Donate": "贊助", "AskQuestion": "諮詢", "PrevHunk": "選擇上一段", diff --git a/pkg/integration/components/common.go b/pkg/integration/components/common.go index 2d62e9ea3c9..d1dbab2e65c 100644 --- a/pkg/integration/components/common.go +++ b/pkg/integration/components/common.go @@ -11,7 +11,7 @@ func (self *Common) ContinueMerge() { self.t.GlobalPress(self.t.keys.Universal.CreateRebaseOptionsMenu) self.t.ExpectPopup().Menu(). - Title(Equals("Rebase options")). + Title(EqualsOneOf("Rebase options", "Revert options")). Select(Contains("continue")). Confirm() } diff --git a/pkg/integration/components/text_matcher.go b/pkg/integration/components/text_matcher.go index 3970dc77879..be9881152d1 100644 --- a/pkg/integration/components/text_matcher.go +++ b/pkg/integration/components/text_matcher.go @@ -79,6 +79,17 @@ func (self *TextMatcher) Equals(target string) *TextMatcher { return self } +func (self *TextMatcher) EqualsOneOf(targets ...string) *TextMatcher { + self.appendRule(matcherRule[string]{ + name: fmt.Sprintf("equals one of '%s'", strings.Join(targets, "', '")), + testFn: func(value string) (bool, string) { + return lo.Contains(targets, value), fmt.Sprintf("Expected '%s' to equal one of '%s'", value, strings.Join(targets, "', '")) + }, + }) + + return self +} + const IS_SELECTED_RULE_NAME = "is selected" // special rule that is only to be used in the TopLines and Lines methods, as a way of @@ -132,3 +143,7 @@ func MatchesRegexp(target string) *TextMatcher { func Equals(target string) *TextMatcher { return AnyString().Equals(target) } + +func EqualsOneOf(targets ...string) *TextMatcher { + return AnyString().EqualsOneOf(targets...) +} 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 index 0b6bd71aa64..7ec104fe139 100644 --- 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 @@ -45,6 +45,11 @@ var MoveCommitsToNewBranchFromBaseBranch = NewIntegrationTest(NewIntegrationTest Type("new branch"). Confirm() + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + Select(Contains("Create empty commit and continue")). + Confirm() + t.Views().Branches(). Lines( Contains("new-branch").DoesNotContain("↑").IsSelected(), diff --git a/pkg/integration/tests/cherry_pick/cherry_pick.go b/pkg/integration/tests/cherry_pick/cherry_pick.go index a278a5639bd..f00de7aaf4d 100644 --- a/pkg/integration/tests/cherry_pick/cherry_pick.go +++ b/pkg/integration/tests/cherry_pick/cherry_pick.go @@ -60,6 +60,7 @@ var CherryPick = NewIntegrationTest(NewIntegrationTestArgs{ Contains("one"), Contains("base"), ). + SelectNextItem(). Press(keys.Commits.PasteCommits). Tap(func() { // cherry-picked commits will be deleted after confirmation @@ -71,14 +72,36 @@ var CherryPick = NewIntegrationTest(NewIntegrationTestArgs{ Content(Contains("Are you sure you want to cherry-pick the 2 copied commit(s) onto this branch?")). Confirm() }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() + }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() + }). Tap(func() { t.Views().Information().Content(DoesNotContain("commits copied")) }). Lines( Contains("four"), Contains("three"), - Contains("two").IsSelected(), - Contains("one"), + Contains("two"), + Contains("one").IsSelected(), Contains("base"), ) @@ -100,6 +123,28 @@ var CherryPick = NewIntegrationTest(NewIntegrationTestArgs{ Content(Contains("Are you sure you want to cherry-pick the 2 copied commit(s) onto this branch?")). Confirm() }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() + }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() + }). Tap(func() { t.Views().Information().Content(DoesNotContain("commits copied")) }). diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go b/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go index b135bfd7ffa..a02df5b9f2d 100644 --- a/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go +++ b/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go @@ -73,16 +73,25 @@ var CherryPickConflicts = NewIntegrationTest(NewIntegrationTestArgs{ t.Common().ContinueOnConflictsResolved("cherry-pick") + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Skip this cherry-pick")). + Confirm() + t.Views().Files().IsEmpty() t.Views().Commits(). Focus(). TopLines( - Contains("second-change-branch unrelated change").IsSelected(), - Contains("second change"), + Contains("second change").IsSelected(), Contains("first change"), ). - SelectNextItem(). + Content(DoesNotContain("second-change-branch unrelated change")). Tap(func() { // because we picked 'Second change' when resolving the conflict, // we now see this commit as having replaced First Change with Second Change, @@ -91,7 +100,7 @@ var CherryPickConflicts = NewIntegrationTest(NewIntegrationTestArgs{ Content(Contains("-First Change")). Content(Contains("+Second Change")) - t.Views().Information().Content(Contains("2 commits copied")) + t.Views().Information().Content(DoesNotContain("commits copied")) }). PressEscape(). Tap(func() { diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go b/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go index a14dbe7c9b5..0e708f50fde 100644 --- a/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go +++ b/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go @@ -74,19 +74,20 @@ var CherryPickDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Tap(func() { - t.Views().Information().Content(DoesNotContain("commit copied")) + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() }). - Lines( - Contains("--- Pending rebase todos ---"), - Contains("pick CI two"), - Contains("--- Commits ---"), - Contains(" CI three"), - Contains(" CI one").IsSelected(), - Contains(" CI base"), - ). Tap(func() { - t.Common().ContinueRebase() + t.Views().Information().Content(DoesNotContain("commit copied")) }). + Focus(). Lines( Contains("CI two"), Contains("CI three"), diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_empty.go b/pkg/integration/tests/cherry_pick/cherry_pick_empty.go new file mode 100644 index 00000000000..0ae689c93a8 --- /dev/null +++ b/pkg/integration/tests/cherry_pick/cherry_pick_empty.go @@ -0,0 +1,74 @@ +package cherry_pick + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CherryPickEmpty = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Cherry-picking a commit with no diff offers skip/create options", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + EmptyCommit("base"). + NewBranch("source"). + CreateFileAndAdd("shared.txt", "content\n").Commit("add shared file on source"). + Checkout("master"). + CreateFileAndAdd("shared.txt", "content\n").Commit("add shared file on master") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master"), + Contains("source"), + ). + SelectNextItem(). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + Lines( + Contains("add shared file on source").IsSelected(), + Contains("base"), + ). + Press(keys.Commits.CherryPickCopy) + + t.Views().Information().Content(Contains("1 commit copied")) + + t.Views().Commits(). + Focus(). + Lines( + Contains("add shared file on master").IsSelected(), + Contains("base"), + ). + Press(keys.Commits.PasteCommits). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Cherry-pick")). + Content(Contains("Are you sure you want to cherry-pick the 1 copied commit(s) onto this branch?")). + Confirm() + }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Skip this cherry-pick")). + Confirm() + }). + Tap(func() { + t.Shell().RunCommandExpectError([]string{"git", "rev-parse", "CHERRY_PICK_HEAD"}) + }). + IsFocused(). + Lines( + Contains("add shared file on master").IsSelected(), + Contains("base"), + ) + }, +}) diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_empty_autostash.go b/pkg/integration/tests/cherry_pick/cherry_pick_empty_autostash.go new file mode 100644 index 00000000000..2f33c09dba9 --- /dev/null +++ b/pkg/integration/tests/cherry_pick/cherry_pick_empty_autostash.go @@ -0,0 +1,87 @@ +package cherry_pick + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CherryPickEmptyAutoStash = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Cherry-picking an empty commit after auto-stashing restores stash list and copy mode", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + EmptyCommit("base"). + NewBranch("source"). + CreateFileAndAdd("shared.txt", "content\n").Commit("add shared file on source"). + Checkout("master"). + CreateFileAndAdd("shared.txt", "content\n").Commit("add shared file on master"). + UpdateFile("shared.txt", "local change\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Stash(). + IsEmpty() + + t.Views().Files(). + Lines( + Contains("shared.txt"), + ) + + t.Views().Branches(). + Focus(). + Lines( + Contains("master"), + Contains("source"), + ). + SelectNextItem(). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + Lines( + Contains("add shared file on source").IsSelected(), + Contains("base"), + ). + Press(keys.Commits.CherryPickCopy) + + t.Views().Information().Content(Contains("1 commit copied")) + + t.Views().Commits(). + Focus(). + Lines( + Contains("add shared file on master").IsSelected(), + Contains("base"), + ). + Press(keys.Commits.PasteCommits). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Cherry-pick")). + Content(Contains("Are you sure you want to cherry-pick the 1 copied commit(s) onto this branch?")). + Confirm() + }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Skip this cherry-pick")). + Confirm() + }). + Tap(func() { + t.Shell().RunCommandExpectError([]string{"git", "rev-parse", "CHERRY_PICK_HEAD"}) + t.Views().Stash().IsEmpty() + t.Views().Files().Lines( + Contains("shared.txt"), + ) + t.Views().Information().Content(Contains("commit copied")) + }). + Lines( + Contains("add shared file on master").IsSelected(), + Contains("base"), + ) + }, +}) diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_empty_create_commit.go b/pkg/integration/tests/cherry_pick/cherry_pick_empty_create_commit.go new file mode 100644 index 00000000000..4e45b4c4415 --- /dev/null +++ b/pkg/integration/tests/cherry_pick/cherry_pick_empty_create_commit.go @@ -0,0 +1,74 @@ +package cherry_pick + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CherryPickEmptyCreateCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Cherry-picking a commit with no diff allows creating an empty commit to continue", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + EmptyCommit("base"). + NewBranch("source"). + CreateFileAndAdd("shared.txt", "content\n").Commit("add shared file on source"). + Checkout("master"). + CreateFileAndAdd("shared.txt", "content\n").Commit("add shared file on master") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master"), + Contains("source"), + ). + SelectNextItem(). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + Lines( + Contains("add shared file on source").IsSelected(), + Contains("base"), + ). + Press(keys.Commits.CherryPickCopy) + + t.Views().Information().Content(Contains("1 commit copied")) + + t.Views().Commits(). + Focus(). + Lines( + Contains("add shared file on master").IsSelected(), + Contains("base"), + ). + Press(keys.Commits.PasteCommits). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Cherry-pick")). + Content(Contains("Are you sure you want to cherry-pick the 1 copied commit(s) onto this branch?")). + Confirm() + }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() + }). + Tap(func() { + t.Shell().RunCommandExpectError([]string{"git", "rev-parse", "CHERRY_PICK_HEAD"}) + }). + Lines( + Contains("add shared file on source"), + Contains("add shared file on master").IsSelected(), + Contains("base"), + ) + }, +}) diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_empty_followed_by_conflict.go b/pkg/integration/tests/cherry_pick/cherry_pick_empty_followed_by_conflict.go new file mode 100644 index 00000000000..c9e20ae270e --- /dev/null +++ b/pkg/integration/tests/cherry_pick/cherry_pick_empty_followed_by_conflict.go @@ -0,0 +1,128 @@ +package cherry_pick + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CherryPickEmptyFollowedByConflict = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Cherry-picking multiple commits skips an empty commit and only cleans up after resolving subsequent conflicts", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "recency" + }, + SetupRepo: func(shell *Shell) { + shell. + CreateFileAndAdd("shared.txt", "base\n").Commit("add shared base"). + CreateFileAndAdd("conflict.txt", "base version\n").Commit("add conflict base"). + NewBranch("target"). + UpdateFileAndAdd("shared.txt", "target change\n").Commit("update shared on target"). + UpdateFileAndAdd("conflict.txt", "target version\n").Commit("update conflict on target"). + Checkout("master"). + NewBranch("source"). + UpdateFileAndAdd("shared.txt", "target change\n").Commit("match target shared"). + UpdateFileAndAdd("conflict.txt", "source version\n").Commit("add conflict on source"). + Checkout("target") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("target"), + Contains("source"), + Contains("master"), + ). + SelectNextItem(). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + Lines( + Contains("add conflict on source").IsSelected(), + Contains("match target shared"), + Contains("add conflict base"), + Contains("add shared base"), + ). + Press(keys.Universal.RangeSelectDown). + Lines( + Contains("add conflict on source").IsSelected(), + Contains("match target shared").IsSelected(), + Contains("add conflict base"), + Contains("add shared base"), + ). + Press(keys.Commits.CherryPickCopy) + + t.Views().Information().Content(Contains("2 commits copied")) + + t.Views().Commits(). + Focus(). + Lines( + Contains("update conflict on target").IsSelected(), + Contains("update shared on target"), + Contains("add conflict base"), + Contains("add shared base"), + ). + Press(keys.Commits.PasteCommits). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Cherry-pick")). + Content(Contains("Are you sure you want to cherry-pick the 2 copied commit(s) onto this branch?")). + Confirm() + }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Skip this cherry-pick")). + Confirm() + }). + Tap(func() { + t.Shell().RunCommand([]string{"git", "rev-parse", "CHERRY_PICK_HEAD"}) + }) + + t.Common().AcknowledgeConflicts() + + t.Views().Information().Content(Contains("2 commits copied")) + + t.Views().Files(). + IsFocused(). + SelectedLine(Contains("conflict.txt")). + PressEnter() + + t.Views().MergeConflicts(). + IsFocused(). + LineCount(EqualsInt(5)). + Lines( + Contains("<<<<<<< HEAD"), + Contains("target version"), + Contains("======="), + Contains("source version"), + Contains(">>>>>>>"), + ). + SelectNextItem(). + SelectNextItem(). + PressPrimaryAction() + + t.Common().ContinueOnConflictsResolved("cherry-pick") + + t.Views().Files().IsEmpty() + + t.Views().Commits(). + Focus(). + TopLines( + Contains("add conflict on source").IsSelected(), + Contains("update conflict on target"), + Contains("update shared on target"), + ). + SelectedLine(Contains("add conflict on source")) + + t.Views().Information().Content(DoesNotContain("commit copied")) + + t.Shell().RunCommandExpectError([]string{"git", "rev-parse", "CHERRY_PICK_HEAD"}) + }, +}) diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_range.go b/pkg/integration/tests/cherry_pick/cherry_pick_range.go index dfebeae2ed8..d739766a18e 100644 --- a/pkg/integration/tests/cherry_pick/cherry_pick_range.go +++ b/pkg/integration/tests/cherry_pick/cherry_pick_range.go @@ -68,6 +68,28 @@ var CherryPickRange = NewIntegrationTest(NewIntegrationTestArgs{ Content(Contains("Are you sure you want to cherry-pick the 2 copied commit(s) onto this branch?")). Confirm() }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() + }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() + }). Tap(func() { t.Views().Information().Content(DoesNotContain("commits copied")) }). diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_range_empty_intermediate.go b/pkg/integration/tests/cherry_pick/cherry_pick_range_empty_intermediate.go new file mode 100644 index 00000000000..398786436b5 --- /dev/null +++ b/pkg/integration/tests/cherry_pick/cherry_pick_range_empty_intermediate.go @@ -0,0 +1,99 @@ +package cherry_pick + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CherryPickRangeEmptyIntermediate = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Cherry-picking a range with an intermediate empty commit continues after creating an empty commit", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "recency" + }, + SetupRepo: func(shell *Shell) { + shell. + CreateFileAndAdd("shared.txt", "original\n").Commit("add shared file on master"). + NewBranch("target"). + UpdateFileAndAdd("shared.txt", "target change\n").Commit("update shared file on target"). + Checkout("master"). + NewBranch("source"). + CreateFileAndAdd("unique1.txt", "content1\n").Commit("add unique1 on source"). + UpdateFileAndAdd("shared.txt", "target change\n").Commit("match target change"). + CreateFileAndAdd("unique2.txt", "content2\n").Commit("add unique2 on source"). + Checkout("target") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("target"), + Contains("source"), + Contains("master"), + ). + SelectNextItem(). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + Lines( + Contains("add unique2 on source").IsSelected(), + Contains("match target change"), + Contains("add unique1 on source"), + Contains("add shared file on master"), + ). + Press(keys.Universal.RangeSelectDown). + Lines( + Contains("add unique2 on source").IsSelected(), + Contains("match target change").IsSelected(), + Contains("add unique1 on source"), + Contains("add shared file on master"), + ). + Press(keys.Universal.RangeSelectDown). + Lines( + Contains("add unique2 on source").IsSelected(), + Contains("match target change").IsSelected(), + Contains("add unique1 on source").IsSelected(), + Contains("add shared file on master"), + ). + Press(keys.Commits.CherryPickCopy) + + t.Views().Information().Content(Contains("3 commits copied")) + + t.Views().Commits(). + Focus(). + Lines( + Contains("update shared file on target").IsSelected(), + Contains("add shared file on master"), + ). + Press(keys.Commits.PasteCommits). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Cherry-pick")). + Content(Contains("Are you sure you want to cherry-pick the 3 copied commit(s) onto this branch?")). + Confirm() + }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() + }). + Tap(func() { + t.Shell().RunCommandExpectError([]string{"git", "rev-parse", "CHERRY_PICK_HEAD"}) + }). + Lines( + Contains("add unique2 on source"), + Contains("match target change"), + Contains("add unique1 on source"), + Contains("update shared file on target").IsSelected(), + Contains("add shared file on master"), + ) + }, +}) diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_range_empty_intermediate_skip.go b/pkg/integration/tests/cherry_pick/cherry_pick_range_empty_intermediate_skip.go new file mode 100644 index 00000000000..147a0c6027f --- /dev/null +++ b/pkg/integration/tests/cherry_pick/cherry_pick_range_empty_intermediate_skip.go @@ -0,0 +1,97 @@ +package cherry_pick + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CherryPickRangeEmptyIntermediateSkip = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Cherry-picking a range with an intermediate empty commit and skipping it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.LocalBranchSortOrder = "recency" + }, + SetupRepo: func(shell *Shell) { + shell. + CreateFileAndAdd("shared.txt", "original\n").Commit("add shared file on master"). + NewBranch("target"). + UpdateFileAndAdd("shared.txt", "target change\n").Commit("update shared file on target"). + Checkout("master"). + NewBranch("source"). + CreateFileAndAdd("unique1.txt", "content1\n").Commit("add unique1 on source"). + UpdateFileAndAdd("shared.txt", "target change\n").Commit("match target change"). + CreateFileAndAdd("unique2.txt", "content2\n").Commit("add unique2 on source"). + Checkout("target") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("target"), + Contains("source"), + Contains("master"), + ). + SelectNextItem(). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + Lines( + Contains("add unique2 on source").IsSelected(), + Contains("match target change"), + Contains("add unique1 on source"), + Contains("add shared file on master"), + ). + Press(keys.Universal.RangeSelectDown). + Lines( + Contains("add unique2 on source").IsSelected(), + Contains("match target change").IsSelected(), + Contains("add unique1 on source"), + Contains("add shared file on master"), + ). + Press(keys.Universal.RangeSelectDown). + Lines( + Contains("add unique2 on source").IsSelected(), + Contains("match target change").IsSelected(), + Contains("add unique1 on source").IsSelected(), + Contains("add shared file on master"), + ). + Press(keys.Commits.CherryPickCopy) + + t.Views().Information().Content(Contains("3 commits copied")) + + t.Views().Commits(). + Focus(). + Lines( + Contains("update shared file on target").IsSelected(), + Contains("add shared file on master"), + ). + Press(keys.Commits.PasteCommits). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Cherry-pick")). + Content(Contains("Are you sure you want to cherry-pick the 3 copied commit(s) onto this branch?")). + Confirm() + }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Skip this cherry-pick")). + Confirm() + }). + Tap(func() { + t.Views().Information().Content(DoesNotContain("commits copied")) + }). + TopLines( + Contains("add unique2 on source").IsSelected(), + Contains("add unique1 on source"), + Contains("update shared file on target"), + ) + }, +}) diff --git a/pkg/integration/tests/commit/revert_empty_commit_resolution.go b/pkg/integration/tests/commit/revert_empty_commit_resolution.go new file mode 100644 index 00000000000..736f42aa2e9 --- /dev/null +++ b/pkg/integration/tests/commit/revert_empty_commit_resolution.go @@ -0,0 +1,69 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RevertEmptyCommitResolution = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Handles a revert whose commit becomes empty and offers skip/create options", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("myfile", "") + shell.Commit("add empty file") + shell.CreateFileAndAdd("myfile", "first line\n") + shell.Commit("add first line") + shell.UpdateFileAndAdd("myfile", "first line\nsecond line\n") + shell.Commit("add second line") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("CI ◯ add second line").IsSelected(), + Contains("CI ◯ add first line"), + Contains("CI ◯ add empty file"), + ). + SelectNextItem(). + Press(keys.Commits.RevertCommit). + Tap(func() { + t.ExpectPopup().Confirmation(). + Title(Equals("Revert commit")). + Content(MatchesRegexp(`Are you sure you want to revert \w+?`)). + Confirm() + t.Common().AcknowledgeConflicts() + }) + + t.Shell(). + UpdateFile("myfile", "first line\nsecond line\n"). + RunCommand([]string{"git", "add", "myfile"}) + + t.Views().Commits().Focus() + t.Common().ContinueRebase() + t.Common().ContinueOnConflictsResolved("revert") + + t.ExpectPopup().Menu(). + Title(Equals("Commit produced no changes")). + ContainsLines( + Contains("Skip this revert step"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() + + t.Views().Commits(). + Lines( + Contains("CI ◯ Revert \"add first line\""), + Contains("CI ◯ add second line"), + Contains("CI ◯ add first line").IsSelected(), + Contains("CI ◯ add empty file"), + ) + + t.Views().Commits().Content(DoesNotContain("Pending reverts")) + t.Views().Options().Content(DoesNotContain("View revert options")). + Content(DoesNotContain("You are currently neither rebasing nor merging")) + }, +}) diff --git a/pkg/integration/tests/demo/cherry_pick.go b/pkg/integration/tests/demo/cherry_pick.go index a29f34bb954..a9fecf6701f 100644 --- a/pkg/integration/tests/demo/cherry_pick.go +++ b/pkg/integration/tests/demo/cherry_pick.go @@ -1,6 +1,10 @@ package demo import ( + "os" + "os/exec" + "time" + "github.com/jesseduffield/lazygit/pkg/config" . "github.com/jesseduffield/lazygit/pkg/integration/components" ) @@ -29,6 +33,34 @@ var CherryPick = NewIntegrationTest(NewIntegrationTestArgs{ Checkout("hotfix/fix-bug") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { + wd, err := os.Getwd() + if err != nil { + t.Fail("Could not determine working directory: " + err.Error()) + return + } + + cherryPickInProgress := func() bool { + cmd := exec.Command("git", "rev-parse", "CHERRY_PICK_HEAD") + cmd.Dir = wd + + return cmd.Run() == nil + } + + waitForCherryPickInProgress := func(timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for { + if cherryPickInProgress() { + return true + } + + if time.Now().After(deadline) { + return false + } + + t.Wait(100) + } + } + t.SetCaptionPrefix("Cherry pick commits from another branch") t.Wait(1000) @@ -74,6 +106,32 @@ var CherryPick = NewIntegrationTest(NewIntegrationTestArgs{ Content(Contains("Are you sure you want to cherry-pick the 2 copied commit(s) onto this branch?")). Confirm() }). + Tap(func() { + if !waitForCherryPickInProgress(time.Second) { + return + } + + for { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() + + t.Wait(100) + + if !waitForCherryPickInProgress(time.Second) { + break + } + } + }). + Tap(func() { + t.Shell().RunCommandExpectError([]string{"git", "rev-parse", "CHERRY_PICK_HEAD"}) + }). TopLines( Contains("Add Webpack for asset bundling"), Contains("Handle session timeout gracefully"), diff --git a/pkg/integration/tests/interactive_rebase/rebase_with_commit_that_becomes_empty.go b/pkg/integration/tests/interactive_rebase/rebase_with_commit_that_becomes_empty.go index e9b743856d0..de8fc089fec 100644 --- a/pkg/integration/tests/interactive_rebase/rebase_with_commit_that_becomes_empty.go +++ b/pkg/integration/tests/interactive_rebase/rebase_with_commit_that_becomes_empty.go @@ -35,6 +35,16 @@ var RebaseWithCommitThatBecomesEmpty = NewIntegrationTest(NewIntegrationTestArgs Select(Contains("Simple rebase")). Confirm() + t.ExpectPopup().Menu(). + Title(Equals("Commit produced no changes")). + ContainsLines( + Contains("Skip this rebase step"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Skip this rebase step")). + Confirm() + t.Views().Commits(). Lines( Contains("master change 2"), diff --git a/pkg/integration/tests/reflog/cherry_pick.go b/pkg/integration/tests/reflog/cherry_pick.go index 1e223c9e059..c48d59904c3 100644 --- a/pkg/integration/tests/reflog/cherry_pick.go +++ b/pkg/integration/tests/reflog/cherry_pick.go @@ -42,6 +42,23 @@ var CherryPick = NewIntegrationTest(NewIntegrationTestArgs{ Content(Contains("Are you sure you want to cherry-pick the 1 copied commit(s) onto this branch?")). Confirm() }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Cherry-pick produced no changes")). + ContainsLines( + Contains("Skip this cherry-pick"), + Contains("Create empty commit and continue"), + Contains("Cancel"), + ). + Select(Contains("Create empty commit and continue")). + Confirm() + + t.Views().Commits(). + SelectedLine(Contains("one")) + }). + Tap(func() { + t.Shell().RunCommandExpectError([]string{"git", "rev-parse", "CHERRY_PICK_HEAD"}) + }). Lines( Contains("three"), Contains("one").IsSelected(), diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a292227b388..78fa0760f0f 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -88,8 +88,14 @@ var tests = []*components.IntegrationTest{ cherry_pick.CherryPick, cherry_pick.CherryPickConflicts, cherry_pick.CherryPickDuringRebase, + cherry_pick.CherryPickEmpty, + cherry_pick.CherryPickEmptyCreateCommit, + cherry_pick.CherryPickEmptyAutoStash, + cherry_pick.CherryPickEmptyFollowedByConflict, cherry_pick.CherryPickMerge, cherry_pick.CherryPickRange, + cherry_pick.CherryPickRangeEmptyIntermediate, + cherry_pick.CherryPickRangeEmptyIntermediateSkip, commit.AddCoAuthor, commit.AddCoAuthorRange, commit.AddCoAuthorWhileCommitting, @@ -138,6 +144,7 @@ var tests = []*components.IntegrationTest{ commit.RevertMerge, commit.RevertWithConflictMultipleCommits, commit.RevertWithConflictSingleCommit, + commit.RevertEmptyCommitResolution, commit.Reword, commit.Search, commit.SetAuthor,