mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-11-12 15:53:33 -04:00
Let the user continue or abort a squash merge (#6197)
If a squash merge stops with conflicts, lazygit didn't show a merge in progress, because "git merge --squash" doesn't write MERGE_HEAD. After resolving the conflicts, users had to commit the changes themselves, and type the message that "Squash merge and commit" would have used. To give up, they had to discard the changes. The "Abort the merge" item of the conflicts menu failed with "You are currently neither rebasing nor merging". Improve this by showing a merge in progress when a "Squash merge and commit" fails with conflicts, so that users can continue or abort it like they would with a regular merge. We even enter this state after "Squash merge and leave uncommitted", so that users can use the `m` menu to continue and create a commit with the right message. This is a change in behavior that I hope won't disturb anyone's workflows; please file an issue if it does.
This commit is contained in:
@@ -2,6 +2,7 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
@@ -285,6 +286,24 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// SquashMerge stages the changes of the given branch, and makes message the
|
||||
// message for committing them. "git merge --squash" suggests a log of the
|
||||
// branch's commits in SQUASH_MSG; "git commit" uses this file.
|
||||
func (self *BranchCommands) SquashMerge(branchName string, message string) error {
|
||||
mergeErr := self.Merge(branchName, MERGE_VARIANT_SQUASH)
|
||||
|
||||
// If the merge failed without writing SQUASH_MSG, it didn't start. Writing
|
||||
// the file would make it look like a squash merge is in progress.
|
||||
squashMsgPath := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "SQUASH_MSG")
|
||||
if exists, _ := self.os.FileExists(squashMsgPath); exists {
|
||||
if err := self.os.CreateFileWithContent(squashMsgPath, message+"\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return mergeErr
|
||||
}
|
||||
|
||||
// Fast-forwards the branch that is checked out in the given worktree to the
|
||||
// given ref. Fails if that can't be done without a merge commit. Pass empty
|
||||
// strings for the worktree to use the current one.
|
||||
|
||||
@@ -474,25 +474,49 @@ func (self *RebaseCommands) RebaseBranchFromBaseCommit(targetBranchName string,
|
||||
}).Run()
|
||||
}
|
||||
|
||||
func (self *RebaseCommands) GenericMergeOrRebaseActionCmdObj(commandType string, command string) *oscommands.CmdObj {
|
||||
cmdArgs := NewGitCmd(commandType).Arg("--" + command).ToArgv()
|
||||
// GenericMergeOrRebaseActionCmdObj returns the command that continues, skips or
|
||||
// aborts the given operation; command is "continue", "skip" or "abort".
|
||||
func (self *RebaseCommands) GenericMergeOrRebaseActionCmdObj(operation models.EffectiveWorkingTreeState, command string) *oscommands.CmdObj {
|
||||
if operation == models.WORKING_TREE_STATE_SQUASH_MERGING {
|
||||
return self.cmd.New(squashMergeActionArgs(command))
|
||||
}
|
||||
|
||||
cmdArgs := NewGitCmd(operation.CommandName()).Arg("--" + command).ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs)
|
||||
}
|
||||
|
||||
// squashMergeActionArgs returns the arguments of the command that continues or
|
||||
// aborts a squash merge. "git merge --continue" and "git merge --abort" refuse
|
||||
// to run without MERGE_HEAD, which a squash merge doesn't write. "git commit"
|
||||
// commits the changes with the message in SQUASH_MSG. "git merge --abort" runs
|
||||
// "git reset --merge" too.
|
||||
func squashMergeActionArgs(command string) []string {
|
||||
switch command {
|
||||
case "continue":
|
||||
return NewGitCmd("commit").ToArgv()
|
||||
case "abort":
|
||||
return NewGitCmd("reset").Arg("--merge").ToArgv()
|
||||
}
|
||||
|
||||
panic("can't " + command + " a squash merge")
|
||||
}
|
||||
|
||||
func (self *RebaseCommands) ContinueRebase() error {
|
||||
return self.GenericMergeOrRebaseAction("rebase", "continue")
|
||||
return self.GenericMergeOrRebaseAction(models.WORKING_TREE_STATE_REBASING, "continue")
|
||||
}
|
||||
|
||||
func (self *RebaseCommands) AbortRebase() error {
|
||||
return self.GenericMergeOrRebaseAction("rebase", "abort")
|
||||
return self.GenericMergeOrRebaseAction(models.WORKING_TREE_STATE_REBASING, "abort")
|
||||
}
|
||||
|
||||
// GenericMerge takes a commandType of "merge" or "rebase" and a command of "abort", "skip" or "continue"
|
||||
// By default we skip the editor in the case where a commit will be made
|
||||
func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, command string) error {
|
||||
// GenericMergeOrRebaseAction continues, skips or aborts the given operation;
|
||||
// command is "continue", "skip" or "abort". If this makes a commit, we skip the
|
||||
// editor.
|
||||
func (self *RebaseCommands) GenericMergeOrRebaseAction(operation models.EffectiveWorkingTreeState, command string) error {
|
||||
undoRepeatingTodo := func() {}
|
||||
if command == "continue" && (commandType == "cherry-pick" || commandType == "revert") {
|
||||
if command == "continue" &&
|
||||
(operation == models.WORKING_TREE_STATE_CHERRY_PICKING || operation == models.WORKING_TREE_STATE_REVERTING) {
|
||||
var err error
|
||||
undoRepeatingTodo, err = repeatCurrentSequencerTodo(
|
||||
self.cmd, self.repoPaths.WorktreeGitDirPath(), self.config.GetCoreCommentChar())
|
||||
@@ -501,23 +525,24 @@ func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, comma
|
||||
}
|
||||
}
|
||||
|
||||
if commandType == "rebase" && command == "continue" {
|
||||
if operation == models.WORKING_TREE_STATE_REBASING && command == "continue" {
|
||||
if err := self.commitPickThatRebaseStoppedAt(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
cmdObj := skipEditor(self.GenericMergeOrRebaseActionCmdObj(commandType, command))
|
||||
cmdObj := skipEditor(self.GenericMergeOrRebaseActionCmdObj(operation, command))
|
||||
if command == "continue" || command == "skip" {
|
||||
switch commandType {
|
||||
case "rebase":
|
||||
switch operation {
|
||||
case models.WORKING_TREE_STATE_REBASING:
|
||||
cmdObj = self.resumeAfterLockError(cmdObj)
|
||||
case "cherry-pick", "revert":
|
||||
case models.WORKING_TREE_STATE_CHERRY_PICKING, models.WORKING_TREE_STATE_REVERTING:
|
||||
cmdObj = retryContinueOrSkipAfterLockError(cmdObj,
|
||||
filepath.Join(self.repoPaths.WorktreeGitDirPath(), "sequencer", "todo"))
|
||||
case "am":
|
||||
case models.WORKING_TREE_STATE_APPLYING_PATCHES:
|
||||
cmdObj = retryContinueOrSkipAfterLockError(cmdObj,
|
||||
filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-apply", "next"))
|
||||
case models.WORKING_TREE_STATE_MERGING, models.WORKING_TREE_STATE_SQUASH_MERGING, models.WORKING_TREE_STATE_NONE:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,7 +558,7 @@ func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, comma
|
||||
// sometimes we need to do a sequence of things in a rebase but the user needs to
|
||||
// fix merge conflicts along the way. When this happens we queue up the next step
|
||||
// so that after the next successful rebase continue we can continue from where we left off
|
||||
if commandType == "rebase" && command == "continue" && self.onSuccessfulContinue != nil {
|
||||
if operation == models.WORKING_TREE_STATE_REBASING && command == "continue" && self.onSuccessfulContinue != nil {
|
||||
f := self.onSuccessfulContinue
|
||||
self.onSuccessfulContinue = nil
|
||||
return f()
|
||||
@@ -554,7 +579,7 @@ func (self *RebaseCommands) commitPickThatRebaseStoppedAt() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return skipEditor(self.GenericMergeOrRebaseActionCmdObj("cherry-pick", "continue")).Run()
|
||||
return skipEditor(self.GenericMergeOrRebaseActionCmdObj(models.WORKING_TREE_STATE_CHERRY_PICKING, "continue")).Run()
|
||||
}
|
||||
|
||||
// resumeAfterLockError makes cmdObj, a command that starts or continues a
|
||||
@@ -563,7 +588,7 @@ func (self *RebaseCommands) commitPickThatRebaseStoppedAt() error {
|
||||
func (self *RebaseCommands) resumeAfterLockError(cmdObj *oscommands.CmdObj) *oscommands.CmdObj {
|
||||
return resumeRebaseAfterLockError(cmdObj, self.repoPaths.WorktreeGitDirPath(), self.config.GetCoreCommentChar(),
|
||||
func() *oscommands.CmdObj {
|
||||
return skipEditor(self.GenericMergeOrRebaseActionCmdObj("rebase", "continue"))
|
||||
return skipEditor(self.GenericMergeOrRebaseActionCmdObj(models.WORKING_TREE_STATE_REBASING, "continue"))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ func (self *StatusCommands) WorkingTreeState() models.WorkingTreeState {
|
||||
result.CherryPicking, _ = self.IsInCherryPick()
|
||||
result.Reverting, _ = self.IsInRevert()
|
||||
result.ApplyingPatches, _ = self.IsApplyingPatches()
|
||||
result.SquashMerging, _ = self.IsInSquashMerge()
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -63,6 +64,25 @@ func (self *StatusCommands) IsInMergeState() (bool, error) {
|
||||
return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "MERGE_HEAD"))
|
||||
}
|
||||
|
||||
// IsInSquashMerge tells whether "git merge --squash" has staged changes that
|
||||
// haven't been committed yet. "git merge --squash" writes SQUASH_MSG, a message
|
||||
// for committing them, but no MERGE_HEAD. "git commit" uses the message and
|
||||
// removes the file; so do "git reset" without paths, switching branches, and
|
||||
// "git stash".
|
||||
func (self *StatusCommands) IsInSquashMerge() (bool, error) {
|
||||
return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "SQUASH_MSG"))
|
||||
}
|
||||
|
||||
// SquashMergeMessage returns the message in SQUASH_MSG (see IsInSquashMerge), or
|
||||
// an empty string if there is no squash merge in progress
|
||||
func (self *StatusCommands) SquashMergeMessage() string {
|
||||
content, err := os.ReadFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "SQUASH_MSG"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(content))
|
||||
}
|
||||
|
||||
func (self *StatusCommands) IsInCherryPick() (bool, error) {
|
||||
hasCherryPickHead, err := self.hasCherryPickHead()
|
||||
if err != nil || hasCherryPickHead {
|
||||
|
||||
@@ -361,6 +361,11 @@ func (self *WorkingTreeCommands) DiscardUnstagedFileChanges(file *models.File) e
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// DiscardUnstagedChanges discards the unstaged changes of the given files
|
||||
func (self *WorkingTreeCommands) DiscardUnstagedChanges(paths []string) error {
|
||||
return runGitCmdOnPaths("checkout", paths, self.cmd)
|
||||
}
|
||||
|
||||
// Escapes special characters in a filename for gitignore and exclude files, and prepends `/`
|
||||
func escapeFilename(filename string) string {
|
||||
re := regexp.MustCompile(`^[!#]|[\[\]*]`)
|
||||
|
||||
@@ -4,9 +4,9 @@ import "github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
|
||||
// The state of the working tree. Several of these can be true at once.
|
||||
// In particular, the concrete multi-state combinations that can occur in
|
||||
// practice are Rebasing+CherryPicking, Rebasing+Reverting, and
|
||||
// Rebasing+ApplyingPatches. Theoretically, I guess Rebasing+Merging could also
|
||||
// happen, but it probably won't in practice.
|
||||
// practice are Rebasing+CherryPicking, Rebasing+Reverting,
|
||||
// Rebasing+ApplyingPatches, and Rebasing+SquashMerging. Theoretically, I guess
|
||||
// Rebasing+Merging could also happen, but it probably won't in practice.
|
||||
type WorkingTreeState struct {
|
||||
Rebasing bool
|
||||
Merging bool
|
||||
@@ -14,10 +14,13 @@ type WorkingTreeState struct {
|
||||
Reverting bool
|
||||
// git am
|
||||
ApplyingPatches bool
|
||||
// git merge --squash, until the changes are committed
|
||||
SquashMerging bool
|
||||
}
|
||||
|
||||
func (self WorkingTreeState) Any() bool {
|
||||
return self.Rebasing || self.Merging || self.CherryPicking || self.Reverting || self.ApplyingPatches
|
||||
return self.Rebasing || self.Merging || self.CherryPicking || self.Reverting || self.ApplyingPatches ||
|
||||
self.SquashMerging
|
||||
}
|
||||
|
||||
func (self WorkingTreeState) None() bool {
|
||||
@@ -34,6 +37,7 @@ const (
|
||||
WORKING_TREE_STATE_CHERRY_PICKING
|
||||
WORKING_TREE_STATE_REVERTING
|
||||
WORKING_TREE_STATE_APPLYING_PATCHES
|
||||
WORKING_TREE_STATE_SQUASH_MERGING
|
||||
)
|
||||
|
||||
// Effective returns the "current" state; if several states are true at once,
|
||||
@@ -56,6 +60,9 @@ func (self WorkingTreeState) Effective() EffectiveWorkingTreeState {
|
||||
if self.Merging {
|
||||
return WORKING_TREE_STATE_MERGING
|
||||
}
|
||||
if self.SquashMerging {
|
||||
return WORKING_TREE_STATE_SQUASH_MERGING
|
||||
}
|
||||
if self.ApplyingPatches {
|
||||
return WORKING_TREE_STATE_APPLYING_PATCHES
|
||||
}
|
||||
@@ -69,6 +76,7 @@ func (self WorkingTreeState) Title(tr *i18n.TranslationSet) string {
|
||||
return map[EffectiveWorkingTreeState]string{
|
||||
WORKING_TREE_STATE_REBASING: tr.RebasingStatus,
|
||||
WORKING_TREE_STATE_MERGING: tr.MergingStatus,
|
||||
WORKING_TREE_STATE_SQUASH_MERGING: tr.MergingStatus,
|
||||
WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickingStatus,
|
||||
WORKING_TREE_STATE_REVERTING: tr.RevertingStatus,
|
||||
WORKING_TREE_STATE_APPLYING_PATCHES: tr.ApplyingPatchesStatus,
|
||||
@@ -79,6 +87,7 @@ func (self WorkingTreeState) LowerCaseTitle(tr *i18n.TranslationSet) string {
|
||||
return map[EffectiveWorkingTreeState]string{
|
||||
WORKING_TREE_STATE_REBASING: tr.LowercaseRebasingStatus,
|
||||
WORKING_TREE_STATE_MERGING: tr.LowercaseMergingStatus,
|
||||
WORKING_TREE_STATE_SQUASH_MERGING: tr.LowercaseMergingStatus,
|
||||
WORKING_TREE_STATE_CHERRY_PICKING: tr.LowercaseCherryPickingStatus,
|
||||
WORKING_TREE_STATE_REVERTING: tr.LowercaseRevertingStatus,
|
||||
WORKING_TREE_STATE_APPLYING_PATCHES: tr.LowercaseApplyingPatchesStatus,
|
||||
@@ -89,6 +98,7 @@ func (self WorkingTreeState) OptionsMenuTitle(tr *i18n.TranslationSet) string {
|
||||
return map[EffectiveWorkingTreeState]string{
|
||||
WORKING_TREE_STATE_REBASING: tr.RebaseOptionsTitle,
|
||||
WORKING_TREE_STATE_MERGING: tr.MergeOptionsTitle,
|
||||
WORKING_TREE_STATE_SQUASH_MERGING: tr.MergeOptionsTitle,
|
||||
WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickOptionsTitle,
|
||||
WORKING_TREE_STATE_REVERTING: tr.RevertOptionsTitle,
|
||||
WORKING_TREE_STATE_APPLYING_PATCHES: tr.ApplyPatchesOptionsTitle,
|
||||
@@ -99,6 +109,7 @@ func (self WorkingTreeState) OptionsMapTitle(tr *i18n.TranslationSet) string {
|
||||
return map[EffectiveWorkingTreeState]string{
|
||||
WORKING_TREE_STATE_REBASING: tr.ViewRebaseOptions,
|
||||
WORKING_TREE_STATE_MERGING: tr.ViewMergeOptions,
|
||||
WORKING_TREE_STATE_SQUASH_MERGING: tr.ViewMergeOptions,
|
||||
WORKING_TREE_STATE_CHERRY_PICKING: tr.ViewCherryPickOptions,
|
||||
WORKING_TREE_STATE_REVERTING: tr.ViewRevertOptions,
|
||||
WORKING_TREE_STATE_APPLYING_PATCHES: tr.ViewApplyPatchesOptions,
|
||||
@@ -111,20 +122,24 @@ func (self WorkingTreeState) OperationName(tr *i18n.TranslationSet) string {
|
||||
return map[EffectiveWorkingTreeState]string{
|
||||
WORKING_TREE_STATE_REBASING: tr.RebaseOperationName,
|
||||
WORKING_TREE_STATE_MERGING: tr.MergeOperationName,
|
||||
WORKING_TREE_STATE_SQUASH_MERGING: tr.MergeOperationName,
|
||||
WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickOperationName,
|
||||
WORKING_TREE_STATE_REVERTING: tr.RevertOperationName,
|
||||
WORKING_TREE_STATE_APPLYING_PATCHES: tr.ApplyPatchesOperationName,
|
||||
}[self.Effective()]
|
||||
}
|
||||
|
||||
func (self WorkingTreeState) CommandName() string {
|
||||
// CommandName returns the git command whose --continue, --skip and --abort
|
||||
// options continue, skip or abort the operation. A squash merge has no such
|
||||
// command.
|
||||
func (self EffectiveWorkingTreeState) CommandName() string {
|
||||
return map[EffectiveWorkingTreeState]string{
|
||||
WORKING_TREE_STATE_REBASING: "rebase",
|
||||
WORKING_TREE_STATE_MERGING: "merge",
|
||||
WORKING_TREE_STATE_CHERRY_PICKING: "cherry-pick",
|
||||
WORKING_TREE_STATE_REVERTING: "revert",
|
||||
WORKING_TREE_STATE_APPLYING_PATCHES: "am",
|
||||
}[self.Effective()]
|
||||
}[self]
|
||||
}
|
||||
|
||||
func (self WorkingTreeState) CanShowTodos() bool {
|
||||
|
||||
@@ -131,6 +131,10 @@ func (self *CommitMessageContext) GetInitialMessage() string {
|
||||
return self.viewModel.initialMessage
|
||||
}
|
||||
|
||||
func (self *CommitMessageContext) SetInitialMessage(message string) {
|
||||
self.viewModel.initialMessage = message
|
||||
}
|
||||
|
||||
func (self *CommitMessageContext) GetHistoryMessage() string {
|
||||
return self.viewModel.historyMessage
|
||||
}
|
||||
@@ -148,7 +152,6 @@ func (self *CommitMessageContext) SetPanelState(
|
||||
summaryTitle string,
|
||||
descriptionTitle string,
|
||||
preserveMessage bool,
|
||||
initialMessage string,
|
||||
onConfirm func(string, string) error,
|
||||
onSwitchToEditor func(string) error,
|
||||
forceSkipHooks bool,
|
||||
@@ -156,7 +159,6 @@ func (self *CommitMessageContext) SetPanelState(
|
||||
) {
|
||||
self.viewModel.selectedindex = index
|
||||
self.viewModel.preserveMessage = preserveMessage
|
||||
self.viewModel.initialMessage = initialMessage
|
||||
self.viewModel.onConfirm = onConfirm
|
||||
self.viewModel.onSwitchToEditor = onSwitchToEditor
|
||||
self.viewModel.forceSkipHooks = forceSkipHooks
|
||||
|
||||
@@ -1891,7 +1891,10 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.FILES, types.WORKTREES},
|
||||
Then: self.c.Helpers().MergeAndRebase.AbortSquashMergeIfNoChangesAreLeft,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
|
||||
@@ -143,10 +143,7 @@ func (self *CommitsHelper) OpenCommitMessagePanel(opts *OpenCommitMessagePanelOp
|
||||
}
|
||||
|
||||
// When there's no explicit initial message but we're in a preservation
|
||||
// context, fall back to any previously preserved message. This is stored as
|
||||
// the "initial" value so the unchanged-message check on close still works
|
||||
// correctly (in particular, clearing the panel then escaping will notice
|
||||
// the difference and delete the preserved file).
|
||||
// context, fall back to any previously preserved message.
|
||||
initialMessage := opts.InitialMessage
|
||||
initialMessageIsPreserved := false
|
||||
if opts.PreserveMessage && initialMessage == "" {
|
||||
@@ -159,7 +156,6 @@ func (self *CommitsHelper) OpenCommitMessagePanel(opts *OpenCommitMessagePanelOp
|
||||
opts.SummaryTitle,
|
||||
opts.DescriptionTitle,
|
||||
opts.PreserveMessage,
|
||||
initialMessage,
|
||||
onConfirm,
|
||||
opts.OnSwitchToEditor,
|
||||
opts.ForceSkipHooks,
|
||||
@@ -172,6 +168,14 @@ func (self *CommitsHelper) OpenCommitMessagePanel(opts *OpenCommitMessagePanelOp
|
||||
self.SetMessageAndDescriptionInView(initialMessage)
|
||||
}
|
||||
|
||||
// PreserveCommitMessage compares the message in the panel with this when the
|
||||
// panel closes, so that it preserves the message only if the user changed it
|
||||
// (in particular, clearing the panel then escaping will notice the
|
||||
// difference and delete the preserved file). Take it from the panel, because
|
||||
// a message with a blank line after the summary comes out of the panel
|
||||
// without it.
|
||||
self.c.Contexts().CommitMessage.SetInitialMessage(self.JoinCommitMessageAndUnwrappedDescription())
|
||||
|
||||
self.c.Context().Push(self.c.Contexts().CommitMessage, types.OnFocusOpts{})
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ func (self *MergeAndRebaseHelper) CreateRebaseOptionsMenu() error {
|
||||
return &types.MenuItem{
|
||||
Label: row.option,
|
||||
OnPress: func() error {
|
||||
if row.option == REBASE_OPTION_ABORT {
|
||||
return self.abortMergeOrRebase(false)
|
||||
}
|
||||
return self.genericMergeCommand(row.option)
|
||||
},
|
||||
Keys: row.keys,
|
||||
@@ -89,8 +92,9 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
|
||||
// non-subprocess path runs on a worker with a waiting status.
|
||||
//
|
||||
// showWaitingStatus is false only for the recursive auto-skip in
|
||||
// CheckMergeOrRebaseWithRefreshOptions, which already runs on a worker, so it
|
||||
// must not spin up a second waiting status. calledFromWorker is used only by the
|
||||
// CheckMergeOrRebaseWithRefreshOptions and for aborting after discarding
|
||||
// changes in abortMergeOrRebase; both already run on a worker, so they must not
|
||||
// spin up a second waiting status. calledFromWorker is used only by the
|
||||
// subprocess path below: it's true for that recursive worker skip and false for
|
||||
// genericMergeCommand's UI-thread invocation, so the post-action refresh picks
|
||||
// RefreshFromWorker vs Refresh correctly.
|
||||
@@ -117,21 +121,21 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa
|
||||
}
|
||||
}
|
||||
|
||||
commandType := status.CommandName()
|
||||
selectHeadCommitOnSuccess := command == REBASE_OPTION_CONTINUE &&
|
||||
effectiveStatus == models.WORKING_TREE_STATE_MERGING
|
||||
isMerging := effectiveStatus == models.WORKING_TREE_STATE_MERGING ||
|
||||
effectiveStatus == models.WORKING_TREE_STATE_SQUASH_MERGING
|
||||
selectHeadCommitOnSuccess := command == REBASE_OPTION_CONTINUE && isMerging
|
||||
|
||||
// we should end up with a command like 'git merge --continue'
|
||||
|
||||
// it's impossible for a rebase to require a commit so we'll use a subprocess only if it's a merge
|
||||
needsSubprocess := (effectiveStatus == models.WORKING_TREE_STATE_MERGING && command != REBASE_OPTION_ABORT && self.c.UserConfig().Git.Merging.ManualCommit) ||
|
||||
needsSubprocess := (isMerging && command != REBASE_OPTION_ABORT && self.c.UserConfig().Git.Merging.ManualCommit) ||
|
||||
// but we'll also use a subprocess if we have exec todos; those are likely to be lengthy build
|
||||
// tasks whose output the user will want to see in the terminal
|
||||
(effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos(calledFromWorker))
|
||||
|
||||
if needsSubprocess {
|
||||
// TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction
|
||||
success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command))
|
||||
success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(effectiveStatus, command))
|
||||
self.refreshAfterMergeOrRebase(types.RefreshOptions{
|
||||
CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess),
|
||||
}, calledFromWorker)
|
||||
@@ -141,9 +145,10 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa
|
||||
|
||||
// runAction always ends up on a worker: either the waiting status below spins
|
||||
// one up, or we're the recursive auto-skip reached from
|
||||
// CheckMergeOrRebaseWithRefreshOptions, which already runs on one.
|
||||
// CheckMergeOrRebaseWithRefreshOptions or the abort in abortMergeOrRebase,
|
||||
// which already run on one.
|
||||
runAction := func() error {
|
||||
result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command)
|
||||
result := self.c.Git().Rebase.GenericMergeOrRebaseAction(effectiveStatus, command)
|
||||
return self.CheckMergeOrRebaseWithRefreshOptions(result,
|
||||
types.RefreshOptions{
|
||||
CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess),
|
||||
@@ -305,7 +310,7 @@ func (self *MergeAndRebaseHelper) PromptForConflictHandling() error {
|
||||
Label: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationMenuItem,
|
||||
map[string]string{"operation": operation}),
|
||||
OnPress: func() error {
|
||||
return self.genericMergeCommand(REBASE_OPTION_ABORT)
|
||||
return self.abortMergeOrRebase(false)
|
||||
},
|
||||
Keys: menuKey('a'),
|
||||
},
|
||||
@@ -315,19 +320,90 @@ func (self *MergeAndRebaseHelper) PromptForConflictHandling() error {
|
||||
}
|
||||
|
||||
func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error {
|
||||
// prompt user to confirm that they want to abort, then do it
|
||||
placeholders := map[string]string{
|
||||
"operation": self.c.Git().Status.WorkingTreeState().OperationName(self.c.Tr),
|
||||
}
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationTitle, placeholders),
|
||||
Prompt: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationPrompt, placeholders),
|
||||
HandleConfirm: func() error {
|
||||
return self.genericMergeCommand(REBASE_OPTION_ABORT)
|
||||
},
|
||||
})
|
||||
return self.abortMergeOrRebase(true)
|
||||
}
|
||||
|
||||
return nil
|
||||
// abortMergeOrRebase aborts the operation in progress. If alwaysConfirm is
|
||||
// false, it asks the user to confirm only if it has to discard changes before
|
||||
// git can abort.
|
||||
func (self *MergeAndRebaseHelper) abortMergeOrRebase(alwaysConfirm bool) error {
|
||||
status := self.c.Git().Status.WorkingTreeState()
|
||||
placeholders := map[string]string{
|
||||
"operation": status.OperationName(self.c.Tr),
|
||||
}
|
||||
|
||||
pathsToDiscard := pathsToDiscardBeforeAbort(status, self.c.Model().Files, self.c.Model().Submodules)
|
||||
if len(pathsToDiscard) > 0 {
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationTitle, placeholders),
|
||||
Prompt: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationDiscardsChangesPrompt, placeholders) +
|
||||
"\n\n- " + strings.Join(pathsToDiscard, "\n- "),
|
||||
HandleConfirm: func() error {
|
||||
return self.c.WithWaitingStatus(status.Title(self.c.Tr), func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.DiscardAllUnstagedChangesInFile)
|
||||
if err := self.c.Git().WorkingTree.DiscardUnstagedChanges(pathsToDiscard); err != nil {
|
||||
return err
|
||||
}
|
||||
return self.genericMergeCommandImpl(REBASE_OPTION_ABORT, false, true)
|
||||
})
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if alwaysConfirm {
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationTitle, placeholders),
|
||||
Prompt: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationPrompt, placeholders),
|
||||
HandleConfirm: func() error {
|
||||
return self.genericMergeCommand(REBASE_OPTION_ABORT)
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.genericMergeCommand(REBASE_OPTION_ABORT)
|
||||
}
|
||||
|
||||
// pathsToDiscardBeforeAbort returns the files whose unstaged changes we have to
|
||||
// discard before git can abort the operation in progress.
|
||||
//
|
||||
// Git refuses to start a merge, cherry-pick, revert or "git am" if there are
|
||||
// staged changes, or if a file that it has to change has unstaged changes. It
|
||||
// does start a squash merge that it can fast-forward while there are staged
|
||||
// changes, but we don't offer squash merges then. Therefore, if a file has
|
||||
// staged changes during the operation, git staged them (or the user did after
|
||||
// git stopped), and any unstaged changes on top of them were made after git
|
||||
// stopped, for example to fix the build. Git's abort command keeps unstaged
|
||||
// changes, but it fails if they are on top of staged changes that it has to
|
||||
// reset. A rebase is different. It refuses to start if there are any changes,
|
||||
// and its abort command discards all changes.
|
||||
func pathsToDiscardBeforeAbort(status models.WorkingTreeState, files []*models.File, submoduleConfigs []*models.SubmoduleConfig) []string {
|
||||
if status.Effective() == models.WORKING_TREE_STATE_REBASING {
|
||||
return nil
|
||||
}
|
||||
|
||||
return lo.FilterMap(files, func(file *models.File, _ int) (string, bool) {
|
||||
return file.Path, file.HasStagedChanges && file.HasUnstagedChanges && !file.HasMergeConflicts &&
|
||||
!file.IsSubmodule(submoduleConfigs)
|
||||
})
|
||||
}
|
||||
|
||||
// AbortSquashMergeIfNoChangesAreLeft aborts a squash merge in progress if there
|
||||
// are no changes left, for example because the user discarded them. Otherwise,
|
||||
// SQUASH_MSG would keep the squash merge in progress until the next commit,
|
||||
// and that commit would get its message. Untracked files don't count, because
|
||||
// a squash merge stages the files that it adds.
|
||||
func (self *MergeAndRebaseHelper) AbortSquashMergeIfNoChangesAreLeft() error {
|
||||
if self.c.Git().Status.WorkingTreeState().Effective() != models.WORKING_TREE_STATE_SQUASH_MERGING {
|
||||
return nil
|
||||
}
|
||||
|
||||
if lo.SomeBy(self.c.Model().Files, func(file *models.File) bool { return file.Tracked || file.HasStagedChanges }) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.genericMergeCommand(REBASE_OPTION_ABORT)
|
||||
}
|
||||
|
||||
// PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
|
||||
@@ -611,15 +687,24 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e
|
||||
}
|
||||
}
|
||||
|
||||
// Git refuses to merge while there are staged changes, but not if it can
|
||||
// fast-forward the squash merge. It would add the changes of the branch to
|
||||
// the staged ones then, and we couldn't tell them apart anymore.
|
||||
var squashMergeDisabledReason *types.DisabledReason
|
||||
if AnyStagedFiles(self.c.Model().Files) {
|
||||
squashMergeDisabledReason = &types.DisabledReason{Text: self.c.Tr.CantSquashMergeWithStagedChanges}
|
||||
}
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.Merge,
|
||||
Items: []*types.MenuItem{
|
||||
firstRegularMergeItem,
|
||||
secondRegularMergeItem,
|
||||
{
|
||||
Label: self.c.Tr.SquashMergeUncommitted,
|
||||
OnPress: self.SquashMergeUncommitted(refName),
|
||||
Keys: menuKey('s'),
|
||||
Label: self.c.Tr.SquashMergeUncommitted,
|
||||
OnPress: self.SquashMergeUncommitted(refName, checkedOutBranchName),
|
||||
DisabledReason: squashMergeDisabledReason,
|
||||
Keys: menuKey('s'),
|
||||
Tooltip: utils.ResolvePlaceholderString(
|
||||
self.c.Tr.SquashMergeUncommittedTooltip,
|
||||
map[string]string{
|
||||
@@ -628,9 +713,10 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e
|
||||
),
|
||||
},
|
||||
{
|
||||
Label: self.c.Tr.SquashMergeCommitted,
|
||||
OnPress: self.SquashMergeCommitted(refName, checkedOutBranchName),
|
||||
Keys: menuKey('S'),
|
||||
Label: self.c.Tr.SquashMergeCommitted,
|
||||
OnPress: self.SquashMergeCommitted(refName, checkedOutBranchName),
|
||||
DisabledReason: squashMergeDisabledReason,
|
||||
Keys: menuKey('S'),
|
||||
Tooltip: utils.ResolvePlaceholderString(
|
||||
self.c.Tr.SquashMergeCommittedTooltip,
|
||||
map[string]string{
|
||||
@@ -653,12 +739,18 @@ func (self *MergeAndRebaseHelper) RegularMerge(refName string, variant git_comma
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MergeAndRebaseHelper) SquashMergeUncommitted(refName string) func() error {
|
||||
func (self *MergeAndRebaseHelper) SquashMergeUncommitted(refName, checkedOutBranchName string) func() error {
|
||||
return func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.SquashMerge)
|
||||
return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error {
|
||||
err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH)
|
||||
return self.CheckMergeOrRebase(err)
|
||||
err := self.c.Git().Branch.SquashMerge(refName, self.squashMergeMessage(refName, checkedOutBranchName))
|
||||
err = self.CheckMergeOrRebase(err)
|
||||
|
||||
// The user asked us not to commit the changes, so don't offer to
|
||||
// once they have resolved the conflicts
|
||||
self.c.State().GetRepoState().SetMergeOrRebaseStartedInLazygit(false)
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -667,24 +759,22 @@ func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranch
|
||||
return func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.SquashMerge)
|
||||
return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error {
|
||||
err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH)
|
||||
if err = self.CheckMergeOrRebase(err); err != nil {
|
||||
return err
|
||||
err := self.c.Git().Branch.SquashMerge(refName, self.squashMergeMessage(refName, checkedOutBranchName))
|
||||
if err == nil {
|
||||
err = self.c.Git().Rebase.GenericMergeOrRebaseAction(models.WORKING_TREE_STATE_SQUASH_MERGING, REBASE_OPTION_CONTINUE)
|
||||
}
|
||||
message := utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{
|
||||
"selectedRef": refName,
|
||||
"currentBranch": checkedOutBranchName,
|
||||
})
|
||||
err = self.c.Git().Commit.CommitCmdObj(message, "", false).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return nil
|
||||
return self.CheckMergeOrRebaseAndSelectHeadCommit(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MergeAndRebaseHelper) squashMergeMessage(refName, checkedOutBranchName string) string {
|
||||
return utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{
|
||||
"selectedRef": refName,
|
||||
"currentBranch": checkedOutBranchName,
|
||||
})
|
||||
}
|
||||
|
||||
// Returns wantsFastForward, wantsNonFastForward. These will never both be true, but they can both be false.
|
||||
func (self *MergeAndRebaseHelper) fastForwardMergeUserPreference() (bool, bool) {
|
||||
// Check user config first, because it takes precedence over git config
|
||||
|
||||
@@ -237,6 +237,9 @@ func (self *WorkingTreeHelper) HandleCommitPress() error {
|
||||
var initialMessage string
|
||||
preservedMessage := self.c.Contexts().CommitMessage.GetPreservedMessageAndLogError()
|
||||
if preservedMessage == "" {
|
||||
initialMessage = self.c.Git().Status.SquashMergeMessage()
|
||||
}
|
||||
if preservedMessage == "" && initialMessage == "" {
|
||||
commitPrefixConfigs := self.commitPrefixConfigsForRepo()
|
||||
for _, commitPrefixConfig := range commitPrefixConfigs {
|
||||
prefixPattern := commitPrefixConfig.Pattern
|
||||
|
||||
@@ -304,6 +304,7 @@ type TranslationSet struct {
|
||||
SquashMergeUncommittedTooltip string
|
||||
SquashMergeCommitted string
|
||||
SquashMergeCommittedTooltip string
|
||||
CantSquashMergeWithStagedChanges string
|
||||
ConfirmQuit string
|
||||
SwitchRepo string
|
||||
AllBranchesLogGraph string
|
||||
@@ -891,6 +892,7 @@ type TranslationSet struct {
|
||||
GitCommandFailed string
|
||||
AbortOperationTitle string
|
||||
AbortOperationPrompt string
|
||||
AbortOperationDiscardsChangesPrompt string
|
||||
OpenLogMenu string
|
||||
OpenLogMenuTooltip string
|
||||
LogMenuTitle string
|
||||
@@ -1533,6 +1535,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
SquashMergeUncommittedTooltip: "Squash merge '{{.selectedBranch}}' into the working tree.",
|
||||
SquashMergeCommitted: "Squash merge and commit",
|
||||
SquashMergeCommittedTooltip: "Squash merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}' as a single commit.",
|
||||
CantSquashMergeWithStagedChanges: "Cannot squash merge while there are staged changes",
|
||||
ConfirmQuit: `Are you sure you want to quit?`,
|
||||
SwitchRepo: `Switch to a recent repo`,
|
||||
AllBranchesLogGraph: `Show/cycle all branch logs`,
|
||||
@@ -2124,6 +2127,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
GitCommandFailed: "Git command failed. Check command log for details (open with %s)",
|
||||
AbortOperationTitle: "Abort {{.operation}}",
|
||||
AbortOperationPrompt: "Are you sure you want to abort {{.operation}}?",
|
||||
AbortOperationDiscardsChangesPrompt: "Aborting {{.operation}} also discards the unstaged changes of these files. Are you sure?",
|
||||
OpenLogMenu: "View log options",
|
||||
OpenLogMenuTooltip: "View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph.",
|
||||
LogMenuTitle: "Commit Log Options",
|
||||
|
||||
@@ -60,5 +60,7 @@ var SquashMerge = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
t.Views().Files().Focus().Lines(
|
||||
Contains("work"),
|
||||
)
|
||||
|
||||
t.Views().Status().Content(Equals("(merging) repo → original-branch"))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package branch
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
|
||||
)
|
||||
|
||||
var SquashMergeAbortAfterConflicts = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Squash merge and commit a branch, and abort when there are conflicts",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Git.LocalBranchSortOrder = "recency"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shared.MergeConflictsSetup(shell)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("first-change-branch").IsSelected(),
|
||||
Contains("second-change-branch"),
|
||||
Contains("original-branch"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.MergeIntoCurrentBranch)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Merge")).
|
||||
Select(Contains("Squash merge and commit")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Conflicts!")).
|
||||
Select(Contains("Abort the merge")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Status().Content(Equals("repo → first-change-branch"))
|
||||
|
||||
t.Views().Files().IsEmpty()
|
||||
|
||||
t.Views().Commits().
|
||||
TopLines(
|
||||
Contains("first change"),
|
||||
Contains("original"),
|
||||
)
|
||||
|
||||
t.FileSystem().PathNotPresent(".git/SQUASH_MSG")
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
package branch
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
|
||||
)
|
||||
|
||||
var SquashMergeContinueAfterConflicts = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Squash merge and commit a branch, and continue after resolving the conflicts",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Git.LocalBranchSortOrder = "recency"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shared.MergeConflictsSetup(shell)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("first-change-branch").IsSelected(),
|
||||
Contains("second-change-branch"),
|
||||
Contains("original-branch"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.MergeIntoCurrentBranch)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Merge")).
|
||||
Select(Contains("Squash merge and commit")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Conflicts!")).
|
||||
Select(Contains("View conflicts")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Status().Content(Equals("(merging) repo → first-change-branch"))
|
||||
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("UU file").IsSelected(),
|
||||
).
|
||||
Tap(func() {
|
||||
t.Shell().UpdateFile("file", "resolved content")
|
||||
}).
|
||||
Press(keys.Universal.Refresh)
|
||||
|
||||
t.Common().ContinueOnConflictsResolved("merge")
|
||||
|
||||
t.Views().Status().Content(Equals("repo → first-change-branch"))
|
||||
|
||||
t.Views().Files().IsEmpty()
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("Squash merge second-change-branch into first-change-branch").IsSelected(),
|
||||
Contains("first change"),
|
||||
Contains("original"),
|
||||
Contains("three"),
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
t.FileSystem().PathNotPresent(".git/SQUASH_MSG")
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
package branch
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
|
||||
)
|
||||
|
||||
var SquashMergeUncommittedWithConflicts = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Squash merge a branch without committing, resolve the conflicts, and continue the merge later",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Git.LocalBranchSortOrder = "recency"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shared.MergeConflictsSetup(shell)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("first-change-branch").IsSelected(),
|
||||
Contains("second-change-branch"),
|
||||
Contains("original-branch"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.MergeIntoCurrentBranch)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Merge")).
|
||||
Select(Contains("Squash merge and leave uncommitted")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Conflicts!")).
|
||||
Select(Contains("View conflicts")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("UU file").IsSelected(),
|
||||
).
|
||||
Tap(func() {
|
||||
t.Shell().UpdateFile("file", "resolved content")
|
||||
}).
|
||||
Press(keys.Universal.Refresh)
|
||||
|
||||
// The user asked not to commit, so we don't offer to continue the merge
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("M file"),
|
||||
)
|
||||
|
||||
t.Views().Status().Content(Equals("(merging) repo → first-change-branch"))
|
||||
|
||||
t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu)
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Merge options")).
|
||||
Select(Contains("continue")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Status().Content(Equals("repo → first-change-branch"))
|
||||
|
||||
t.Views().Files().IsEmpty()
|
||||
|
||||
t.Views().Commits().
|
||||
TopLines(
|
||||
Contains("Squash merge second-change-branch into first-change-branch"),
|
||||
Contains("first change"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
package branch
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var SquashMergeWithStagedChanges = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Try to squash merge a branch while there are staged changes",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.NewBranch("original-branch").
|
||||
EmptyCommit("one").
|
||||
NewBranch("feature-branch").
|
||||
CreateFileAndAdd("feature-file", "content").
|
||||
Commit("feature").
|
||||
Checkout("original-branch").
|
||||
CreateFileAndAdd("staged-file", "content")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("original-branch").IsSelected(),
|
||||
Contains("feature-branch"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.MergeIntoCurrentBranch)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Merge")).
|
||||
Select(Contains("Squash merge and commit")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectToast(Equals("Disabled: Cannot squash merge while there are staged changes"))
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Merge")).
|
||||
Select(Contains("Squash merge and leave uncommitted")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectToast(Equals("Disabled: Cannot squash merge while there are staged changes"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
package cherry_pick
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var AbortCherryPickAfterEditingPickedFile = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Abort a cherry-pick that stopped with conflicts after editing a file that it picked without conflicts",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.
|
||||
CreateFileAndAdd("conflicting-file", "original\n").
|
||||
CreateFileAndAdd("picked-file", "original\n").
|
||||
Commit("original").
|
||||
NewBranch("feature").
|
||||
UpdateFileAndAdd("conflicting-file", "feature\n").
|
||||
UpdateFileAndAdd("picked-file", "feature\n").
|
||||
Commit("feature change").
|
||||
Checkout("master").
|
||||
UpdateFileAndAdd("conflicting-file", "master\n").
|
||||
Commit("master change").
|
||||
RunCommandExpectError([]string{"git", "cherry-pick", "feature"}).
|
||||
UpdateFile("picked-file", "feature\nfix the build\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Status().Content(Equals("(cherry-picking) repo → master"))
|
||||
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Equals("UU conflicting-file").IsSelected(),
|
||||
)
|
||||
|
||||
t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu)
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Cherry-pick options")).
|
||||
Select(Contains("abort")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Confirmation().
|
||||
Title(Equals("Abort the cherry-pick")).
|
||||
Content(Equals("Aborting the cherry-pick also discards the unstaged changes of these files. Are you sure?\n\n- picked-file")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Status().Content(Equals("repo → master"))
|
||||
|
||||
t.Views().Files().IsEmpty()
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("master change"),
|
||||
Contains("original"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
package commit
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var CancelCommitDuringSquashMerge = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Open the commit panel during a squash merge started outside lazygit, and close it without changing git's message",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("one").
|
||||
NewBranch("feature-branch").
|
||||
CreateFileAndAdd("feature-file", "content").
|
||||
Commit("feature").
|
||||
Checkout("master").
|
||||
RunCommand([]string{"git", "merge", "--squash", "feature-branch"})
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Lines(
|
||||
Equals("A feature-file"),
|
||||
).
|
||||
Press(keys.Files.CommitChanges)
|
||||
|
||||
t.ExpectPopup().CommitMessagePanel().
|
||||
InitialText(Equals("Squashed commit of the following:")).
|
||||
Cancel()
|
||||
|
||||
t.FileSystem().PathNotPresent(".git/LAZYGIT_PENDING_COMMIT")
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
package commit
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var CommitDuringSquashMerge = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Commit the changes of a squash merge that was left uncommitted, using the squash merge message",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Git.CommitPrefix = []config.CommitPrefixConfig{{Pattern: "^(\\w+)-branch$", Replace: "[$1] "}}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.NewBranch("original-branch").
|
||||
EmptyCommit("one").
|
||||
NewBranch("feature-branch").
|
||||
CreateFileAndAdd("feature-file", "content").
|
||||
Commit("feature").
|
||||
Checkout("original-branch")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("original-branch").IsSelected(),
|
||||
Contains("feature-branch"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.MergeIntoCurrentBranch)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Merge")).
|
||||
Select(Contains("Squash merge and leave uncommitted")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Lines(
|
||||
Equals("A feature-file"),
|
||||
).
|
||||
Press(keys.Files.CommitChanges)
|
||||
|
||||
t.ExpectPopup().CommitMessagePanel().
|
||||
Title(Equals("Commit summary")).
|
||||
InitialText(Equals("Squash merge feature-branch into original-branch")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Status().Content(Equals("repo → original-branch"))
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("Squash merge feature-branch into original-branch"),
|
||||
Contains("one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
package conflicts
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var AbortMergeAfterEditingMergedFile = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Abort a merge that stopped with conflicts after editing a file that it merged without conflicts",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.
|
||||
CreateFileAndAdd("conflicting-file", "original\n").
|
||||
CreateFileAndAdd("merged-file", "original\n").
|
||||
CreateFileAndAdd("unrelated-file", "original\n").
|
||||
Commit("original").
|
||||
NewBranch("feature").
|
||||
UpdateFileAndAdd("conflicting-file", "feature\n").
|
||||
UpdateFileAndAdd("merged-file", "feature\n").
|
||||
Commit("feature change").
|
||||
Checkout("master").
|
||||
UpdateFileAndAdd("conflicting-file", "master\n").
|
||||
Commit("master change").
|
||||
UpdateFile("unrelated-file", "unrelated change\n").
|
||||
RunCommandExpectError([]string{"git", "merge", "--no-edit", "feature"}).
|
||||
UpdateFile("merged-file", "feature\nfix the build\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Status().Content(Equals("(merging) repo → master"))
|
||||
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Equals("UU conflicting-file").IsSelected(),
|
||||
)
|
||||
|
||||
t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu)
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Merge options")).
|
||||
Select(Contains("abort")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Confirmation().
|
||||
Title(Equals("Abort the merge")).
|
||||
Content(Equals("Aborting the merge also discards the unstaged changes of these files. Are you sure?\n\n- merged-file")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Status().Content(Equals("repo → master"))
|
||||
|
||||
t.Views().Files().
|
||||
Lines(
|
||||
Equals(" M unrelated-file"),
|
||||
)
|
||||
|
||||
t.FileSystem().FileContent("merged-file", Equals("original\n"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var DiscardAllChangesOfSquashMerge = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Discard all changes of a squash merge that was left uncommitted, which ends the squash merge",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.NewBranch("original-branch").
|
||||
CreateFileAndAdd("file", "original\n").
|
||||
Commit("one").
|
||||
NewBranch("feature-branch").
|
||||
UpdateFileAndAdd("file", "feature\n").
|
||||
CreateFileAndAdd("feature-file", "content").
|
||||
Commit("feature").
|
||||
Checkout("original-branch").
|
||||
CreateFile("untracked-file", "content")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("original-branch").IsSelected(),
|
||||
Contains("feature-branch"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.MergeIntoCurrentBranch)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Merge")).
|
||||
Select(Contains("Squash merge and leave uncommitted")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Status().Content(Equals("(merging) repo → original-branch"))
|
||||
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Lines(
|
||||
Equals("▼ /"),
|
||||
Equals(" A feature-file"),
|
||||
Equals(" M file"),
|
||||
Equals(" ?? untracked-file"),
|
||||
).
|
||||
NavigateToLine(Equals(" M file")).
|
||||
Press(keys.Universal.Remove).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Discard changes")).
|
||||
Select(Contains("Discard all changes")).
|
||||
Confirm()
|
||||
}).
|
||||
Lines(
|
||||
Equals("▼ /"),
|
||||
Equals(" A feature-file"),
|
||||
Equals(" ?? untracked-file"),
|
||||
).
|
||||
NavigateToLine(Equals(" A feature-file"))
|
||||
|
||||
// There are still changes left, so the squash merge is still in progress
|
||||
t.Views().Status().Content(Equals("(merging) repo → original-branch"))
|
||||
|
||||
t.Views().Files().
|
||||
IsFocused().
|
||||
Press(keys.Universal.Remove).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Discard changes")).
|
||||
Select(Contains("Discard all changes")).
|
||||
Confirm()
|
||||
}).
|
||||
Lines(
|
||||
Equals("?? untracked-file").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Status().Content(Equals("repo → original-branch"))
|
||||
|
||||
t.FileSystem().PathNotPresent(".git/SQUASH_MSG")
|
||||
},
|
||||
})
|
||||
@@ -110,8 +110,13 @@ var tests = []*components.IntegrationTest{
|
||||
branch.SortRemoteBranches,
|
||||
branch.SortRemoteBranchesInStackOrder,
|
||||
branch.SquashMerge,
|
||||
branch.SquashMergeAbortAfterConflicts,
|
||||
branch.SquashMergeContinueAfterConflicts,
|
||||
branch.SquashMergeUncommittedWithConflicts,
|
||||
branch.SquashMergeWithStagedChanges,
|
||||
branch.Suggestions,
|
||||
branch.UnsetUpstream,
|
||||
cherry_pick.AbortCherryPickAfterEditingPickedFile,
|
||||
cherry_pick.CherryPick,
|
||||
cherry_pick.CherryPickAcrossWorktrees,
|
||||
cherry_pick.CherryPickCommitThatBecomesEmpty,
|
||||
@@ -132,12 +137,14 @@ var tests = []*components.IntegrationTest{
|
||||
commit.AmendWhenThereAreConflictsAndCancel,
|
||||
commit.AmendWhenThereAreConflictsAndContinue,
|
||||
commit.AutoWrapMessage,
|
||||
commit.CancelCommitDuringSquashMerge,
|
||||
commit.Checkout,
|
||||
commit.CheckoutFileFromCommit,
|
||||
commit.CheckoutFileFromRangeSelectionOfCommits,
|
||||
commit.CheckoutFileWithLocalModifications,
|
||||
commit.Commit,
|
||||
commit.CommitAllWithNestedRepository,
|
||||
commit.CommitDuringSquashMerge,
|
||||
commit.CommitMultiline,
|
||||
commit.CommitSkipHooks,
|
||||
commit.CommitSwitchToEditor,
|
||||
@@ -198,6 +205,7 @@ var tests = []*components.IntegrationTest{
|
||||
config.NegativeRefspec,
|
||||
config.RemoteNamedStar,
|
||||
config.SidePanelsInPerRepoConfig,
|
||||
conflicts.AbortMergeAfterEditingMergedFile,
|
||||
conflicts.ConflictMarkerSizeNotAutoStaged,
|
||||
conflicts.ConflictMarkerSizeResolve,
|
||||
conflicts.ContinuePromptDismissedWhenResolvedExternally,
|
||||
@@ -270,6 +278,7 @@ var tests = []*components.IntegrationTest{
|
||||
file.CopyMenu,
|
||||
file.DirWithUntrackedFile,
|
||||
file.DirectoryDiffWithRenamedFiles,
|
||||
file.DiscardAllChangesOfSquashMerge,
|
||||
file.DiscardAllDirChanges,
|
||||
file.DiscardAllDirChangesWhenFiltering,
|
||||
file.DiscardNestedRepositories,
|
||||
|
||||
Reference in New Issue
Block a user