diff options
Diffstat (limited to 'pkg/gui/controllers/undo_controller.go')
-rw-r--r-- | pkg/gui/controllers/undo_controller.go | 266 |
1 files changed, 266 insertions, 0 deletions
diff --git a/pkg/gui/controllers/undo_controller.go b/pkg/gui/controllers/undo_controller.go new file mode 100644 index 000000000..984b8e1a6 --- /dev/null +++ b/pkg/gui/controllers/undo_controller.go @@ -0,0 +1,266 @@ +package controllers + +import ( + "github.com/jesseduffield/lazygit/pkg/commands" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/commands/types/enums" + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gui/popup" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" +) + +// Quick summary of how this all works: +// when you want to undo or redo, we start from the top of the reflog and work +// down until we've reached the last user-initiated reflog entry that hasn't already been undone +// we then do the reverse of what that reflog describes. +// When we do this, we create a new reflog entry, and tag it as either an undo or redo +// Then, next time we want to undo, we'll use those entries to know which user-initiated +// actions we can skip. E.g. if I do do three things, A, B, and C, and hit undo twice, +// the reflog will read UUCBA, and when I read the first two undos, I know to skip the following +// two user actions, meaning we end up undoing reflog entry C. Redoing works in a similar way. + +type UndoController struct { + c *ControllerCommon + git *commands.GitCommand + + refHelper IRefHelper + workingTreeHelper IWorkingTreeHelper + + getFilteredReflogCommits func() []*models.Commit +} + +var _ types.IController = &UndoController{} + +func NewUndoController( + c *ControllerCommon, + git *commands.GitCommand, + refHelper IRefHelper, + workingTreeHelper IWorkingTreeHelper, + + getFilteredReflogCommits func() []*models.Commit, +) *UndoController { + return &UndoController{ + c: c, + git: git, + refHelper: refHelper, + workingTreeHelper: workingTreeHelper, + + getFilteredReflogCommits: getFilteredReflogCommits, + } +} + +type ReflogActionKind int + +const ( + CHECKOUT ReflogActionKind = iota + COMMIT + REBASE + CURRENT_REBASE +) + +type reflogAction struct { + kind ReflogActionKind + from string + to string +} + +func (self *UndoController) Keybindings( + getKey func(key string) interface{}, + config config.KeybindingConfig, + guards types.KeybindingGuards, +) []*types.Binding { + bindings := []*types.Binding{ + { + Key: getKey(config.Universal.Undo), + Handler: self.reflogUndo, + Description: self.c.Tr.LcUndoReflog, + }, + { + Key: getKey(config.Universal.Redo), + Handler: self.reflogRedo, + Description: self.c.Tr.LcRedoReflog, + }, + } + + return bindings +} + +func (self *UndoController) Context() types.Context { + return nil +} + +func (self *UndoController) reflogUndo() error { + undoEnvVars := []string{"GIT_REFLOG_ACTION=[lazygit undo]"} + undoingStatus := self.c.Tr.UndoingStatus + + if self.git.Status.WorkingTreeState() == enums.REBASE_MODE_REBASING { + return self.c.ErrorMsg(self.c.Tr.LcCantUndoWhileRebasing) + } + + return self.parseReflogForActions(func(counter int, action reflogAction) (bool, error) { + if counter != 0 { + return false, nil + } + + switch action.kind { + case COMMIT, REBASE: + self.c.LogAction(self.c.Tr.Actions.Undo) + return true, self.hardResetWithAutoStash(action.from, hardResetOptions{ + EnvVars: undoEnvVars, + WaitingStatus: undoingStatus, + }) + case CHECKOUT: + self.c.LogAction(self.c.Tr.Actions.Undo) + return true, self.refHelper.CheckoutRef(action.from, types.CheckoutRefOptions{ + EnvVars: undoEnvVars, + WaitingStatus: undoingStatus, + }) + case CURRENT_REBASE: + // do nothing + } + + self.c.Log.Error("didn't match on the user action when trying to undo") + return true, nil + }) +} + +func (self *UndoController) reflogRedo() error { + redoEnvVars := []string{"GIT_REFLOG_ACTION=[lazygit redo]"} + redoingStatus := self.c.Tr.RedoingStatus + + if self.git.Status.WorkingTreeState() == enums.REBASE_MODE_REBASING { + return self.c.ErrorMsg(self.c.Tr.LcCantRedoWhileRebasing) + } + + return self.parseReflogForActions(func(counter int, action reflogAction) (bool, error) { + // if we're redoing and the counter is zero, we just return + if counter == 0 { + return true, nil + } else if counter > 1 { + return false, nil + } + + switch action.kind { + case COMMIT, REBASE: + self.c.LogAction(self.c.Tr.Actions.Redo) + return true, self.hardResetWithAutoStash(action.to, hardResetOptions{ + EnvVars: redoEnvVars, + WaitingStatus: redoingStatus, + }) + case CHECKOUT: + self.c.LogAction(self.c.Tr.Actions.Redo) + return true, self.refHelper.CheckoutRef(action.to, types.CheckoutRefOptions{ + EnvVars: redoEnvVars, + WaitingStatus: redoingStatus, + }) + case CURRENT_REBASE: + // do nothing + } + + self.c.Log.Error("didn't match on the user action when trying to redo") + return true, nil + }) +} + +// Here we're going through the reflog and maintaining a counter that represents how many +// undos/redos/user actions we've seen. when we hit a user action we call the callback specifying +// what the counter is up to and the nature of the action. +// If we find ourselves mid-rebase, we just return because undo/redo mid rebase +// requires knowledge of previous TODO file states, which you can't just get from the reflog. +// Though we might support this later, hence the use of the CURRENT_REBASE action kind. +func (self *UndoController) parseReflogForActions(onUserAction func(counter int, action reflogAction) (bool, error)) error { + counter := 0 + reflogCommits := self.getFilteredReflogCommits() + rebaseFinishCommitSha := "" + var action *reflogAction + for reflogCommitIdx, reflogCommit := range reflogCommits { + action = nil + + prevCommitSha := "" + if len(reflogCommits)-1 >= reflogCommitIdx+1 { + prevCommitSha = reflogCommits[reflogCommitIdx+1].Sha + } + + if rebaseFinishCommitSha == "" { + if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^\[lazygit undo\]`); ok { + counter++ + } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^\[lazygit redo\]`); ok { + counter-- + } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(abort\)|^rebase -i \(finish\)`); ok { + rebaseFinishCommitSha = reflogCommit.Sha + } else if ok, match := utils.FindStringSubmatch(reflogCommit.Name, `^checkout: moving from ([\S]+) to ([\S]+)`); ok { + action = &reflogAction{kind: CHECKOUT, from: match[1], to: match[2]} + } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^commit|^reset: moving to|^pull`); ok { + action = &reflogAction{kind: COMMIT, from: prevCommitSha, to: reflogCommit.Sha} + } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(start\)`); ok { + // if we're here then we must be currently inside an interactive rebase + action = &reflogAction{kind: CURRENT_REBASE, from: prevCommitSha} + } + } else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name, `^rebase -i \(start\)`); ok { + action = &reflogAction{kind: REBASE, from: prevCommitSha, to: rebaseFinishCommitSha} + rebaseFinishCommitSha = "" + } + + if action != nil { + if action.kind != CURRENT_REBASE && action.from == action.to { + // if we're going from one place to the same place we'll ignore the action. + continue + } + ok, err := onUserAction(counter, *action) + if ok { + return err + } + counter-- + } + } + return nil +} + +type hardResetOptions struct { + WaitingStatus string + EnvVars []string +} + +// only to be used in the undo flow for now (does an autostash) +func (self *UndoController) hardResetWithAutoStash(commitSha string, options hardResetOptions) error { + reset := func() error { + if err := self.refHelper.ResetToRef(commitSha, "hard", options.EnvVars); err != nil { + return self.c.Error(err) + } + return nil + } + + // if we have any modified tracked files we need to ask the user if they want us to stash for them + dirtyWorkingTree := self.workingTreeHelper.IsWorkingTreeDirty() + if dirtyWorkingTree { + // offer to autostash changes + return self.c.Ask(popup.AskOpts{ + Title: self.c.Tr.AutoStashTitle, + Prompt: self.c.Tr.AutoStashPrompt, + HandleConfirm: func() error { + return self.c.WithWaitingStatus(options.WaitingStatus, func() error { + if err := self.git.Stash.Save(self.c.Tr.StashPrefix + commitSha); err != nil { + return self.c.Error(err) + } + if err := reset(); err != nil { + return err + } + + err := self.git.Stash.Pop(0) + if err := self.c.Refresh(types.RefreshOptions{}); err != nil { + return err + } + if err != nil { + return self.c.Error(err) + } + return nil + }) + }, + }) + } + + return self.c.WithWaitingStatus(options.WaitingStatus, func() error { + return reset() + }) +} |