feat(log): Implement squash and quick squash from the log buffer.

- Squash: behaves like rebase, the user can select 1 or more
      revisions to squash and then a destination
    - Quick squash: `-r {rev} -u --ignore-immutable` flag from jj allowing
      for quick visuall squashes
This commit is contained in:
NicolasGB
2026-01-08 18:29:04 +01:00
committed by Nicolas GB
parent a23997d3bc
commit 9d9e2379fd
5 changed files with 311 additions and 118 deletions
+36 -3
View File
@@ -19,6 +19,7 @@
- [Abandon changes from the log buffer](#abandon-changes-from-the-log-buffer)
- [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)
- [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)
@@ -53,7 +54,7 @@
- `diff` - Show changes with optional filtering by current file
- `new` - Create a new change with optional parent selection
- `edit` - Edit a change
- `squash` - Squash the current diff to it's parent
- `squash` - Squash the current diff to its parent or interactive squash mode from the log buffer
- `rebase` - Rebase changes to a destination
- `bookmark create/delete` - Create and delete bookmarks
- `undo` - Undo the last operation
@@ -119,6 +120,31 @@ You can fetch and push directly from the log buffer:
- Select from existing bookmarks to move them
- Or create a new bookmark at that revision
### Squash changes from the log buffer
Enter an interactive squash mode to squash one or more changes into a destination:
- `s` - Enter squash mode targeting the revision under cursor (in normal mode) or selected revisions (in visual mode)
- `<S-s>` - Quick squash the revision under cursor into its parent
Once in squash mode, the interface highlights your selection and the current squash destination:
- Selected changes are highlighted in your configured `selected_hl` color (default: dark magenta)
- The cursor position (potential squash destination) is highlighted in your configured `targeted_hl` color (default: green)
- Move the cursor to preview different squash destinations with live highlighting
From squash mode, choose how to squash:
- `<CR>` - Squash into (`-t`) the revision under cursor
- `<S-CR>` - Squash into (`-t`) ignoring immutability
- `<Esc>` or `<C-c>` - Exit squash mode without making changes
**Visual mode selection:** Select multiple revisions in visual mode before pressing `s` to squash them all at once. The plugin extracts each selected revision and squashes them together.
**Quick squash:** In normal mode, press `<S-s>` to quickly squash the current revision into its parent. This ignores immutability.
![Squash-from-log](https://github.com/NicolasGB/jj.nvim/raw/main/assets/squash.gif)
### Rebase changes from the log buffer
Enter an interactive rebase mode directly from the log buffer to rebase one or more changes:
@@ -311,6 +337,13 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing
before_immutable = "<S-b>", -- Rebase before revision under cursor (ignore immutability)
exit_mode = { "<Esc>", "<C-c>" }, -- Exit rebase mode
},
squash = "s", -- Enter squash mode targeting revision under cursor or selected revisions
squash_mode = {
into = "<CR>", -- Squash into revision under cursor
into_immutable = "<S-CR>", -- Squash into revision under cursor (ignore immutability)
exit_mode = { "<Esc>", "<C-c>" }, -- Exit squash mode
},
quick_squash = "<S-s>", -- Quick squash revision under cursor into its parent (ignore immutability)
},
-- Status buffer keymaps (set to nil to disable)
status = {
@@ -320,8 +353,8 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing
-- Close keymaps (shared across all buffers)
close = { "q", "<Esc>" },
},
}
}
}}
```
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+16
View File
@@ -46,7 +46,11 @@ local status_module = require("jj.cmd.status")
--- @field open_pr? string|string[]
--- @field open_pr_list? string|string[]
--- @field bookmark? string|string[]
--- @field rebase? string|string[]
--- @field rebase_mode? jj.cmd.rebase.keymaps
--- @field squash? string|string[]
--- @field squash_mode? jj.cmd.squash.keymaps
--- @field quick_squash? string|string[]
--- @class jj.cmd.rebase.keymaps
--- @field onto? string|string[]
@@ -57,6 +61,11 @@ local status_module = require("jj.cmd.status")
--- @field before_immutable? string|string[]
--- @field exit_mode? string|string[]
--- @class jj.cmd.squash.keymaps
--- @field into? string|string[]
--- @field into_immutable? string|string[]
--- @field exit_mode? string|string[]
--- @class jj.cmd.bookmark
--- @field prefix? string Prefix to append when creating a bookmark
@@ -138,6 +147,13 @@ M.config = {
before_immutable = "<S-b>",
exit_mode = { "<Esc>", "<C-c>" },
},
squash = "s",
squash_mode = {
into = "<CR>",
into_immutable = "<S-CR>",
exit_mode = { "<Esc>", "<C-c>" },
},
quick_squash = "<S-s>",
},
status = {
open_file = "<CR>",
+196 -63
View File
@@ -8,8 +8,8 @@ local terminal = require("jj.ui.terminal")
local log_selected_hl_group = "JJLogSelectedHlGroup"
local log_selected_ns_id = vim.api.nvim_create_namespace(log_selected_hl_group)
local log_rebase_target_hl_group = "JJLogRebaseTargetHlGroup"
local log_rebase_target_ns_id = vim.api.nvim_create_namespace(log_rebase_target_hl_group)
local log_special_mode_target_hl_group = "JJLogSpecialModeTargetHlGroup"
local log_special_mode_target_ns_id = vim.api.nvim_create_namespace(log_special_mode_target_hl_group)
local rebase_mode_autocmd_id = nil
local last_rebase_target_line = nil
local HIGHLIGHT_RANGE = 2 -- Revision line + description line
@@ -33,7 +33,7 @@ function M.init_log_highlights()
end
vim.api.nvim_set_hl(0, log_selected_hl_group, cfg.selected)
vim.api.nvim_set_hl(0, log_rebase_target_hl_group, cfg.targeted)
vim.api.nvim_set_hl(0, log_special_mode_target_hl_group, cfg.targeted)
end
--- Find the revision line under cursor, handling description lines
@@ -124,7 +124,7 @@ end
local function apply_target_highlight(buf, revset_line, hl_group)
vim.api.nvim_buf_set_extmark(
buf,
log_rebase_target_ns_id,
log_special_mode_target_ns_id,
revset_line,
0,
{ end_line = revset_line + HIGHLIGHT_RANGE, end_col = 0, hl_group = hl_group }
@@ -137,7 +137,7 @@ local function clear_target_highlight(buf)
if last_rebase_target_line ~= nil then
vim.api.nvim_buf_clear_namespace(
buf,
log_rebase_target_ns_id,
log_special_mode_target_ns_id,
last_rebase_target_line,
last_rebase_target_line + HIGHLIGHT_RANGE
)
@@ -146,7 +146,7 @@ local function clear_target_highlight(buf)
end
--- Update rebase target highlight on cursor movement
local function update_rebase_target_highlight()
local function update_special_mode_target_highlight()
local buf = terminal.state.buf
if not buf then
return
@@ -162,7 +162,71 @@ local function update_rebase_target_highlight()
-- Only highlight if rev is not in selection
local is_in_selection = vim.b.jj_rebase_revsets and string.find(vim.b.jj_rebase_revsets, rev, 1, true)
if not is_in_selection then
apply_target_highlight(buf, revset_line, log_rebase_target_hl_group)
apply_target_highlight(buf, revset_line, log_special_mode_target_hl_group)
end
end
--- Extracts the revsets from either the current line or the selected lines in visual mode
--- @return string|nil The revsets string or nil if none found
local function extract_revsets_from_terminal_buffer()
local buf = terminal.state.buf
if not buf then
utils.notify("No open log buffer", vim.log.levels.ERROR)
return nil
end
local revsets_str = nil
local mode = vim.fn.mode()
-- Get revsets based on mode
if mode == "n" then
revsets_str = get_revset()
elseif mode == "v" or mode == "V" then
local start_line = vim.fn.line("v")
local end_line = vim.fn.line(".")
if start_line > end_line then
start_line, end_line = end_line, start_line
end
local lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false)
local revsets = parser.get_all_revsets(lines)
if not revsets or #revsets == 0 then
utils.notify("No valid revisions found in selected lines", vim.log.levels.ERROR)
return nil
end
revsets_str = table.concat(revsets, " | ")
-- Exit visual mode after extracting revsets
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<Esc>", true, false, true), "n", true)
else
return nil
end
return revsets_str
end
--- Setup highlights for selected revisions in the log buffer
local function setup_selected_highlights()
local buf = terminal.state.buf
if not buf then
return
end
-- Set highlights
local marks = get_highlight_marks()
if not marks or #marks == 0 then
utils.notify("No valid revisions found to highlight", vim.log.levels.ERROR)
return
end
for _, mark in ipairs(marks) do
vim.api.nvim_buf_set_extmark(
buf,
log_selected_ns_id,
mark.line,
mark.col,
{ end_line = mark.end_line, end_col = mark.end_col, hl_group = log_selected_hl_group }
)
end
end
@@ -178,7 +242,7 @@ function M.log(opts)
terminal.store_cursor_position()
-- Make sure to clear highlights before rerunning since the previous log buffer might have some
vim.api.nvim_buf_clear_namespace(terminal.state.buf, log_selected_ns_id, 0, -1)
vim.api.nvim_buf_clear_namespace(terminal.state.buf, log_rebase_target_ns_id, 0, -1)
vim.api.nvim_buf_clear_namespace(terminal.state.buf, log_special_mode_target_ns_id, 0, -1)
end
local jj_cmd = "jj log"
@@ -528,65 +592,49 @@ end
--- Rebase bookmark(s)
function M.handle_log_rebase()
local buf = terminal.state.buf
if not buf then
utils.notify("No open log buffer", vim.log.levels.ERROR)
local revsets_str = extract_revsets_from_terminal_buffer()
-- Validate revsets
if not revsets_str or revsets_str == "" then
return
end
vim.b.jj_rebase_revsets = revsets_str
local revsets_str = nil
local mode = vim.fn.mode()
-- Set highlights
setup_selected_highlights()
-- Get revsets based on mode
if mode == "n" then
revsets_str = get_revset()
elseif mode == "v" or mode == "V" then
local start_line = vim.fn.line("v")
local end_line = vim.fn.line(".")
if start_line > end_line then
start_line, end_line = end_line, start_line
end
local lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false)
local revsets = parser.get_all_revsets(lines)
if not revsets or #revsets == 0 then
utils.notify("No valid revisions found in selected lines", vim.log.levels.ERROR)
return
end
revsets_str = table.concat(revsets, " | ")
-- Exit visual mode before transition
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<Esc>", true, false, true), "n", true)
else
return
end
M.transition_mode("rebase")
utils.notify("Rebase `started`.", vim.log.levels.INFO, 500)
end
--- Squash bookmarks(s)
function M.handle_log_squash()
local revsets_str = extract_revsets_from_terminal_buffer()
-- Validate revsets
if not revsets_str or revsets_str == "" then
return
end
vim.b.jj_rebase_revsets = revsets_str
vim.b.jj_squash_revsets = revsets_str
-- Set highlights
local marks = get_highlight_marks()
if not marks or #marks == 0 then
utils.notify("No valid revisions found to highlight during rebase", vim.log.levels.ERROR)
setup_selected_highlights()
M.transition_mode("squash")
utils.notify("Squash `started`.", vim.log.levels.INFO, 500)
end
--- Quick squash the bookmark under the cursor into it's parent
function M.handle_log_quick_squash()
local revset = get_revset()
if not revset or revset == "" then
return
end
for _, mark in ipairs(marks) do
vim.api.nvim_buf_set_extmark(
buf,
log_selected_ns_id,
mark.line,
mark.col,
{ end_line = mark.end_line, end_col = mark.end_col, hl_group = log_selected_hl_group }
)
end
M.transition_mode("rebase")
utils.notify("Rebase `started`.", vim.log.levels.INFO, 500)
local cmd = string.format("jj squash -r %s -u --ignore-immutable", revset)
utils.notify(string.format("Squashing `%s` into it's parent...", revset), vim.log.levels.INFO)
runner.execute_command_async(cmd, function()
utils.notify(string.format("Successfully squashed `%s` into it's parent", revset), vim.log.levels.INFO)
M.log({})
end, string.format("Error squashing `%s` into it's parent", revset))
end
--- Resolve log keymaps from config, filtering out nil values
@@ -698,6 +746,16 @@ function M.log_keymaps()
handler = M.handle_log_rebase,
modes = { "n", "v" },
},
squash = {
desc = "Squash bookmark(s)",
handler = M.handle_log_squash,
modes = { "n", "v" },
},
quick_squash = {
desc = "Squash the bookmark under the cursor into it's parent (-r) keeping parent's message (-u), alwas ignores immutability",
handler = M.handle_log_quick_squash,
modes = { "n" },
},
}
return cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps())
@@ -749,7 +807,37 @@ function M.rebase_keymaps()
},
exit_mode = {
desc = "Exit rebase to normal mode",
handler = M.handle_rebase_mode_exit,
handler = M.handle_special_mode_exit,
modes = { "n" },
},
}
return cmd.resolve_keymaps_from_specs(keymaps, spec)
end
--- Squash mode keymaps
--- @return jj.core.buffer.keymap[]
function M.squash_keymaps()
local cmd = require("jj.cmd")
local keymaps = cmd.config.keymaps.log.squash_mode or {}
--- @type jj.cmd.keymap_specs
local spec = {
into = {
desc = "Squash into (-t) the revision under cursor",
handler = M.handle_squash_execute,
args = { "into" },
modes = { "n" },
},
into_immutable = {
desc = "Squash onto (-i) the revision under cursor (ignores immutability)",
handler = M.handle_squash_execute,
args = { "into", true },
modes = { "n" },
},
exit_mode = {
desc = "Exit squash to normal mode",
handler = M.handle_special_mode_exit,
modes = { "n" },
},
}
@@ -765,12 +853,14 @@ function M.get_keymaps_for_mode(mode)
return M.log_keymaps()
elseif mode == "rebase" then
return M.rebase_keymaps()
elseif mode == "squash" then
return M.squash_keymaps()
end
return {}
end
--- Transition between buffer modes by swapping keymaps
--- @param target_mode string Target mode name (e.g., "normal", "rebase")
--- @param target_mode "normal"|"rebase"|"squash" Target mode name (e.g., "normal", "rebase")
function M.transition_mode(target_mode)
-- Get the mode keymaps
if target_mode == vim.b.jj_mode then
@@ -782,19 +872,19 @@ function M.transition_mode(target_mode)
terminal.replace_terminal_keymaps(new_keymaps)
-- Set up or tear down rebase mode autocmd
if target_mode == "rebase" then
if target_mode ~= "normal" then
rebase_mode_autocmd_id = vim.api.nvim_create_autocmd("CursorMoved", {
buffer = terminal.state.buf,
callback = update_rebase_target_highlight,
callback = update_special_mode_target_highlight,
})
-- Highlight initial position
update_rebase_target_highlight()
update_special_mode_target_highlight()
elseif rebase_mode_autocmd_id then
vim.api.nvim_del_autocmd(rebase_mode_autocmd_id)
rebase_mode_autocmd_id = nil
-- Clear target highlight
local buf = terminal.state.buf or 0
vim.api.nvim_buf_clear_namespace(buf, log_rebase_target_ns_id, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, log_special_mode_target_ns_id, 0, -1)
last_rebase_target_line = nil
end
@@ -802,8 +892,8 @@ function M.transition_mode(target_mode)
vim.b.jj_mode = target_mode
end
--- Handle rebase mode exit
function M.handle_rebase_mode_exit()
--- Handle special mode exit
function M.handle_special_mode_exit()
-- Clear stored revsets
vim.b.jj_rebase_revsets = nil
@@ -811,7 +901,7 @@ function M.handle_rebase_mode_exit()
-- Clear highlights
local buf = terminal.state.buf or 0
vim.api.nvim_buf_clear_namespace(buf, log_selected_ns_id, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, log_rebase_target_ns_id, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, log_special_mode_target_ns_id, 0, -1)
utils.notify("Rebase `canceled`", vim.log.levels.INFO, 500)
end
@@ -853,7 +943,7 @@ function M.handle_rebase_execute(mode, ignore_immut)
-- Clear all highlighting before transitioning
local buf = terminal.state.buf or 0
vim.api.nvim_buf_clear_namespace(buf, log_selected_ns_id, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, log_rebase_target_ns_id, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, log_special_mode_target_ns_id, 0, -1)
M.transition_mode("normal")
-- Refresh log
@@ -861,4 +951,47 @@ function M.handle_rebase_execute(mode, ignore_immut)
end, "Error during rebase onto")
end
--- Handle squash execution
--- @param mode "into" Squash mode
--- @param ignore_immut boolean? Wether or not to ignore immutability
function M.handle_squash_execute(mode, ignore_immut)
-- Get all revsets in the format "xx xy xz"
local revsets = vim.b.jj_squash_revsets
local destination_revset = get_revset()
if not destination_revset or destination_revset == "" then
return
end
utils.notify(string.format("Squashing...", revsets, mode, destination_revset), vim.log.levels.INFO, 500)
-- U flag to keep destination's message
local cmd = string.format("jj squash -f '%s' -u", revsets)
if mode == "into" then
cmd = cmd .. string.format(" -t %s", destination_revset)
end
-- If ignore_immut is true, add the flag
-- This is not currently exposed in keymaps but could be in the future
if ignore_immut then
cmd = cmd .. " --ignore-immutable"
end
runner.execute_command_async(cmd, function()
utils.notify(
string.format("Squashed `%s` into `%s` successfully", revsets, destination_revset),
vim.log.levels.INFO
)
vim.b.jj_squash_revsets = nil
-- Clear all highlighting before transitioning
local buf = terminal.state.buf or 0
vim.api.nvim_buf_clear_namespace(buf, log_selected_ns_id, 0, -1)
vim.api.nvim_buf_clear_namespace(buf, log_special_mode_target_ns_id, 0, -1)
M.transition_mode("normal")
-- Refresh log
M.log({})
end, "Error during squash into")
end
return M
+63 -52
View File
@@ -52,30 +52,32 @@ end
--- Help for terminal buffer
function M.keymap_help()
-- get the normal mode keys defined for the current buffer
local keys = vim.api.nvim_buf_get_keymap(0, "n")
-- get the normal mode keys defined for the current buffer
local keys = vim.api.nvim_buf_get_keymap(0, "n")
-- sort the keys table by desc
table.sort(keys, function(a, b) return (a.desc or "") < (b.desc or "") end)
-- sort the keys table by desc
table.sort(keys, function(a, b)
return (a.desc or "") < (b.desc or "")
end)
-- Figure out the width of the longest key for later formatting
-- Ignore keys for entries without a desc
local max_key_width = 0
for _, mapping in ipairs(keys) do
local desc = mapping["desc"]
if desc ~= nil then
local key = mapping["lhs"]
local key_width = vim.api.nvim_strwidth(key)
if key_width > max_key_width then
max_key_width = key_width
end
end
end
-- Figure out the width of the longest key for later formatting
-- Ignore keys for entries without a desc
local max_key_width = 0
for _, mapping in ipairs(keys) do
local desc = mapping["desc"]
if desc ~= nil then
local key = mapping["lhs"]
local key_width = vim.api.nvim_strwidth(key)
if key_width > max_key_width then
max_key_width = key_width
end
end
end
-- create a buffer and floating window to show the key mappings
local buf, win = buffer.create_float({
title = " Key mappings ",
title_pos = "left",
-- create a buffer and floating window to show the key mappings
local buf, win = buffer.create_float({
title = " Key mappings ",
title_pos = "left",
enter = true,
bufhidden = "hide",
win_options = {
@@ -85,41 +87,38 @@ function M.keymap_help()
cursorline = false,
signcolumn = "no",
},
})
})
-- helper function to pad a given key with spaces if necessary such that
-- its size will match max_key_width calculated above.
local function space_pad(key)
local key_width = vim.api.nvim_strwidth(key)
local delta = max_key_width - key_width
if delta > 0 then
return key .. string.rep(" ", delta)
end
return key
end
-- helper function to pad a given key with spaces if necessary such that
-- its size will match max_key_width calculated above.
local function space_pad(key)
local key_width = vim.api.nvim_strwidth(key)
local delta = max_key_width - key_width
if delta > 0 then
return key .. string.rep(" ", delta)
end
return key
end
-- create formated entries for items with a non-nil desc
local lines = {}
for _, entry in ipairs(keys) do
if entry["desc"] ~= nil then
local line = string.format(" %s %s",
space_pad(entry["lhs"]),
entry["desc"])
table.insert(lines, line)
end
-- create formated entries for items with a non-nil desc
local lines = {}
for _, entry in ipairs(keys) do
if entry["desc"] ~= nil then
local line = string.format(" %s %s", space_pad(entry["lhs"]), entry["desc"])
table.insert(lines, line)
end
end
end
-- add some helper text at the end
table.insert(lines, "")
table.insert(lines, ' Use "q" or ESC to close this window')
-- add some helper text at the end
table.insert(lines, "")
table.insert(lines, ' Use "q" or ESC to close this window')
-- add the formatted lines to the buffer
vim.api.nvim_buf_set_lines(buf, 3, -1, false, lines)
-- add the formatted lines to the buffer
vim.api.nvim_buf_set_lines(buf, 3, -1, false, lines)
-- Use q or ESC to close the buffer
vim.keymap.set("n", "q", "<cmd>close<CR>", { buffer = buf })
vim.keymap.set("n", "<Esc>", "<cmd>close<CR>", { buffer = buf })
-- Use q or ESC to close the buffer
vim.keymap.set("n", "q", "<cmd>close<CR>", { buffer = buf })
vim.keymap.set("n", "<Esc>", "<cmd>close<CR>", { buffer = buf })
end
--- Close the current terminal buffer if it exists
@@ -282,6 +281,12 @@ 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 },
})
buffer.set_keymaps(state.floating_buf, default_keymaps)
vim.b[state.floating_buf].jj_keymaps_set = true
end
@@ -410,7 +415,7 @@ function M.run(cmd, keymaps)
-- Set base keymaps only if they haven't been set for this buffer yet
if not vim.b[state.buf].jj_keymaps_set then
buffer.set_keymaps(state.buf, {
{ modes = { "n" }, lhs = "g?", rhs = M.keymap_help },
{ modes = { "n" }, lhs = "g?", rhs = M.keymap_help },
-- Disable insert, command and append modes
{ modes = { "n", "v" }, lhs = "i", rhs = function() end },
{ modes = { "n", "v" }, lhs = "c", rhs = function() end },
@@ -421,6 +426,12 @@ function M.run(cmd, keymaps)
vim.b[state.buf].jj_keymaps_set = true
end
-- Remove prompt keymaps
buffer.remove_keymaps(state.buf, {
{ modes = { "n", "v" }, lhs = "[[", rhs = function() end },
{ modes = { "n", "v" }, lhs = "]]", rhs = function() end },
})
-- Remove command-specific keymaps from previous runs
if vim.b[state.buf].jj_command_keymaps then
buffer.remove_keymaps(state.buf, vim.b[state.buf].jj_command_keymaps)