Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions doc/neogit.txt
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,10 @@ The following mappings can all be customized via the setup function.
["<c-s>"] = "StageAll",
["u"] = "Unstage",
["K"] = "Untrack",
["R"] = "Rename",
["U"] = "UnstageStaged",
["<m-u>"] = "Undo",
["<m-r>"] = "Redo",
["y"] = "ShowRefs",
["$"] = "CommandHistory",
["Y"] = "YankSelected",
Expand Down Expand Up @@ -2234,6 +2237,17 @@ working tree and index. It is opened via |:Neogit| or
Visual-mode `V` reverses only the selected lines
within the hunk.

• `<m-u>` / `<m-r>` Undo / redo the last action by stepping HEAD *neogit_status_undo*
back and forth through the reflog.

Covers anything that moves HEAD, such as commits,
amends, merges, rebases and resets. Each step is
tagged in the reflog, so the history isn't lost
when you close and reopen neogit.

Undo and redo are disabled while a rebase or merge
is in progress.

• `<tab>` Toggle folding for the item at point.
• `<cr>` Open the file at point in the editor.
• `{` / `}` Jump to previous / next hunk header.
Expand Down
26 changes: 26 additions & 0 deletions lua/neogit/buffers/status/actions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1766,4 +1766,30 @@ M.v_reverse = function(self)
end)
end

---@param self StatusBuffer
---@return fun(): nil
M.n_undo = function(self)
return a.void(function()
local ok, message = git.undo.undo()
if ok then
self:dispatch_refresh({ update_diffs = { "*:*" } }, "n_undo")
else
notification.warn(message)
end
end)
end

---@param self StatusBuffer
---@return fun(): nil
M.n_redo = function(self)
return a.void(function()
local ok, message = git.undo.redo()
if ok then
self:dispatch_refresh({ update_diffs = { "*:*" } }, "n_redo")
else
notification.warn(message)
end
end)
end

return M
2 changes: 2 additions & 0 deletions lua/neogit/buffers/status/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ function M:open(kind)
[mappings["StageUnstaged"]] = self:_action("n_stage_unstaged"),
[mappings["Unstage"]] = self:_action("n_unstage"),
[mappings["UnstageStaged"]] = self:_action("n_unstage_staged"),
[mappings["Undo"]] = self:_action("n_undo"),
[mappings["Redo"]] = self:_action("n_redo"),
[mappings["GoToFile"]] = self:_action("n_goto_file"),
[mappings["GoToParentRepo"]] = self:_action("n_goto_parent_repo"),
[mappings["TabOpen"]] = self:_action("n_tab_open"),
Expand Down
4 changes: 4 additions & 0 deletions lua/neogit/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ end
---| "StageAll"
---| "Unstage"
---| "UnstageStaged"
---| "Undo"
---| "Redo"
---| "Untrack"
---| "RefreshBuffer"
---| "GoToFile"
Expand Down Expand Up @@ -719,6 +721,8 @@ function M.get_default_values()
["K"] = "Untrack",
["R"] = "Rename",
["U"] = "UnstageStaged",
["<m-u>"] = "Undo",
["<m-r>"] = "Redo",
["y"] = "ShowRefs",
["$"] = "CommandHistory",
["Y"] = "YankSelected",
Expand Down
1 change: 1 addition & 0 deletions lua/neogit/lib/git.lua
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
---@field status NeogitGitStatus
---@field submodule NeogitGitSubmodule
---@field tag NeogitGitTag
---@field undo NeogitGitUndo
---@field worktree NeogitGitWorktree
---@field hooks NeogitGitHooks
local Git = {}
Expand Down
94 changes: 94 additions & 0 deletions lua/neogit/lib/git/undo.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
local git = require("neogit.lib.git")

---@class NeogitGitUndo
local M = {}

-- We tag the reflog message of every undo/redo we perform so we can walk back
-- through the reflog later and tell our own entries apart from regular ones.
-- This keeps the feature stateless: closing and reopening neogit doesn't lose
-- your place in the undo stack.
local UNDO_TAG = "[neogit: undo]"
local REDO_TAG = "[neogit: redo]"

---@class NeogitReflogMove
---@field old string oid HEAD pointed at before the entry
---@field subject string reflog subject line

---HEAD's reflog as a list of moves, newest first. Each move knows the oid HEAD
---sat on _before_ the entry, which is exactly what we reset back to when undoing.
---@return NeogitReflogMove[]
local function reflog()
local lines = git.cli.reflog.show
.format("%gs%x1f%H")
.args("HEAD", "--")
.call({ hidden = true, ignore_error = true }).stdout

local moves = {}
for i, line in ipairs(lines) do
local subject = line:match("^(.-)\31")
local old = lines[i + 1] and lines[i + 1]:match("\31(%x+)$")
if subject and old then
table.insert(moves, { old = old, subject = subject })
end
end

return moves
end

---Moves HEAD to `target`, recording the move in the reflog tagged with `tag` so
---we can recognise it on a later undo/redo.
---@param target string oid to move HEAD to
---@param tag string reflog tag to attach
---@return boolean
local function move_head(target, tag)
local result = git.cli.reset.soft.args(target).env({ GIT_REFLOG_ACTION = tag }).call { ignore_error = true }

return result:success()
end

---Undo and redo lean entirely on the reflog, which doesn't carry enough
---information to safely step around a half-finished rebase or merge.
---@return boolean
local function busy()
return git.rebase.in_progress() or git.merge.in_progress()
end

---Steps HEAD one entry back through the reflog.
---@return boolean success
---@return string? message
function M.undo()
if busy() then
return false, "Can't undo while a rebase or merge is in progress"
end

for _, move in ipairs(reflog()) do
-- Skip our own undo entries so repeated presses keep walking backwards
-- rather than toggling against the last undo. A redo entry is fair game:
-- undoing it just steps back to before the redo.
if not move.subject:find(UNDO_TAG, 1, true) then
return move_head(move.old, UNDO_TAG), nil
end
end

return false, "Nothing to undo"
end

---Steps HEAD one entry forward, replaying the last thing that was undone.
---@return boolean success
---@return string? message
function M.redo()
if busy() then
return false, "Can't redo while a rebase or merge is in progress"
end

-- Only the newest entry can be redone, and only if it was an undo: we move
-- HEAD back to where that undo took us from.
local last = reflog()[1]
if last and last.subject:find(UNDO_TAG, 1, true) then
return move_head(last.old, REDO_TAG), nil
end

return false, "Nothing to redo"
end

return M
2 changes: 2 additions & 0 deletions lua/neogit/popups/help/actions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ M.actions = function()
{ "StageAll", "Stage all", NONE },
{ "Unstage", "Unstage", NONE },
{ "UnstageStaged", "Unstage all", NONE },
{ "Undo", "Undo", NONE },
{ "Redo", "Redo", NONE },
{ "Discard", "Discard", NONE },
{ "Untrack", "Untrack", NONE },
}
Expand Down
158 changes: 158 additions & 0 deletions tests/specs/neogit/lib/git/undo_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
local eq = assert.are.same
local git = require("neogit.lib.git")
local undo = require("neogit.lib.git.undo")

-- Builds the stdout the reflog reader expects: "<subject>\31<hash>" per line,
-- newest first.
local function reflog_lines(entries)
return vim.tbl_map(function(entry)
return entry.subject .. "\31" .. entry.hash
end, entries)
end

describe("lib.git.undo", function()
local original_reflog, original_reset, original_rebase, original_merge
local reset_calls

before_each(function()
original_reflog = git.cli.reflog
original_reset = git.cli.reset
original_rebase = git.rebase.in_progress
original_merge = git.merge.in_progress

reset_calls = {}

git.rebase.in_progress = function()
return false
end
git.merge.in_progress = function()
return false
end

-- Stub `git reset --soft <target>` so we can see where HEAD would move and
-- with which reflog tag, without touching a real repository.
git.cli.reset = {
soft = {
args = function(target)
return {
env = function(env)
return {
call = function()
table.insert(reset_calls, { target = target, action = env.GIT_REFLOG_ACTION })
return {
success = function()
return true
end,
}
end,
}
end,
}
end,
},
}
end)

after_each(function()
git.cli.reflog = original_reflog
git.cli.reset = original_reset
git.rebase.in_progress = original_rebase
git.merge.in_progress = original_merge
end)

-- Stub HEAD's reflog with the given entries (newest first).
local function stub_reflog(entries)
git.cli.reflog = {
show = {
format = function()
return {
args = function()
return {
call = function()
return { stdout = reflog_lines(entries) }
end,
}
end,
}
end,
},
}
end

describe("#undo", function()
it("resets HEAD to the previous reflog position", function()
stub_reflog {
{ subject = "commit: three", hash = "ccc" },
{ subject = "commit: two", hash = "bbb" },
{ subject = "commit: one", hash = "aaa" },
}

local ok = undo.undo()

assert.True(ok)
eq({ { target = "bbb", action = "[neogit: undo]" } }, reset_calls)
end)

it("skips its own undo entries when stepping back", function()
stub_reflog {
{ subject = "[neogit: undo]: updating HEAD", hash = "bbb" },
{ subject = "commit: two", hash = "bbb" },
{ subject = "commit: one", hash = "aaa" },
}

undo.undo()

eq("aaa", reset_calls[1].target)
end)

it("returns a message when there is nothing to undo", function()
stub_reflog {}

local ok, message = undo.undo()

assert.False(ok)
eq("Nothing to undo", message)
eq({}, reset_calls)
end)

it("refuses to run while busy", function()
git.rebase.in_progress = function()
return true
end

local ok, message = undo.undo()

assert.False(ok)
eq("Can't undo while a rebase or merge is in progress", message)
eq({}, reset_calls)
end)
end)

describe("#redo", function()
it("replays the last undo", function()
stub_reflog {
{ subject = "[neogit: undo]: updating HEAD", hash = "bbb" },
{ subject = "commit: three", hash = "ccc" },
{ subject = "commit: two", hash = "bbb" },
}

local ok = undo.redo()

assert.True(ok)
eq({ { target = "ccc", action = "[neogit: redo]" } }, reset_calls)
end)

it("returns a message when the last action wasn't an undo", function()
stub_reflog {
{ subject = "commit: three", hash = "ccc" },
{ subject = "commit: two", hash = "bbb" },
}

local ok, message = undo.redo()

assert.False(ok)
eq("Nothing to redo", message)
eq({}, reset_calls)
end)
end)
end)