feat(split): add jj split command with interactive floating terminal

Add split command that allows splitting a change into multiple revisions
    from the log buffer or via :J split. Supports --parallel, --message,
    --fileset, and --ignore-immutable flags. Includes immutability checks
    with user confirmation and empty change detection.
This commit is contained in:
NicolasGB
2026-02-13 18:44:27 +01:00
committed by Nicolas GB
parent 3115a0b3f0
commit 3d78c28f7e
6 changed files with 325 additions and 44 deletions
+46
View File
@@ -22,6 +22,7 @@
- [Fetch and push from the log buffer](#fetch-and-push-from-the-log-buffer)
- [Manage bookmarks from the log buffer](#manage-bookmarks-from-the-log-buffer)
- [Squash changes from the log buffer](#squash-changes-from-the-log-buffer)
- [Split changes from the log buffer](#split-changes-from-the-log-buffer)
- [Rebase changes from the log buffer](#rebase-changes-from-the-log-buffer)
- [Open a PR/MR from the log buffer](#open-a-prmr-from-the-log-buffer)
- [Open a changed file](#open-a-changed-file)
@@ -60,6 +61,7 @@
- `new` - Create a new change with optional parent selection
- `edit` - Edit a change
- `squash` - Squash the current diff to its parent or interactive squash mode from the log buffer
- `split` - Split a change interactively in a floating terminal
- `rebase` - Rebase changes to a destination
- `bookmark create/delete` - Create and delete bookmarks
- `undo` - Undo the last operation
@@ -176,6 +178,42 @@ From squash mode, choose how to squash:
![Squash-from-log](https://github.com/NicolasGB/jj.nvim/raw/main/assets/squash.gif)
### Split changes from the log buffer
Split a change into two or more revisions directly from the log buffer or the command line:
- `<C-s>` - Split the revision under cursor from the log buffer
The split command opens an interactive floating terminal where jj guides you through selecting which changes go into the first commit. The remaining changes stay in the second commit.
> [!NOTE]
> If you want to use something like [hunk.nvim](https://github.com/julienvincent/hunk.nvim), simply follow the steps and update your jj's config to use it as a tool and a neovim instance with hunk will be ran inside your current neovim, for a seamless integration
>
> Other tools that ran in the terminal like jj's native should work out of the box too.
**Via `:J` command:**
```sh
:J split " Split @ interactively
:J split abc123 " Split a specific revision
:J split @ --parallel " Create parallel changes instead of sequential
:J split @ --message "first half" " Set a commit message for the first split
```
**Via Lua API:**
```lua
local cmd = require("jj.cmd")
cmd.split() -- Split @ interactively
cmd.split({ rev = "abc123" }) -- Split a specific revision
cmd.split({ parallel = true }) -- Create parallel changes
cmd.split({ message = "first half" }) -- Set message for first split
cmd.split({ filesets = { "src/" } }) -- Only include specific filesets
cmd.split({ ignore_immutable = true }) -- Split an immutable revision
```
The floating terminal size is configurable via the `split.width` and `split.height` options (ratios between `0.1` and `1.0`).
### Rebase changes from the log buffer
Enter an interactive rebase mode directly from the log buffer to rebase one or more changes:
@@ -262,6 +300,7 @@ The plugin provides a `:J` command that accepts jj subcommands:
:J fetch " Fetch from remote
:J open_pr " Open PR for current change's bookmark
:J open_pr --list " Select bookmark from all and open PR
:J split " Split a change interactively
:J bookmark create/move/delete
:J # This will use your defined default command
:J <your-alias>
@@ -344,6 +383,12 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing
close_on_edit = false, -- Close log buffer after editing a change
},
-- Configure split command
split = {
width = 0.99, -- Width ratio of the floating terminal (0.1 to 1.0)
height = 0.95, -- Height ratio of the floating terminal (0.1 to 1.0)
},
-- Configure bookmark command
bookmark = {
prefix = ""
@@ -387,6 +432,7 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing
exit_mode = { "<Esc>", "<C-c>" }, -- Exit squash mode
},
quick_squash = "<S-s>", -- Quick squash revision under cursor into its parent (ignore immutability)
split = "<C-s>", -- Split the revision under cursor
summary = "<S-k>", -- Show summary tooltip for revision under cursor
summary_tooltip = {
diff = "<S-d>", -- Diff file at this revision
+51
View File
@@ -5,11 +5,13 @@ local utils = require("jj.utils")
local runner = require("jj.core.runner")
local terminal = require("jj.ui.terminal")
local editor = require("jj.ui.editor")
local parser = require("jj.core.parser")
local diff = require("jj.diff")
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")
-- Config for cmd module
--- @class jj.cmd.describe.editor.keymaps
@@ -90,9 +92,24 @@ local status_module = require("jj.cmd.status")
--- @field close? string|string[] Keymaps for the close keybind
--- @field floating? jj.cmd.floating.keymaps Keymaps for the floating buffer
---@class jj.cmd.split.common
---@field height? number Height % for the split buffer (between 0.1 and 1.0)
---@field width? number Width % for the split buffer (between 0.1 and 1.0)
---@class jj.cmd.split.opts: jj.cmd.split.common
---@field rev? string Revision to split
---@field message? string Commit message for the new revision
---@field filesets? string[] Filesets to include in the split
---@field ignore_immutable? boolean Ignore immutable revisions
---@field parallel? boolean Run operations in parallel
---@field on_exit? fun(exit_code: number) Callback invoked when command exits
---@class jj.cmd.split: jj.cmd.split.common
--- @class jj.cmd.opts
--- @field describe? jj.cmd.describe
--- @field log? jj.cmd.log
--- @field split? jj.cmd.split
--- @field bookmark? jj.cmd.bookmark
--- @field keymaps? jj.cmd.keymaps Keymaps for the buffers containing the of the commands
---
@@ -123,6 +140,10 @@ M.config = {
log = {
close_on_edit = false,
},
split = {
width = 0.99,
height = 0.95,
},
bookmark = {
prefix = "",
},
@@ -167,6 +188,7 @@ M.config = {
edit = "<CR>",
edit_immutable = "<S-CR>",
},
split = "<C-s>",
},
status = {
open_file = "<CR>",
@@ -194,6 +216,8 @@ M.log = log_module.log
M.describe = describe_module.describe
-- Reexport status function
M.status = status_module.status
-- Rexport split function
M.split = split_module.split
--- Merge multiple keymap arrays into one
--- @param ... jj.core.buffer.keymap[][] Keymap arrays to merge
@@ -867,6 +891,32 @@ function M.j(args)
log = function()
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,
}
local index = 2
for i = index, #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
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
end
end
require("jj.cmd.split").split(opts)
end,
diff = function()
M.diff({ current = false })
end,
@@ -957,6 +1007,7 @@ function M.register_command()
"push",
"rebase",
"redo",
"split",
"squash",
"st",
"status",
+25
View File
@@ -725,6 +725,26 @@ function M.handle_log_quick_squash()
end, string.format("Error squashing `%s` into it's parent", revset))
end
--- Handle log split
function M.handle_log_split()
local revset = get_revset()
if not revset or revset == "" then
return
end
require("jj.cmd").split({
rev = revset,
on_exit = function(exit_code)
if exit_code == 0 then
utils.notify(string.format("Successfully split `%s`", revset), vim.log.levels.INFO)
M.log({})
else
utils.notify(string.format("Cancelled splitting `%s`", revset), vim.log.levels.WARN)
end
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
@@ -1007,6 +1027,11 @@ function M.log_keymaps()
handler = M.handle_log_summary,
modes = { "n" },
},
split = {
desc = "Split the revision under cursor",
handler = M.handle_log_split,
modes = { "n" },
},
}
return cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps())
+100
View File
@@ -0,0 +1,100 @@
local M = {}
local utils = require("jj.utils")
local terminal = require("jj.ui.terminal")
--- Clamps a ratio value between 0.1 and 1.0, returning a default of 1.0 if the input is invalid.
---@param value? number
---@param field string
---@return number
local function clamp_ratio(value, field)
if type(value) ~= "number" or value < 0.1 or value > 1.0 then
utils.notify(
string.format("Value for field `%s` must be between `0.1` and `1.0`. Defaulted to `1.0`", field),
vim.log.levels.WARN
)
return 1.0
end
return value
end
local function build_split_command(opts)
local args = { "jj", "split" }
local rev = (opts.rev and opts.rev ~= "") and opts.rev or "@"
table.insert(args, "-r")
table.insert(args, rev)
if opts.parallel then
table.insert(args, "--parallel")
end
if opts.message then
table.insert(args, "--message")
table.insert(args, opts.message)
end
if opts.ignore_immutable then
table.insert(args, "--ignore-immutable")
end
if opts.filesets then
for _, fileset in ipairs(opts.filesets) do
table.insert(args, fileset)
end
end
return table.concat(args, " ")
end
--- Split natively
---@param opts? jj.cmd.split.opts
function M.split(opts)
if not utils.ensure_jj() then
return
end
local cmd_mod = require("jj.cmd")
opts = vim.tbl_deep_extend("force", cmd_mod.config.split or {}, opts or {}) --[[@as jj.cmd.split.opts]]
opts.height = clamp_ratio(opts.height, "height")
opts.width = clamp_ratio(opts.width, "width")
-- If it's empty do nothing
if utils.is_change_empty(opts.rev or "@") then
utils.notify(string.format("The change `%s` is empty, nothing to split.", opts.rev or "@"), vim.log.levels.INFO)
return
end
local function run_split(cmd)
terminal.run_floating(cmd, nil, {
title = " JJ Split ",
modifiable = true,
height = math.floor(vim.o.lines * opts.height),
width = math.floor(vim.o.columns * opts.width),
keep_modifiable = true,
interactive = true,
on_exit = opts.on_exit or nil,
})
end
-- If the change is immutable warn the user and prompt him
if utils.is_change_immutable(opts.rev or "@") then
vim.ui.select(
{ "Yes", "No" },
{ prompt = string.format("The change `%s` is IMMUTABLE, do you still want to split it?", opts.rev or "@") },
function(item)
if item == "Yes" then
opts.ignore_immutable = true
local cmd = build_split_command(opts)
run_split(cmd)
return
end
end
)
else
local cmd = build_split_command(opts)
run_split(cmd)
end
end
return M
+85 -44
View File
@@ -218,7 +218,9 @@ end
--- Run the command in a floating window
--- @param cmd string The command to run in the floating window
--- @param keymaps jj.core.buffer.keymap[]|nil Additional keymaps to set for this floating buffer
function M.run_floating(cmd, keymaps)
--- @param float_opts? {title?: string, height?: number, width?: number, modifiable?: boolean, keep_modifiable?: boolean, on_exit?: fun(exit_code: integer), interactive?: boolean}
function M.run_floating(cmd, keymaps, float_opts)
float_opts = float_opts or {}
-- Clean up previous state if invalid
if state.floating_buf and not vim.api.nvim_buf_is_valid(state.floating_buf) then
state.floating_buf = nil
@@ -246,10 +248,13 @@ function M.run_floating(cmd, keymaps)
-- Create new floating buffer
local buf, win = buffer.create_float({
title = " JJ Diff ",
title = float_opts.title or " JJ Diff ",
title_pos = "center",
enter = true,
bufhidden = "hide",
height = float_opts.height,
width = float_opts.width,
modifiable = float_opts.modifiable ~= nil and float_opts.modifiable or true,
win_options = {
wrap = true,
number = false,
@@ -273,47 +278,77 @@ function M.run_floating(cmd, keymaps)
})
state.floating_buf = buf
-- Create new terminal channel
local chan = vim.api.nvim_open_term(state.floating_buf, {})
if not chan or chan <= 0 then
vim.notify("Failed to create terminal channel", vim.log.levels.ERROR)
return
end
state.floating_chan = chan
local jid
local chan
if float_opts.interactive then
jid = vim.fn.jobstart(cmd, {
term = true,
on_exit = function(_, exit_code)
vim.schedule(function()
if float_opts.on_exit then
float_opts.on_exit(exit_code)
end
if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then
M.close_floating_buffer()
end
vim.cmd("stopinsert")
end)
end,
})
state.floating_chan = jid
vim.cmd("startinsert")
else
-- Create new terminal channel
chan = vim.api.nvim_open_term(state.floating_buf, {})
if not chan or chan <= 0 then
vim.notify("Failed to create terminal channel", vim.log.levels.ERROR)
return
end
state.floating_chan = chan
-- Move cursor to top before output arrives
vim.api.nvim_win_set_cursor(win, { 1, 0 })
-- Move cursor to top before output arrives
vim.api.nvim_win_set_cursor(win, { 1, 0 })
local jid = vim.fn.jobstart(cmd, {
pty = true,
width = vim.api.nvim_win_get_width(win),
height = vim.api.nvim_win_get_height(win),
env = {
TERM = "xterm-256color",
PAGER = "cat",
DELTA_PAGER = "cat",
COLORTERM = "truecolor",
DFT_BACKGROUND = "light",
},
on_stdout = function(_, data)
if not state.floating_buf or not vim.api.nvim_buf_is_valid(state.floating_buf) then
return
end
local output = table.concat(data, "\n")
vim.api.nvim_chan_send(chan, output)
end,
on_exit = function(_, _) --[[ exit_code ]]
vim.schedule(function()
if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then
buffer.set_modifiable(state.floating_buf, false)
buffer.stop_insert(state.floating_buf)
jid = vim.fn.jobstart(cmd, {
pty = true,
width = vim.api.nvim_win_get_width(win),
height = vim.api.nvim_win_get_height(win),
env = {
TERM = "xterm-256color",
PAGER = "cat",
DELTA_PAGER = "cat",
COLORTERM = "truecolor",
DFT_BACKGROUND = "light",
},
on_stdout = function(_, data)
if not state.floating_buf or not vim.api.nvim_buf_is_valid(state.floating_buf) then
return
end
end)
end,
})
local output = table.concat(data, "\n")
vim.api.nvim_chan_send(chan, output)
end,
on_exit = function(_, exit_code)
if float_opts.on_exit then
float_opts.on_exit(exit_code)
end
vim.schedule(function()
if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then
if not float_opts.keep_modifiable then
buffer.set_modifiable(state.floating_buf, false)
end
buffer.stop_insert(state.floating_buf)
end
end)
end,
})
end
if jid <= 0 then
vim.api.nvim_chan_send(chan, "Failed to start command: " .. cmd .. "\r\n")
if not jid or jid <= 0 then
if chan then
vim.api.nvim_chan_send(chan, "Failed to start command: " .. cmd .. "\r\n")
else
vim.notify("Failed to start command: " .. cmd, vim.log.levels.ERROR)
end
state.floating_chan = nil
else
state.floating_job_id = jid
@@ -328,6 +363,10 @@ function M.run_floating(cmd, keymaps)
{ modes = { "n", "v" }, lhs = "<S-a>", rhs = function() end },
{ modes = { "n", "v" }, lhs = "u", rhs = function() end },
}
-- IF it's interactive do not block them
if float_opts.interactive then
default_keymaps = {}
end
-- Merge default keymaps with provided keymaps
if keymaps and #keymaps > 0 then
@@ -336,11 +375,13 @@ function M.run_floating(cmd, keymaps)
end
end
-- Remove prompt keymaps
buffer.remove_keymaps(state.floating_buf, {
{ modes = { "n", "v" }, lhs = "[[", rhs = function() end },
{ modes = { "n", "v" }, lhs = "]]", rhs = function() end },
})
if not float_opts.interactive then
-- Remove prompt keymaps
buffer.remove_keymaps(state.floating_buf, {
{ modes = { "n", "v" }, lhs = "[[", rhs = function() end },
{ modes = { "n", "v" }, lhs = "]]", rhs = function() end },
})
end
buffer.set_keymaps(state.floating_buf, default_keymaps)
vim.b[state.floating_buf].jj_keymaps_set = true
+18
View File
@@ -307,6 +307,24 @@ function M.is_change_immutable(revset)
return vim.trim(output) == "true"
end
--- Check if a given revset is empty
--- @param revset string The revset to check
--- @return boolean True if the revset is empty, false otherwise
function M.is_change_empty(revset)
local output, success = runner.execute_command(
string.format("jj log --no-graph -r '%s' -T 'empty' --quiet", revset),
"Error checking if revset is empty",
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