Improve undo action to restore files upon undoing a commit (#4167)

- **PR Description**

Right now, undoing a commit performs a hard reset, which also discards
all the changes from that commit. This PR adds new config options (and a
new `undo` section) which allow users to choose between `hard` and
`soft` reset modes when undoing commits.

Personally, I think that the default should be `soft`, because the state
before the commit had the files, so undoing a commit should put the
files where they were before. But this PR keeps `hard` as the default
and does not change current behavior.

- **Please check if the PR fulfills these requirements**

* [x] Cheatsheets are up-to-date (run `go generate ./...`)
* [x] Code has been formatted (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting))
* [x] Tests have been added/updated (see
[here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
for the integration test guide)
* [x] Text is internationalised (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation))
* [x] If a new UserConfig entry was added, make sure it can be
hot-reloaded (see
[here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig))
* [x] Docs have been updated if necessary
* [x] You've read through your own file changes for silly mistakes etc

<!--
Be sure to name your PR with an imperative e.g. 'Add worktrees view'
see https://github.com/jesseduffield/lazygit/releases/tag/v0.40.0 for
examples
-->
This commit is contained in:
Jesse Duffield 2025-01-18 00:17:12 +11:00 committed by GitHub
commit ab7b5f6d84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 144 additions and 26 deletions

View file

@ -88,7 +88,20 @@ func (self *UndoController) reflogUndo() error {
} }
switch action.kind { switch action.kind {
case COMMIT, REBASE: case COMMIT:
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.Actions.Undo,
Prompt: fmt.Sprintf(self.c.Tr.SoftResetPrompt, action.from),
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.Undo)
return self.c.WithWaitingStatus(undoingStatus, func(gocui.Task) error {
return self.c.Helpers().Refs.ResetToRef(action.from, "soft", undoEnvVars)
})
},
})
return true, nil
case REBASE:
self.c.Confirm(types.ConfirmOpts{ self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.Actions.Undo, Title: self.c.Tr.Actions.Undo,
Prompt: fmt.Sprintf(self.c.Tr.HardResetAutostashPrompt, action.from), Prompt: fmt.Sprintf(self.c.Tr.HardResetAutostashPrompt, action.from),
@ -105,7 +118,7 @@ func (self *UndoController) reflogUndo() error {
case CHECKOUT: case CHECKOUT:
self.c.Confirm(types.ConfirmOpts{ self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.Actions.Undo, Title: self.c.Tr.Actions.Undo,
Prompt: fmt.Sprintf(self.c.Tr.CheckoutPrompt, action.from), Prompt: fmt.Sprintf(self.c.Tr.CheckoutAutostashPrompt, action.from),
HandleConfirm: func() error { HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.Undo) self.c.LogAction(self.c.Tr.Actions.Undo)
return self.c.Helpers().Refs.CheckoutRef(action.from, types.CheckoutRefOptions{ return self.c.Helpers().Refs.CheckoutRef(action.from, types.CheckoutRefOptions{
@ -159,7 +172,7 @@ func (self *UndoController) reflogRedo() error {
case CHECKOUT: case CHECKOUT:
self.c.Confirm(types.ConfirmOpts{ self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.Actions.Redo, Title: self.c.Tr.Actions.Redo,
Prompt: fmt.Sprintf(self.c.Tr.CheckoutPrompt, action.to), Prompt: fmt.Sprintf(self.c.Tr.CheckoutAutostashPrompt, action.to),
HandleConfirm: func() error { HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.Redo) self.c.LogAction(self.c.Tr.Actions.Redo)
return self.c.Helpers().Refs.CheckoutRef(action.to, types.CheckoutRefOptions{ return self.c.Helpers().Refs.CheckoutRef(action.to, types.CheckoutRefOptions{
@ -244,31 +257,23 @@ func (self *UndoController) hardResetWithAutoStash(commitHash string, options ha
return self.c.Helpers().Refs.ResetToRef(commitHash, "hard", options.EnvVars) return self.c.Helpers().Refs.ResetToRef(commitHash, "hard", options.EnvVars)
} }
// if we have any modified tracked files we need to ask the user if they want us to stash for them // if we have any modified tracked files we need to auto-stash
dirtyWorkingTree := self.c.Helpers().WorkingTree.IsWorkingTreeDirty() dirtyWorkingTree := self.c.Helpers().WorkingTree.IsWorkingTreeDirty()
if dirtyWorkingTree { if dirtyWorkingTree {
// offer to autostash changes return self.c.WithWaitingStatus(options.WaitingStatus, func(gocui.Task) error {
self.c.Confirm(types.ConfirmOpts{ if err := self.c.Git().Stash.Push(self.c.Tr.StashPrefix + commitHash); err != nil {
Title: self.c.Tr.AutoStashTitle, return err
Prompt: self.c.Tr.AutoStashPrompt, }
HandleConfirm: func() error { if err := reset(); err != nil {
return self.c.WithWaitingStatus(options.WaitingStatus, func(gocui.Task) error { return err
if err := self.c.Git().Stash.Push(self.c.Tr.StashPrefix + commitHash); err != nil { }
return err
}
if err := reset(); err != nil {
return err
}
err := self.c.Git().Stash.Pop(0) err := self.c.Git().Stash.Pop(0)
if err != nil { if err != nil {
return err return err
} }
return self.c.Refresh(types.RefreshOptions{}) return self.c.Refresh(types.RefreshOptions{})
})
},
}) })
return nil
} }
return self.c.WithWaitingStatus(options.WaitingStatus, func(gocui.Task) error { return self.c.WithWaitingStatus(options.WaitingStatus, func(gocui.Task) error {

View file

@ -732,8 +732,9 @@ type TranslationSet struct {
ConfirmRevertCommit string ConfirmRevertCommit string
RewordInEditorTitle string RewordInEditorTitle string
RewordInEditorPrompt string RewordInEditorPrompt string
CheckoutPrompt string CheckoutAutostashPrompt string
HardResetAutostashPrompt string HardResetAutostashPrompt string
SoftResetPrompt string
UpstreamGone string UpstreamGone string
NukeDescription string NukeDescription string
DiscardStagedChangesDescription string DiscardStagedChangesDescription string
@ -1745,7 +1746,8 @@ func EnglishTranslationSet() *TranslationSet {
RewordInEditorTitle: "Reword in editor", RewordInEditorTitle: "Reword in editor",
RewordInEditorPrompt: "Are you sure you want to reword this commit in your editor?", RewordInEditorPrompt: "Are you sure you want to reword this commit in your editor?",
HardResetAutostashPrompt: "Are you sure you want to hard reset to '%s'? An auto-stash will be performed if necessary.", HardResetAutostashPrompt: "Are you sure you want to hard reset to '%s'? An auto-stash will be performed if necessary.",
CheckoutPrompt: "Are you sure you want to checkout '%s'?", SoftResetPrompt: "Are you sure you want to soft reset to '%s'?",
CheckoutAutostashPrompt: "Are you sure you want to checkout '%s'? An auto-stash will be performed if necessary.",
UpstreamGone: "(upstream gone)", UpstreamGone: "(upstream gone)",
NukeDescription: "If you want to make all the changes in the worktree go away, this is the way to do it. If there are dirty submodule changes this will stash those changes in the submodule(s).", NukeDescription: "If you want to make all the changes in the worktree go away, this is the way to do it. If there are dirty submodule changes this will stash those changes in the submodule(s).",
DiscardStagedChangesDescription: "This will create a new stash entry containing only staged files and then drop it, so that the working tree is left with only unstaged changes", DiscardStagedChangesDescription: "This will create a new stash entry containing only staged files and then drop it, so that the working tree is left with only unstaged changes",

View file

@ -367,6 +367,7 @@ var tests = []*components.IntegrationTest{
ui.SwitchTabFromMenu, ui.SwitchTabFromMenu,
ui.SwitchTabWithPanelJumpKeys, ui.SwitchTabWithPanelJumpKeys,
undo.UndoCheckoutAndDrop, undo.UndoCheckoutAndDrop,
undo.UndoCommit,
undo.UndoDrop, undo.UndoDrop,
worktree.AddFromBranch, worktree.AddFromBranch,
worktree.AddFromBranchDetached, worktree.AddFromBranchDetached,

View file

@ -0,0 +1,110 @@
package undo
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var UndoCommit = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Undo/redo a commit",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("other-file", "other-file-1")
shell.Commit("one")
shell.CreateFileAndAdd("file", "file-1")
shell.Commit("two")
shell.UpdateFile("other-file", "other-file-2")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
confirmUndo := func() {
t.ExpectPopup().Confirmation().
Title(Equals("Undo")).
Content(MatchesRegexp(`Are you sure you want to soft reset to '.*'\?`)).
Confirm()
}
confirmRedo := func() {
t.ExpectPopup().Confirmation().
Title(Equals("Redo")).
Content(MatchesRegexp(`Are you sure you want to hard reset to '.*'\? An auto-stash will be performed if necessary\.`)).
Confirm()
}
confirmDiscardFile := func() {
t.ExpectPopup().Menu().
Title(Equals("Discard changes")).
Select(Contains("Discard all changes")).
Confirm()
}
t.Views().Files().
Lines(
Contains(" M other-file"),
)
t.Views().Commits().Focus().
Lines(
Contains("two").IsSelected(),
Contains("one"),
).
Press(keys.Universal.Undo).
Tap(confirmUndo).
Lines(
Contains("one").IsSelected(),
)
t.Views().Files().
Lines(
Contains("A file"),
Contains(" M other-file"),
)
t.Views().Commits().Focus().
Press(keys.Universal.Redo).
Tap(confirmRedo).
Lines(
Contains("two").IsSelected(),
Contains("one"),
)
t.Views().Files().
Lines(
Contains(" M other-file"),
)
// Undo again, this time discarding the original change before redoing again
t.Views().Commits().Focus().
Press(keys.Universal.Undo).
Tap(confirmUndo).
Lines(
Contains("one").IsSelected(),
)
t.Views().Files().Focus().
Lines(
Contains("A file"),
Contains(" M other-file").IsSelected(),
).
Press(keys.Universal.PrevItem).
Press(keys.Universal.Remove).
Tap(confirmDiscardFile).
Lines(
Contains(" M other-file"),
).
Press(keys.Universal.Redo).
Tap(confirmRedo)
t.Views().Commits().
Lines(
Contains("two"),
Contains("one"),
)
t.Views().Files().
Lines(
Contains(" M other-file"),
)
},
})