How Lazygit Implements Its Undo/Redo System Using the Git Reflog
Lazygit implements undo and redo by walking the git reflog to identify user-initiated actions, using counter logic to skip previous undo markers, and executing inverse git commands to revert commits, checkouts, and rebases.
The lazygit undo redo system transforms the git reflog from a passive audit log into an interactive history navigation tool. In the jesseduffield/lazygit repository, the implementation centers on the UndoController which parses reflog entries to distinguish between user actions and system-generated markers. This design allows developers to revert complex operations without manually calculating commit hashes or understanding git plumbing commands.
Registering the Undo and Redo Keybindings
The user interface wires the u and Ctrl-r keys to reflog-based handlers inside pkg/gui/controllers/undo_controller.go. The GetKeybindings method registers these actions with descriptive tooltips for the UI.
func (self *UndoController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{
{
Key: opts.GetKey(opts.Config.Universal.Undo), // default "u"
Handler: self.reflogUndo,
Description: self.c.Tr.UndoReflog,
Tooltip: self.c.Tr.UndoTooltip,
},
{
Key: opts.GetKey(opts.Config.Universal.Redo), // default "Ctrl-r"
Handler: self.reflogRedo,
Description: self.c.Tr.RedoReflog,
Tooltip: self.c.Tr.RedoTooltip,
},
}
return bindings
}
When triggered, reflogUndo and reflogRedo delegate to parseReflogForActions, the core parser that implements the lazygit reflog undo redo logic.
Parsing the Git Reflog to Identify Reversible Actions
The heart of the system lives in parseReflogForActions (lines 200–246 of undo_controller.go). This function iterates over self.c.Model().ReflogCommits—an in-memory slice populated by the reflog loader—and classifies each entry using regex patterns on the reflog message.
Detecting User Actions vs. System Markers
The parser distinguishes between genuine user operations and lazygit-generated entries by matching against specific patterns:
- Checkout detection:
^checkout: moving from ([\S]+) to ([\S]+)creates aCHECKOUTaction tracking the source and destination refs - Commit detection:
^commit|^reset: moving to|^pullcreates aCOMMITaction using the current hash and the previous entry's hash - Rebase detection: Complex patterns identify
REBASEoperations by correlatingrebase (finish)markers with subsequentrebase (start)entries - System markers:
^\[lazygit undo\]and^\[lazygit redo\]entries are injected by lazygit itself via theGIT_REFLOG_ACTIONenvironment variable to track previous undo/redo cycles
The Counter Logic That Tracks Undo State
A counter variable tracks how many undo operations must be skipped before finding the next reversible user action. When reflogUndo runs, the counter increments for every [lazygit undo] marker encountered; when it reaches zero, the parser has found the most recent user action that has not yet been undone.
func (self *UndoController) parseReflogForActions(
onUserAction func(counter int, action reflogAction) (bool, error),
) error {
counter := 0
reflogCommits := self.c.Model().ReflogCommits
rebaseFinishCommitHash := ""
var action *reflogAction
for reflogCommitIdx, reflogCommit := range reflogCommits {
action = nil
prevCommitHash := ""
if len(reflogCommits)-1 >= reflogCommitIdx+1 {
prevCommitHash = reflogCommits[reflogCommitIdx+1].Hash()
}
if rebaseFinishCommitHash == "" {
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 {
rebaseFinishCommitHash = reflogCommit.Hash()
} 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: prevCommitHash, to: reflogCommit.Hash()}
} else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name,
`^rebase (-i )?\(start\)`); ok {
action = &reflogAction{kind: CURRENT_REBASE, from: prevCommitHash}
}
} else if ok, _ := utils.FindStringSubmatch(reflogCommit.Name,
`^rebase (-i )?\(start\)`); ok {
action = &reflogAction{kind: REBASE, from: prevCommitHash, to: rebaseFinishCommitHash}
rebaseFinishCommitHash = ""
}
if action != nil {
if action.kind != CURRENT_REBASE && action.from == action.to {
continue
}
ok, err := onUserAction(counter, *action)
if ok {
return err
}
counter--
}
}
return nil
}
When the callback returns true, the iteration stops and the controller executes the inverse command.
Loading Reflog Entries Efficiently
The ReflogCommitLoader in pkg/commands/git_commands/reflog_commit_loader.go (lines 25–63) populates the model by executing git log -g --format=+%H%x00%ct%x00%gs%x00%P. The -g flag instructs git to traverse reflog entries rather than the commit graph.
func (self *ReflogCommitLoader) GetReflogCommits(
hashPool *utils.StringPool,
lastReflogCommit *models.Commit,
filterPath string,
filterAuthor string,
) ([]*models.Commit, bool, error) {
cmdArgs := NewGitCmd("log").
Config("log.showSignature=false").
Arg("-g").
Arg("--format=+%H%x00%ct%x00%gs%x00%P").
ArgIf(filterAuthor != "", "--author="+filterAuthor).
ArgIf(filterPath != "", "--follow", "--name-status", "--", filterPath).
ToArgv()
cmdObj := self.cmd.New(cmdArgs).DontLog()
onlyObtainedNewReflogCommits := false
commits, err := loadCommits(cmdObj, filterPath, func(line string) (*models.Commit, bool) {
commit, ok := self.parseLine(hashPool, line)
if !ok {
return nil, false
}
if lastReflogCommit != nil && self.sameReflogCommit(commit, lastReflogCommit) {
onlyObtainedNewReflogCommits = true
return nil, true
}
return commit, false
})
return commits, onlyObtainedNewReflogCommits, err
}
The loader implements incremental refresh optimization: if the most recently loaded commit matches lastReflogCommit, the parser stops early, making repeated undo/redo operations performant even in large repositories.
Executing Undo and Redo Operations
When parseReflogForActions identifies a reversible action with a counter value of zero, the controller invokes self.c.Helpers().Refs.ResetToRef or equivalent helpers to execute the inverse operation:
- Commit undo:
git reset --soft <from_hash>restores the repository to the state before the commit while preserving working directory changes - Checkout undo:
git checkout <from_hash>returns to the previous branch or commit - Rebase undo: Complex reset operations revert the repository to the state before the rebase began
Crucially, lazygit sets GIT_REFLOG_ACTION=[lazygit undo] (or [lazygit redo]) in the environment when executing these commands. This injects the marker entries that the parser later uses to maintain the counter state, creating a robust lazygit reflog undo redo cycle that correctly handles multiple consecutive undos or redos.
Summary
- Reflog walking: The system parses
git log -goutput to identify CHECKOUT, COMMIT, and REBASE actions via regex patterns inundo_controller.go - Counter logic: A counter tracks how many previous undo/redo operations to skip, ensuring the next action reverses the correct user-initiated change
- Inverse commands: Undo executes
git reset --softorgit checkoutto the previous state; redo applies the inverse in the opposite direction - System markers: Environment variables inject
[lazygit undo]and[lazygit redo]entries into the reflog to distinguish system actions from user actions - Incremental loading: The
ReflogCommitLoaderoptimizes performance by stopping when it encounters previously cached entries
Frequently Asked Questions
How does lazygit distinguish between user actions and its own undo operations?
Lazygit injects special markers into the reflog by setting the GIT_REFLOG_ACTION environment variable to [lazygit undo] or [lazygit redo] when executing reverse commands. The parseReflogForActions function in undo_controller.go searches for these specific strings using regex patterns and increments or decrements a counter accordingly, ensuring these system-generated entries are skipped when calculating the next reversible action.
What git commands does lazygit use to perform undo and redo?
For commit operations, lazygit executes git reset --soft <hash> to move HEAD without discarding working directory changes. For checkout operations, it runs git checkout <hash> to return to the previous reference. For rebases, it performs complex reset operations to the pre-rebase state. Each command is invoked through the Refs helper with special environment variables that mark the reflog entry as an undo or redo action.
Can lazygit undo a git rebase operation?
Yes, the lazygit undo redo system supports rebases through specialized detection logic in parseReflogForActions. The parser correlates rebase (finish) markers with subsequent rebase (start) entries to construct a REBASE action type. When undoing, lazygit resets the repository to the commit hash recorded before the rebase began, effectively reverting the entire rebase sequence including any squashes, rewords, or reordering that occurred during the interactive session.
How does lazygit optimize reflog loading for large repositories?
The ReflogCommitLoader in reflog_commit_loader.go implements incremental loading by comparing newly fetched entries against lastReflogCommit. If the parser encounters a commit hash and timestamp matching the cached state, it sets onlyObtainedNewReflogCommits to true and stops processing. This prevents re-parsing the entire reflog history on every undo/redo operation, ensuring the UI remains responsive even when the reflog contains thousands of entries.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →