--- @class jj.cmd.log local M = {} local utils = require("jj.utils") local runner = require("jj.core.runner") local parser = require("jj.core.parser") local terminal = require("jj.ui.terminal") --- @class jj.cmd.log_opts --- @field summary? boolean --- @field reversed? boolean --- @field no_graph? boolean --- @field limit? uinteger --- @field revisions? string --- @field raw_flags? string ---@type jj.cmd.log_opts local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = 20, raw_flags = nil } --- Jujutsu log --- @param opts? jj.cmd.log_opts Optional command options function M.log(opts) if not utils.ensure_jj() then return end -- If a log was already being displayed before this command we will want to maintain the cursor position if terminal.state.buf_cmd == "log" then terminal.store_cursor_position() end local jj_cmd = "jj log" local merged_opts = vim.tbl_extend("force", default_log_opts, opts or {}) -- If a raw has been given simply execute it as is if merged_opts.raw_flags then return terminal.run(string.format("%s %s", jj_cmd, merged_opts.raw_flags), M.log_keymaps()) end for key, value in pairs(merged_opts) do key = key:gsub("_", "-") if key == "limit" and value then jj_cmd = string.format("%s --%s %d", jj_cmd, key, value) elseif key == "revisions" and value then jj_cmd = string.format("%s --%s %s", jj_cmd, key, value) elseif value then jj_cmd = string.format("%s --%s", jj_cmd, key) end end terminal.run(jj_cmd, M.log_keymaps()) end --- --- Create a new change relative to the revision under the cursor in a jj log buffer. --- Behavior: --- flag == nil -> branch off the current revision --- flag == "after" -> create a new change after the current revision (-A) --- 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. --- @param flag? 'after' Position relative to the current revision; nil to branch off. --- @param ignore_immut? boolean Pass --ignore-immutable to jj when true. function M.handle_log_new(flag, ignore_immut) 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 -- Mapping for flag-specific options and messages. local flag_map = { after = { opt = "-A", err = "Error creating new change after: `%s`", ok = "Successfully created change after: `%s`", }, default = { opt = "", err = "Error creating new change branching off `%s`", ok = "Successfully created change branching off `%s`", }, } local cfg = flag_map[flag] or flag_map.default -- Build command parts local cmd_parts = { "jj", "new" } if cfg.opt ~= "" then table.insert(cmd_parts, cfg.opt) end table.insert(cmd_parts, revset) if ignore_immut then table.insert(cmd_parts, "--ignore-immutable") end local cmd = table.concat(cmd_parts, " ") 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 function M.handle_log_diff() local line = vim.api.nvim_get_current_line() local revset = parser.get_rev_from_log_line(line) if revset then local cmd = string.format("jj show --no-pager %s", revset) terminal.run_floating(cmd, require("jj.cmd").floating_keymaps()) else utils.notify("No valid revision found in the log line", vim.log.levels.ERROR) end end --- Handle describing a log line function M.handle_log_describe() local line = vim.api.nvim_get_current_line() local revset = parser.get_rev_from_log_line(line) if revset then require("jj.cmd").describe(nil, revset) else utils.notify("No valid revision found in the log line", vim.log.levels.ERROR) end end --- 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. --- @param ignore_immut? boolean Pass --ignore-immutable to jj edit when true. --- @param close_on_exit? boolean Close the log buffer after editing when true. function M.handle_log_edit(ignore_immut, close_on_exit) 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 revision, edit it. -- Build command parts. local cmd_parts = { "jj", "edit" } if ignore_immut then table.insert(cmd_parts, "--ignore-immutable") end table.insert(cmd_parts, revset) -- Build cmd string local cmd = table.concat(cmd_parts, " ") -- Try to execute cmd 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. --- On success, notifies and refreshes the log buffer. --- @param ignore_immut? boolean Pass --ignore-immutable to jj abandon when true. function M.handle_log_abandon(ignore_immut) 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 revision, abandon it. -- Build command parts. local cmd_parts = { "jj", "abandon" } if ignore_immut then table.insert(cmd_parts, "--ignore-immutable") end table.insert(cmd_parts, revset) -- Build cmd string local cmd = table.concat(cmd_parts, " ") -- Try to execute cmd runner.execute_command_async(cmd, function() utils.notify(string.format("Abandoned change: `%s`", revset), vim.log.levels.INFO) M.log({}) end, "Error abandoning change") end --- Handle fetching from `jj log` buffer. function M.handle_log_fetch() local remotes = utils.get_remotes() if not remotes or #remotes == 0 then utils.notify("No git remotes found to fetch from", vim.log.levels.ERROR) return end if #remotes > 1 then -- Prompt to select a remote vim.ui.select(remotes, { prompt = "Select remote to fetch from: ", format_item = function(item) return string.format("%s (%s)", item.name, item.url) end, }, function(choice) if choice then local cmd = string.format("jj git fetch --remote %s", choice.name) runner.execute_command_async(cmd, function() utils.notify(string.format("Fetching from %s...", choice), vim.log.levels.INFO) M.log({}) end, "Error fetching from remote") end end) else -- Only one remote, fetch from it directly 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 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(output) if output and string.find(output, "Nothing changed%.") then utils.notify("Nothing changed.", vim.log.levels.INFO) else utils.notify("Successfully pushed all to remote", vim.log.levels.INFO) M.log({}) end 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(output) if output and string.find(output, "Nothing changed%.") then utils.notify("Nothing changed.", vim.log.levels.INFO) else utils.notify(string.format("Successfully pushed bookmark for `%s`", revset), vim.log.levels.INFO) M.log({}) end end, string.format("Error pushing bookmark for `%s`", revset)) end --- Handle opening a PR/MR from `jj log` buffer for the revision under cursor --- @param list_bookmarks? boolean If true, prompt to select from all bookmarks instead of using current revision function M.handle_log_open_pr(list_bookmarks) if list_bookmarks then -- Get all bookmarks local bookmarks = utils.get_all_bookmarks() if #bookmarks == 0 then utils.notify("No bookmarks found", vim.log.levels.ERROR) return end -- Prompt to select a bookmark vim.ui.select(bookmarks, { prompt = "Select bookmark to open PR for: ", }, function(choice) if choice then utils.open_pr_for_bookmark(choice) end end) -- Return early return end -- Default behavior: parse revision and open PR 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 -- Get the bookmark for this revision 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 -- Trim and clean the bookmark (remove asterisks and whitespace) bookmark = bookmark:match("^%*?(.-)%*?$"):gsub("%s+", "") if bookmark == "" then utils.notify("[OPEN PR] No bookmark found for revision", vim.log.levels.ERROR) return end -- Open the PR using the utility function utils.open_pr_for_bookmark(bookmark) end -- Create or move bookmark at revision under cursor in `jj log` buffer local function handle_log_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 -- Get all bookmarks local bookmarks = utils.get_all_bookmarks() table.insert(bookmarks, 1, "[Create new]") -- Prompt to select or create a bookmark vim.ui.select(bookmarks, { prompt = "Select a bookmark to move or create a new: ", }, function(choice) if choice then if choice == "[Create new]" then -- Prompt for new bookmark name vim.ui.input({ prompt = "Enter new bookmark name: " }, function(input) if input and input ~= "" then local cmd = string.format("jj bookmark create %s -r %s", input, revset) runner.execute_command_async(cmd, function() utils.notify( string.format("Created bookmark `%s` at `%s`", input, revset), vim.log.levels.INFO ) M.log({}) end, "Error creating bookmark") end end) else -- Move existing bookmark to the revision local cmd = string.format("jj bookmark move %s --to %s", choice, revset) runner.execute_command_async(cmd, function() utils.notify(string.format("Moved bookmark `%s` to `%s`", choice, revset), vim.log.levels.INFO) M.log({}) end, "Error moving bookmark") end end end) end --- Resolve log keymaps from config, filtering out nil values --- @return jj.core.buffer.keymap[] function M.log_keymaps() local cmd = require("jj.cmd") -- Reduce repetition by declaring a specification table. -- Each entry maps the config key name to: -- desc: Keybind description -- handler: function to call -- args: optional list of arguments passed to handler local keymaps = cmd.config.keymaps.log or {} local close_on_edit = cmd.config.log.close_on_edit or false local specs = { edit = { desc = "Checkout revision under cursor", handler = M.handle_log_edit, args = { false, close_on_edit }, }, edit_immutable = { desc = "Checkout revision under cursor (ignores immutability)", handler = M.handle_log_edit, args = { true, close_on_edit }, }, describe = { desc = "Describe revision under cursor", handler = M.handle_log_describe, }, diff = { desc = "Diff revision under cursor", handler = M.handle_log_diff, }, new = { desc = "Create new change branching off revision under cursor", handler = M.handle_log_new, args = { nil, false }, }, new_after = { desc = "Create new change after revision under cursor", handler = M.handle_log_new, args = { "after", false }, }, new_after_immutable = { desc = "Create new change after revision under cursor (ignore immutable)", handler = M.handle_log_new, args = { "after", true }, }, undo = { desc = "Undo last change", handler = cmd.undo, }, redo = { desc = "Redo last undone change", handler = cmd.redo, }, abandon = { desc = "Abandon revision under cursor", handler = M.handle_log_abandon, -- As of now i'm only exposing the non ignore-immutable version of abandon in the 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, }, open_pr = { desc = "Open PR/MR for revision under cursor", handler = M.handle_log_open_pr, }, open_pr_list = { desc = "Open PR/MR by selecting from all bookmarks", handler = M.handle_log_open_pr, args = { true }, }, bookmark = { desc = "Create or move bookmark at revision under cursor", handler = handle_log_bookmark, }, } return cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps()) end return M