feat(resolve): add resolve command and log integration (#125)

Add first-class conflict resolution support to jj.nvim.

   - add `jj.cmd.resolve` to run `jj resolve` in a floating terminal or via an
     external tool
   - add `:J resolve` with support for `-r/--revision`, `--tool`,
     `--external/--ext`, and positional filesets
   - integrate resolve into the log buffer with the `gr` mapping and optional
     `resolve_strategies` picker
   - add `utils.is_change_conflicted()` and avoid opening resolve when the
     selected revision has no conflicts
   - document resolve usage, config, and keymaps in the README
   - add tests for resolve arg parsing and conflict detection helpers
This commit is contained in:
Nicolas GB
2026-06-16 19:06:33 +02:00
committed by GitHub
parent 9f2a76d079
commit 7fc91fe6d2
7 changed files with 543 additions and 31 deletions
+103 -7
View File
@@ -11,6 +11,7 @@ local log_module = require("jj.cmd.log")
local describe_module = require("jj.cmd.describe")
local status_module = require("jj.cmd.status")
local split_module = require("jj.cmd.split")
local resolve_module = require("jj.cmd.resolve")
-- Config for cmd module
--- @class jj.cmd.describe.editor.keymaps
@@ -115,9 +116,22 @@ local split_module = require("jj.cmd.split")
---@field parallel? boolean Run operations in parallel
---@field on_exit? fun(exit_code: number) Callback invoked when command exits
--- @class jj.cmd.resolve.strategy
--- @field name string Label shown in vim.ui.select
--- @field args? string[] Extra args passed to jj resolve
--- @field external? boolean Run externally instead of floating terminal
---@class jj.cmd.resolve.opts
---@field rev string|nil The revision to resolve, defaults to "@"
---@field filesets string[]|nil The filesets to resolve, defaults to all filesets
---@field external boolean|nil Whether to use external merge tool, if this boolean is set the command will not be ran in a floating terminal inside neovim (defaults: false)
---@field args string[]|nil Additional arguments to pass to the resolve command
---@field on_exit? fun(exit_code: number) Callback invoked when command exits
--- @class jj.cmd.opts
--- @field describe? jj.cmd.describe
--- @field log? jj.cmd.log
--- @field resolve_strategies? jj.cmd.resolve.strategy[] List of conflict resolve strategies shared across cmd integrations
--- @field bookmark? jj.cmd.bookmark
--- @field keymaps? jj.cmd.keymaps Keymaps for the buffers containing the of the commands
---
@@ -157,6 +171,7 @@ M.config = {
log = {
close_on_edit = false,
},
resolve_strategies = {},
bookmark = {
prefix = "",
},
@@ -214,6 +229,7 @@ M.config = {
edit_file = "o",
},
split = "<C-s>",
resolve = "gr",
tag_set = "<S-t>",
history = "<S-h>",
change_revset = "<C-r>",
@@ -248,6 +264,8 @@ M.describe = describe_module.describe
M.status = status_module.status
-- Rexport split function
M.split = split_module.split
-- Reexport resolve function
M.resolve = resolve_module.resolve
--- Merge multiple keymap arrays into one
--- @param ... jj.core.buffer.keymap[][] Keymap arrays to merge
@@ -1240,6 +1258,63 @@ function M.fetch_pr(opts)
end)
end
--- Parse arguments passed to `:J resolve`
--- @param args string[]
--- @return jj.cmd.resolve.opts|nil opts
--- @return string|nil err
function M.parse_resolve_args(args)
local opts = { rev = "@" } --[[@as jj.cmd.resolve.opts]]
local already_set = {
rev = false,
tool = false,
}
local i = 1
while i <= #args do
local arg = args[i]
if arg == "--external" or arg == "--ext" then
opts.external = true
elseif arg == "--tool" then
local tool = args[i + 1]
if not tool or tool:sub(1, 1) == "-" then
return nil, "Missing value for --tool"
end
if already_set.tool then
return nil, "Tool already set. Cannot specify multiple tools."
end
opts.args = opts.args or {}
table.insert(opts.args, "--tool")
table.insert(opts.args, tool)
already_set.tool = true
i = i + 1
elseif arg == "--revision" or arg == "-r" then
local rev = args[i + 1]
if not rev or rev:sub(1, 1) == "-" then
return nil, "Missing value for --revision/-r"
end
if already_set.rev then
return nil, "Revision already set. Cannot specify multiple revisions."
end
opts.rev = rev
already_set.rev = true
i = i + 1
elseif arg:sub(1, 2) == "--" then
return nil, string.format("Unknown option: %s", arg)
else
-- Positional args are treated as filesets.
opts.filesets = opts.filesets or {}
table.insert(opts.filesets, arg)
end
i = i + 1
end
return opts, nil
end
--- @param args string|string[] jj command arguments
function M.j(args)
if not utils.ensure_jj() then
@@ -1304,27 +1379,39 @@ function M.j(args)
M.log({ raw_flags = remaining_args_str ~= "" and remaining_args_str or nil })
end,
split = function()
local rev = remaining_args and remaining_args[1] or "@"
local opts = {
rev = rev,
rev = "@",
}
local index = 2
for i = index, #remaining_args do
local i = 1
while i <= #remaining_args do
local arg = remaining_args[i]
if arg == "--parallel" then
opts.parallel = true
elseif arg == "--ignore-immutable" then
opts.ignore_immutable = true
elseif arg == "--message" and remaining_args[i + 1] then
opts.message = remaining_args[i + 1]
index = i + 1
i = i + 1
elseif arg == "--fileset" and remaining_args[i + 1] then
opts.filesets = opts.filesets or {}
table.insert(opts.filesets, remaining_args[i + 1])
index = i + 1
i = i + 1
elseif arg:sub(1, 2) == "--" then
-- Unknown option: ignore it here.
utils.notify(string.format("Unknown option: %s", arg), vim.log.levels.WARN)
return
else
if opts.rev == "@" then
opts.rev = arg
else
opts.filesets = opts.filesets or {}
table.insert(opts.filesets, arg)
end
end
i = i + 1
end
require("jj.cmd.split").split(opts)
@@ -1430,6 +1517,14 @@ function M.j(args)
fetch_pr = function()
M.fetch_pr()
end,
resolve = function()
local opts, err = M.parse_resolve_args(remaining_args)
if err then
utils.notify(err, vim.log.levels.ERROR)
return
end
require("jj.cmd.resolve").resolve(opts)
end,
}
if handlers[subcommand] then
@@ -1481,6 +1576,7 @@ function M.register_command()
"commit",
"tag",
"fetch_pr",
"resolve",
}
local matches = {}
for _, cmd in ipairs(subcommands) do
+59
View File
@@ -943,6 +943,60 @@ function M.handle_log_split()
})
end
--- Handle log resolve conflict
function M.handle_log_resolve()
local revset = get_revset()
if not revset or revset == "" then
return
end
if not utils.is_change_conflicted(revset) then
utils.notify(string.format("Revision `%s` has no conflicts to resolve", revset), vim.log.levels.INFO)
return
end
local exit_func = function(exit_code)
if exit_code == 0 then
utils.notify(string.format("Successfully resolved `%s`", revset), vim.log.levels.INFO)
vim.schedule(function()
M.log({})
end)
else
-- Since we previously replaced the floating with the resolve we actually want to re run the log cmd
if require("jj").config.terminal.window.type == "floating" then
vim.schedule(function()
M.log({})
end)
end
end
end
local strategies = require("jj.cmd").config.resolve_strategies
if strategies and #strategies > 1 then
vim.ui.select(strategies, {
prompt = "Select a resolve strategy: ",
format_item = function(item)
return item.name
end,
}, function(choice)
if choice then
require("jj.cmd").resolve({
rev = revset,
args = choice.args,
external = choice.external,
on_exit = exit_func,
})
end
end)
elseif strategies and #strategies == 1 then
local choice = strategies[1]
require("jj.cmd").resolve({ rev = revset, args = choice.args, external = choice.external, on_exit = exit_func })
else
-- Simply resolve with the default
require("jj.cmd").resolve({ rev = revset, on_exit = exit_func })
end
end
--- Handle diff action in summary tooltip
--- Diffs the file at revset against its parent (revset-)
--- Opens a floating diff, and returns focus to tooltip when closed
@@ -1322,6 +1376,11 @@ function M.log_keymaps()
handler = M.handle_log_select_prev_revision,
modes = { "n", "v" },
},
resolve = {
desc = "Resolve conflicts for revision under cursor",
handler = M.handle_log_resolve,
modes = { "n" },
},
}
local merged_keymaps = cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps())
+67
View File
@@ -0,0 +1,67 @@
local M = {}
local utils = require("jj.utils")
local terminal = require("jj.ui.terminal")
local runner = require("jj.core.runner")
--- Resolve conflicts in the current change.
--- @param opts? jj.cmd.resolve.opts
function M.resolve(opts)
opts = vim.tbl_deep_extend("force", {}, opts or {}) --[[@as jj.cmd.resolve.opts]]
local rev = opts.rev or "@"
local filesets = opts.filesets or {}
local args = opts.args or {}
if not utils.ensure_jj() then
return
end
local cmd_args = { "jj", "resolve", "--revision", rev }
-- Extra arguments
vim.list_extend(cmd_args, args)
-- Append the filestes
vim.list_extend(cmd_args, filesets)
local escaped_cmd_args = {}
for _, arg in ipairs(cmd_args) do
table.insert(escaped_cmd_args, vim.fn.shellescape(arg))
end
local cmd = table.concat(escaped_cmd_args, " ")
utils.notify(string.format("Resolving conflicts in change `%s`...", rev), vim.log.levels.INFO)
-- If external is set, run the command asynchronously and invoke the on_exit callback if provided
if opts.external then
-- Run the command asynchronously and notify the user of the result
runner.execute_command_async(
cmd,
function(output)
if output and output ~= "" then
utils.notify(output, vim.log.levels.INFO)
end
if opts.on_exit then
opts.on_exit(0)
end
end,
string.format("Could not resolve conflicts in `%s`", rev),
nil,
nil,
function()
if opts.on_exit then
opts.on_exit(1)
end
end
)
else
-- Otherwise, run in a floating terminal
terminal.run_floating(cmd, nil, {
title = " JJ Resolve ",
modifiable = true,
keep_modifiable = true,
interactive = true,
on_exit = opts.on_exit or nil,
})
end
end
return M
+2 -1
View File
@@ -17,7 +17,8 @@ end
--- @return boolean success Whether the command succeeded
function M.execute_command(cmd, error_prefix, input, silent)
local stderr_file = vim.fn.tempname()
local output = vim.fn.system({ "sh", "-c", string.format("(%s) 2>%s", cmd, vim.fn.shellescape(stderr_file)) }, input)
local output =
vim.fn.system({ "sh", "-c", string.format("(%s) 2>%s", cmd, vim.fn.shellescape(stderr_file)) }, input)
local success = vim.v.shell_error == 0
if not success then
+18
View File
@@ -644,6 +644,24 @@ function M.is_change_empty(revset)
return vim.trim(output) == "true"
end
--- Check if a given revset has conflicts
--- @param revset string The revset to check
--- @return boolean True if the revset has conflicts, false otherwise
function M.is_change_conflicted(revset)
local output, success = runner.execute_command(
string.format("jj log --no-graph -r %s -T 'conflict' --quiet", vim.fn.shellescape(revset)),
"Error checking if revset has conflicts",
nil,
true
)
if not success or not output then
return false
end
return vim.trim(output) == "true"
end
--- Build describe text for a given revision
--- @param revset? string The revision to describe (default: @)
--- @return string[]|nil