Discard edits on top of staged changes when aborting a merge or cherry-pick

If a merge, cherry-pick, revert or "git am" stops with conflicts, users
often edit the files that git changed, for example to fix the build. If
they then decide to give up and abort, git fails with "Entry 'file' not
uptodate. Cannot merge." It keeps unstaged changes when aborting, but it
can't reset the staged changes of a file underneath them. "git am
--abort" even stops halfway, after it has reset the conflicted files.

Git refuses to start any of these operations if there are staged
changes, or if a file that it has to change has unstaged changes. So
unstaged changes on top of staged ones were made after git stopped.
Discard them before aborting, but ask the user first, listing the
files. A rebase doesn't need this, because "git rebase --abort"
discards all changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-11-08 15:36:41 +01:00
co-authored by Claude Opus 5.5
parent acb54f73d1
commit c7002e5e52
6 changed files with 199 additions and 16 deletions
@@ -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(`^[!#]|[\[\]*]`)
@@ -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.
@@ -141,7 +145,8 @@ 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)
return self.CheckMergeOrRebaseWithRefreshOptions(result,
@@ -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,71 @@ 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. So
// 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)
})
}
// PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
+2
View File
@@ -892,6 +892,7 @@ type TranslationSet struct {
GitCommandFailed string
AbortOperationTitle string
AbortOperationPrompt string
AbortOperationDiscardsChangesPrompt string
OpenLogMenu string
OpenLogMenuTooltip string
LogMenuTitle string
@@ -2126,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",
@@ -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,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"))
},
})
+2
View File
@@ -113,6 +113,7 @@ var tests = []*components.IntegrationTest{
branch.SquashMergeWithStagedChanges,
branch.Suggestions,
branch.UnsetUpstream,
cherry_pick.AbortCherryPickAfterEditingPickedFile,
cherry_pick.CherryPick,
cherry_pick.CherryPickAcrossWorktrees,
cherry_pick.CherryPickCommitThatBecomesEmpty,
@@ -199,6 +200,7 @@ var tests = []*components.IntegrationTest{
config.NegativeRefspec,
config.RemoteNamedStar,
config.SidePanelsInPerRepoConfig,
conflicts.AbortMergeAfterEditingMergedFile,
conflicts.ConflictMarkerSizeNotAutoStaged,
conflicts.ConflictMarkerSizeResolve,
conflicts.ContinuePromptDismissedWhenResolvedExternally,