diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 19dba12fe..fb32baa74 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -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. diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index 675f50c8c..b0c9fe077 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -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")) }) } diff --git a/pkg/commands/git_commands/status.go b/pkg/commands/git_commands/status.go index f65990359..a01ed201c 100644 --- a/pkg/commands/git_commands/status.go +++ b/pkg/commands/git_commands/status.go @@ -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 { diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index db68d694f..14faaa3e0 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -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(`^[!#]|[\[\]*]`) diff --git a/pkg/commands/models/working_tree_state.go b/pkg/commands/models/working_tree_state.go index 2207d3a77..0bd0f860b 100644 --- a/pkg/commands/models/working_tree_state.go +++ b/pkg/commands/models/working_tree_state.go @@ -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 { diff --git a/pkg/gui/context/commit_message_context.go b/pkg/gui/context/commit_message_context.go index 7db3a085b..f6db124fe 100644 --- a/pkg/gui/context/commit_message_context.go +++ b/pkg/gui/context/commit_message_context.go @@ -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 diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index c696f0406..3037943c5 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -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 }) }, diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index 5e50ba7b2..448c451d3 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -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{}) } diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 4ce378bfe..6eb62b917 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -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 diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index a11830649..b7ce2f431 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -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 diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 7a5a6d3d8..3d4689d8a 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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", diff --git a/pkg/integration/tests/branch/squash_merge.go b/pkg/integration/tests/branch/squash_merge.go index 509073cbe..416308920 100644 --- a/pkg/integration/tests/branch/squash_merge.go +++ b/pkg/integration/tests/branch/squash_merge.go @@ -60,5 +60,7 @@ var SquashMerge = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files().Focus().Lines( Contains("work"), ) + + t.Views().Status().Content(Equals("(merging) repo → original-branch")) }, }) diff --git a/pkg/integration/tests/branch/squash_merge_abort_after_conflicts.go b/pkg/integration/tests/branch/squash_merge_abort_after_conflicts.go new file mode 100644 index 000000000..e0872493f --- /dev/null +++ b/pkg/integration/tests/branch/squash_merge_abort_after_conflicts.go @@ -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") + }, +}) diff --git a/pkg/integration/tests/branch/squash_merge_continue_after_conflicts.go b/pkg/integration/tests/branch/squash_merge_continue_after_conflicts.go new file mode 100644 index 000000000..1b6a32fab --- /dev/null +++ b/pkg/integration/tests/branch/squash_merge_continue_after_conflicts.go @@ -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") + }, +}) diff --git a/pkg/integration/tests/branch/squash_merge_uncommitted_with_conflicts.go b/pkg/integration/tests/branch/squash_merge_uncommitted_with_conflicts.go new file mode 100644 index 000000000..5cc413224 --- /dev/null +++ b/pkg/integration/tests/branch/squash_merge_uncommitted_with_conflicts.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/branch/squash_merge_with_staged_changes.go b/pkg/integration/tests/branch/squash_merge_with_staged_changes.go new file mode 100644 index 000000000..0e2b70413 --- /dev/null +++ b/pkg/integration/tests/branch/squash_merge_with_staged_changes.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/cherry_pick/abort_cherry_pick_after_editing_picked_file.go b/pkg/integration/tests/cherry_pick/abort_cherry_pick_after_editing_picked_file.go new file mode 100644 index 000000000..11363d80c --- /dev/null +++ b/pkg/integration/tests/cherry_pick/abort_cherry_pick_after_editing_picked_file.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/commit/cancel_commit_during_squash_merge.go b/pkg/integration/tests/commit/cancel_commit_during_squash_merge.go new file mode 100644 index 000000000..a6867acff --- /dev/null +++ b/pkg/integration/tests/commit/cancel_commit_during_squash_merge.go @@ -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") + }, +}) diff --git a/pkg/integration/tests/commit/commit_during_squash_merge.go b/pkg/integration/tests/commit/commit_during_squash_merge.go new file mode 100644 index 000000000..9edf8d4e7 --- /dev/null +++ b/pkg/integration/tests/commit/commit_during_squash_merge.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/conflicts/abort_merge_after_editing_merged_file.go b/pkg/integration/tests/conflicts/abort_merge_after_editing_merged_file.go new file mode 100644 index 000000000..bd40103d4 --- /dev/null +++ b/pkg/integration/tests/conflicts/abort_merge_after_editing_merged_file.go @@ -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")) + }, +}) diff --git a/pkg/integration/tests/file/discard_all_changes_of_squash_merge.go b/pkg/integration/tests/file/discard_all_changes_of_squash_merge.go new file mode 100644 index 000000000..4cd39e119 --- /dev/null +++ b/pkg/integration/tests/file/discard_all_changes_of_squash_merge.go @@ -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") + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index f2369036f..ee1ca9a91 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -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,