feat: Add fetch/push commands and log keybinds, make long-running operations non-blocking

Convert blocking jj commands to async using vim.fn.jobstart() to prevent UI
freezing during fetch, push, rebase, squash, and other heavy operations.

Changes:
- Implement execute_command_async() in runner with proper error handling
  - Captures stdout from job and passes to success callback
  - Supports stdin input for commands that require it
  - Includes silent flag to suppress error notifications
  - Replicates sync version's error handling behavior

- Convert blocking operations to async:
  - handle_log_fetch, handle_log_push_all, handle_log_new
  - handle_log_edit, handle_log_abandon, handle_log_describe
  - describe.execute_describe, all init.lua commands

- Add push/fetch as public commands with CLI support:
  - M.fetch() - fetch from remote
  - M.push(opts) - push all changes or specific bookmark
  - CLI: :J push, :J push <bookmark>, :J fetch

- Implement handle_log_push_bookmark() for revision under cursor:
  - Retrieves and strips modified bookmark indicator (*)
  - Pushes specific bookmark to remote
  - Keybind: Shift+p in log buffer

- Update README with new keybinds:
  - a: abandon revision under cursor
  - f: fetch from remote
  - p: push all changes
  - Shift+p: push revision's bookmark

- Add Lua API docs for push command options
This commit is contained in:
NicolasGB
2025-11-27 20:58:34 +01:00
committed by Nicolas GB
parent 8f1a60342a
commit e22bf5ec4e
6 changed files with 282 additions and 71 deletions
+42
View File
@@ -68,6 +68,20 @@ You can undo/redo changes directly from the log buffer:
- `<S-u>` - Undo the last operation
- `<S-r>` - Redo the last undone operation
### Abandon changes from the log buffer
You can abandon changes directly from the log buffer:
- `a` - Abandon the revision under the cursor
### Fetch and push from the log buffer
You can fetch and push directly from the log buffer:
- `f` - Fetch from remote
- `<S-p>` - Push all changes to remote
- `p` - Push bookmark of revision under cursor to remote
### Open a changed file
Just press enter to open the a file from the `status` output in your current window.
@@ -101,6 +115,9 @@ The plugin provides a `:J` command that accepts jj subcommands:
:J log
:J describe "Your change description"
:J new
:J push " Push all changes
:J push main " Push only main bookmark
:J fetch " Fetch from remote
:J # This will use your defined default command
:J <your-alias>
```
@@ -178,6 +195,10 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing
new_after_immutable = "<S-n>", -- Create new change after (ignore immutability)
undo = "<S-u>", -- Undo last operation
redo = "<S-r>", -- Redo last undone operation
abandon = "a", -- Abandon revision under cursor
fetch = "f", -- Fetch from remote
push = "p", -- Push bookmark of revision under cursor
push_all = "<S-p>", -- Push all changes to remote
},
-- Status buffer keymaps (set to nil to disable)
status = {
@@ -296,6 +317,22 @@ cmd.new({ show_log = true, with_input = true }) -- Prompt for parent
cmd.new({ args = "--before @" }) -- Pass custom args
```
### Push Command Options
The `push` function accepts an options table:
```lua
local cmd = require("jj.cmd")
cmd.push({
bookmark = "main" -- Push specific bookmark (default: all changes)
})
-- Examples:
cmd.push() -- Push all changes
cmd.push({ bookmark = "main" }) -- Push only main bookmark
cmd.push({ bookmark = "feature" }) -- Push only feature bookmark
```
### Diff Split Views
Use the `diff` module for opening splits:
@@ -338,6 +375,8 @@ diff.open_hsplit({ rev = "@-2" }) -- Horizontal split against @-2
checkout = "<CR>",
describe = "d",
diff = "<S-d>",
abandon = "<S-a>",
fetch = "<S-f>",
},
status = {
open_file = "<CR>",
@@ -367,6 +406,9 @@ diff.open_hsplit({ rev = "@-2" }) -- Horizontal split against @-2
vim.keymap.set("n", "<leader>jr", cmd.rebase, { desc = "JJ rebase" })
vim.keymap.set("n", "<leader>jb", cmd.bookmark_create, { desc = "JJ bookmark create" })
vim.keymap.set("n", "<leader>jB", cmd.bookmark_delete, { desc = "JJ bookmark delete" })
vim.keymap.set("n", "<leader>ja", cmd.abandon, { desc = "JJ abandon" })
vim.keymap.set("n", "<leader>jf", cmd.fetch, { desc = "JJ fetch" })
vim.keymap.set("n", "<leader>jp", cmd.push, { desc = "JJ push" })
-- Diff commands
local diff = require("jj.diff")
+3 -3
View File
@@ -30,10 +30,9 @@ local function execute_describe(description, revset)
cmd = cmd .. " --stdin"
-- Use --stdin to properly handle multi-line and special characters
local _, success = runner.execute_command(cmd, "Failed to describe", description)
if success then
runner.execute_command_async(cmd, function()
utils.notify("Description set.", vim.log.levels.INFO)
end
end, "Failed to describe", description)
end
--- Resolve describe editor keymaps from config
@@ -151,3 +150,4 @@ function M.describe(description, revset, opts)
end
return M
+105 -45
View File
@@ -36,6 +36,9 @@ local status_module = require("jj.cmd.status")
--- @field undo? string|string[]
--- @field redo? string|string[]
--- @field abandon? string|string[]
--- @field fetch? string|string[]
--- @field push_all? string|string[]
--- @field push? string|string[]
--- @class jj.cmd.status.keymaps
--- @field open_file? string|string[] Keymaps for the status command buffer, setting a keymap to nil will disable it
@@ -54,7 +57,7 @@ local status_module = require("jj.cmd.status")
--- @class jj.cmd.opts
--- @field describe? jj.cmd.describe
--- @field log? jj.cmd.log
--- @field keymaps? jj.cmd.keymaps Keymaps for the buffers containing the output of the commands
--- @field keymaps? jj.cmd.keymaps Keymaps for the buffers containing the of the commands
---
--- @class jj.cmd.keymap_spec
--- @field desc string
@@ -63,6 +66,9 @@ local status_module = require("jj.cmd.status")
--- @alias jj.cmd.keymap_specs table<string, jj.cmd.keymap_spec>
--- @class jj.cmd.push_opts
--- @field bookmark? string Specific bookmark to push (default: all)
--- @type jj.cmd.opts
M.config = {
describe = {
@@ -88,6 +94,9 @@ M.config = {
undo = "<S-u>",
redo = "<S-r>",
abandon = "a",
fetch = "f",
push_all = "<S-P>",
push = "p",
},
status = {
open_file = "<CR>",
@@ -207,12 +216,13 @@ function M.new(opts)
--- @param cmd string
local function execute_new(cmd)
runner.execute_command(cmd, "Failed to create new change")
utils.notify("Command `new` was succesful.", vim.log.levels.INFO)
-- Show the updated log if the user requested it
if opts.show_log then
M.log()
end
runner.execute_command_async(cmd, function()
utils.notify("Command `new` was succesful.", vim.log.levels.INFO)
-- Show the updated log if the user requested it
if opts.show_log then
M.log()
end
end, "Failed to create new change")
end
-- If the user wants use input mode
@@ -255,11 +265,9 @@ function M.edit()
default = "",
}, function(input)
if input then
local _, success = runner.execute_command(string.format("jj edit %s", input), "Error editing change")
if not success then
return
end
M.log({})
runner.execute_command_async(string.format("jj edit %s", input), function()
M.log({})
end, "Error editing change")
else
terminal.close_terminal_buffer()
end
@@ -273,13 +281,12 @@ function M.squash()
end
local cmd = "jj squash"
local _, success = runner.execute_command(cmd, "Failed to squash")
if success then
runner.execute_command_async(cmd, function()
utils.notify("Command `squash` was succesful.", vim.log.levels.INFO)
if terminal.state.buf_cmd == "log" then
M.log()
end
end
end, "Failed to squash")
end
--- @class jj.cmd.diff_opts
@@ -321,11 +328,10 @@ function M.rebase()
if input then
local cmd = string.format("jj rebase -d '%s'", input)
utils.notify(string.format("Beginning rebase on %s", input), vim.log.levels.INFO)
local _, success = runner.execute_command(cmd, "Error rebasing")
if success then
runner.execute_command_async(cmd, function()
utils.notify("Rebase successful.", vim.log.levels.INFO)
M.log({})
end
end, "Error rebasing")
else
terminal.close_terminal_buffer()
end
@@ -344,11 +350,10 @@ function M.bookmark_create()
}, function(input)
if input then
local cmd = string.format("jj b c %s", input)
local _, success = runner.execute_command(cmd, "Error creating bookmark")
if success then
runner.execute_command_async(cmd, function()
utils.notify(string.format("Bookmark `%s` created successfully for @", input), vim.log.levels.INFO)
M.log({})
end
end, "Error creating bookmark")
else
terminal.close_terminal_buffer()
end
@@ -367,11 +372,10 @@ function M.bookmark_delete()
}, function(input)
if input then
local cmd = string.format("jj b d %s", input)
local _, success = runner.execute_command(cmd, "Error deleting bookmark")
if success then
runner.execute_command_async(cmd, function()
utils.notify(string.format("Bookmark `%s` deleted successfully.", input), vim.log.levels.INFO)
M.log({})
end
end, "Error deleting bookmark")
else
terminal.close_terminal_buffer()
end
@@ -385,13 +389,12 @@ function M.undo()
end
local cmd = "jj undo"
local _, success = runner.execute_command(cmd, "Failed to undo")
if success then
runner.execute_command_async(cmd, function()
utils.notify("Command `undo` was succesful.", vim.log.levels.INFO)
if terminal.state.buf_cmd == "log" then
M.log({})
end
end
end, "Failed to undo")
end
-- Jujutsu redo
@@ -401,13 +404,12 @@ function M.redo()
end
local cmd = "jj redo"
local _, success = runner.execute_command(cmd, "Failed to redo")
if success then
runner.execute_command_async(cmd, function()
utils.notify("Command `redo` was succesful.", vim.log.levels.INFO)
if terminal.state.buf_cmd == "log" then
M.log({})
end
end
end, "Failed to redo")
end
-- Jujutsu abandon
@@ -423,16 +425,61 @@ function M.abandon()
}, function(input)
if input then
local cmd = string.format("jj abandon %s", input)
local _, success = runner.execute_command(cmd, "Error abandoning change")
if success then
runner.execute_command_async(cmd, function()
utils.notify("Change abandoned successfully.", vim.log.levels.INFO)
M.log({})
end
end, "Error abandoning change")
else
terminal.close_terminal_buffer()
end
end)
end
-- Jujutsu fetch
function M.fetch()
if not utils.ensure_jj() then
return
end
local log_open = terminal.state.buf_cmd == "log"
local cmd = "jj git fetch"
utils.notify("Fetching ...", vim.log.levels.INFO, 1000)
runner.execute_command_async(cmd, function()
utils.notify("Successfully fetched from remote", vim.log.levels.INFO)
if log_open then
M.log({})
end
end, "Error fetching from remote")
end
-- Jujutsu push
--- @param opts? jj.cmd.push_opts Optional push options
function M.push(opts)
if not utils.ensure_jj() then
return
end
opts = opts or {}
local log_open = terminal.state.buf_cmd == "log"
local cmd = "jj git push"
if opts.bookmark then
utils.notify(string.format("Pushing `%s` bookmark ...", opts.bookmark), vim.log.levels.INFO, 1000)
cmd = string.format("%s --bookmark %s", cmd, opts.bookmark)
else
utils.notify(string.format("Pushing `ALL` bookmarks...", opts.bookmark), vim.log.levels.INFO, 1000)
end
runner.execute_command_async(cmd, function()
utils.notify("Successfully pushed to remote", vim.log.levels.INFO)
if log_open then
M.log({})
end
end, "Error pushing to remote")
end
--- @param args string|string[] jj command arguments
function M.j(args)
if not utils.ensure_jj() then
@@ -509,6 +556,19 @@ function M.j(args)
st = function()
M.status()
end,
abandon = function()
M.abandon()
end,
push = function()
if #remaining_args > 0 then
M.push({ bookmark = remaining_args[1] })
else
M.push()
end
end,
fetch = function()
M.fetch()
end,
}
if handlers[subcommand] then
@@ -535,22 +595,23 @@ function M.register_command()
nargs = "*",
complete = function(arglead, _, _)
local subcommands = {
"log",
"status",
"st",
"diff",
"describe",
"new",
"squash",
"bookmark",
"edit",
"abandon",
"b",
"bookmark",
"describe",
"diff",
"edit",
"fetch",
"git",
"log",
"new",
"push",
"rebase",
"abandon",
"undo",
"redo",
"squash",
"st",
"status",
"undo",
}
local matches = {}
for _, cmd in ipairs(subcommands) do
@@ -580,4 +641,3 @@ function M.register_command()
end
return M
+84 -20
View File
@@ -95,14 +95,11 @@ function M.handle_log_new(flag, ignore_immut)
end
local cmd = table.concat(cmd_parts, " ")
local _, success = runner.execute_command(cmd, string.format(cfg.err, revset))
if not success then
return
end
utils.notify(string.format(cfg.ok, revset), vim.log.levels.INFO)
-- Refresh the log buffer after creating the change.
require("jj.cmd").log()
runner.execute_command_async(cmd, function()
utils.notify(string.format(cfg.ok, revset), vim.log.levels.INFO)
-- Refresh the log buffer after creating the change.
require("jj.cmd").log()
end, string.format(cfg.err, revset))
end
--- Handle diffing a log line
@@ -129,7 +126,7 @@ function M.handle_log_describe()
end
end
--- Handle keypress enter on `jj log` buffer to edit a revision.
--- Handle keypress edit on `jj log` buffer to edit a revision.
--- If ignore_immut is true, adds --ignore-immutable to the command.
--- Silently returns if no revision is found or the jj command fails.
--- On success, notifies and refreshes the log buffer.
@@ -156,8 +153,17 @@ function M.handle_log_edit(ignore_immut, close_on_exit)
local cmd = table.concat(cmd_parts, " ")
-- Try to execute cmd
local _, success = runner.execute_command(cmd, "Error editing change")
if not success then
runner.execute_command_async(cmd, function()
-- Close the terminal buffer
if close_on_exit then
utils.notify(string.format("Editing change: `%s`", revset), vim.log.levels.INFO)
terminal.close_terminal_buffer()
else
M.log({})
end
end, "Error editing change")
end
--- Handle abandon `jj log` buffer.
--- If ignore_immut is true, adds --ignore-immutable to the command.
--- Silently returns if no revision is found or the jj command fails.
@@ -170,17 +176,11 @@ function M.handle_log_abandon(ignore_immut)
return
end
-- Close the terminal buffer
-- If we found a revision, abandon it.
if close_on_exit then
utils.notify(string.format("Editing change: `%s`", revset), vim.log.levels.INFO)
-- Build command parts.
terminal.close_terminal_buffer()
local cmd_parts = { "jj", "abandon" }
else
if ignore_immut then
M.log({})
table.insert(cmd_parts, "--ignore-immutable")
end
@@ -193,10 +193,62 @@ function M.handle_log_abandon(ignore_immut)
runner.execute_command_async(cmd, function()
utils.notify(string.format("Abandoned change: `%s`", revset), vim.log.levels.INFO)
M.log({})
end, function()
utils.notify("Error abandoning change", vim.log.levels.ERROR)
end)
end, "Error abandoning change")
end
--- Handle fetching from `jj log` buffer.
function M.handle_log_fetch()
local cmd = "jj git fetch"
utils.notify("Fetching from remote...", vim.log.levels.INFO)
runner.execute_command_async(cmd, function()
utils.notify("Successfully fetched from remote", vim.log.levels.INFO)
M.log({})
end, "Error fetching from remote")
end
--- Handle pushing from `jj log` buffer.
function M.handle_log_push_all()
local cmd = "jj git push"
utils.notify("Pushing `ALL` bookmarks", vim.log.levels.INFO)
runner.execute_command_async(cmd, function()
utils.notify("Successfully pushed all to remote", vim.log.levels.INFO)
M.log({})
end, "Error pushing to remote")
end
--- Handle log pushing bookmark from current line in `jj log` buffer.
function M.handle_log_push_bookmark()
local line = vim.api.nvim_get_current_line()
local revset = parser.get_rev_from_log_line(line)
if not revset or revset == "" then
return
end
-- If we found a revfision get it's bookmark and push it
local bookmark, success = runner.execute_command(
string.format("jj log -r %s -T 'bookmarks' --no-graph", revset),
string.format("Error retrieving bookmark for `%s`", revset),
nil,
false
)
if not success or not bookmark then
return
end
-- If there's a * trim it (bookmarks with modifications have *)
bookmark = bookmark:gsub("%*", ""):gsub("^%s+", ""):gsub("%s+$", "")
if bookmark == "" then
utils.notify("No bookmark found for revision", vim.log.levels.ERROR)
return
end
-- Push the bookmark from the revset found
local cmd = string.format("jj git push --bookmark %s -N", bookmark)
utils.notify(string.format("Pushing bookmark `%s`...", bookmark), vim.log.levels.INFO)
runner.execute_command_async(cmd, function()
utils.notify(string.format("Successfully pushed bookmark for `%s`", revset), vim.log.levels.INFO)
M.log({})
end, string.format("Error pushing bookmark for `%s`", revset))
end
--- Resolve log keymaps from config, filtering out nil values
@@ -261,6 +313,18 @@ function M.log_keymaps()
-- Maybe in the future we can add another keymap for that, if people request it
args = { false },
},
fetch = {
desc = "Fetch from remote",
handler = M.handle_log_fetch,
},
push_all = {
desc = "Push all to remote",
handler = M.handle_log_push_all,
},
push = {
desc = "Push bookmark of revision under cursor to remote",
handler = M.handle_log_push_bookmark,
},
}
return cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps())
+44 -1
View File
@@ -29,5 +29,48 @@ function M.execute_command(cmd, error_prefix, input, silent)
return output, success
end
return M
--- Execute a system command asynchronously
--- @param cmd string The command to execute
--- @param on_success function|nil Callback on success, receives output as parameter
--- @param error_prefix string|nil Optional error message prefix
--- @param input string|nil Optional input to pass to stdin
--- @param silent boolean|nil Optional to silent the notification
function M.execute_command_async(cmd, on_success, error_prefix, input, silent)
local output_lines = {}
local job_id = vim.fn.jobstart(cmd, {
on_stdout = function(_, data)
for _, line in ipairs(data) do
if line ~= "" then
table.insert(output_lines, line)
end
end
end,
on_exit = function(_, exit_code)
local output = table.concat(output_lines, "\n")
if exit_code == 0 then
if on_success then
on_success(output)
end
else
local error_message
if error_prefix then
error_message = string.format("%s: %s", error_prefix, output)
else
error_message = output
end
if not silent then
vim.notify(error_message, vim.log.levels.ERROR, { title = "JJ" })
end
end
end,
})
-- Send stdin if provided
if input then
vim.fn.chansend(job_id, input)
vim.fn.chanclose(job_id, "stdin")
end
end
return M
+4 -2
View File
@@ -108,9 +108,11 @@ end
---- Notify function to display messages with a title
--- @param message string The message to display
--- @param level? number The log level (default: INFO)
function M.notify(message, level)
--- @param timeout number? The timeout duration in milliseconds (default: 3000)
function M.notify(message, level, timeout)
level = level or vim.log.levels.INFO
vim.notify(message, level, { title = "JJ", timeout = 3000 })
timeout = timeout or 3000
vim.notify(message, level, { title = "JJ", timeout = timeout })
end
return M