diff --git a/README.md b/README.md index 81572ef..0f5c182 100644 --- a/README.md +++ b/README.md @@ -849,20 +849,25 @@ The `log` function accepts an options table: ```lua local cmd = require("jj.cmd") cmd.log({ - summary = false, -- Show summary of changes (default: false) - reversed = false, -- Reverse the log order (default: false) - no_graph = false, -- Hide the graph (default: false) - limit = 20, -- Limit number of entries (default: 20) - revisions = "'all()'" -- Revision specifier (default: all reachable) + summary = false, -- Show summary of changes (default: false) + reversed = false, -- Reverse the log order (default: false) + no_graph = false, -- Hide the graph (default: false) + limit = 50, -- Optional limit; omitted by default + revisions = "main::@", -- Optional revset + raw_flags = { "-r", "main::@" } -- Optional raw argv flags for `jj log` }) -- Examples: -cmd.log({ limit = 50 }) -- Show 50 entries -cmd.log({ revisions = "'main::@'" }) -- Show commits between main and current -cmd.log({ summary = true, limit = 100 }) -- Show summary with high limit -cmd.log({ raw = "-r 'main::@' --summary --no-graph" }) -- Pass raw flags directly +cmd.log({ limit = 50 }) +cmd.log({ revisions = "main::@" }) +cmd.log({ summary = true, limit = 100 }) +cmd.log({ raw_flags = { "-r", "main::@", "--summary", "--no-graph" } }) ``` +> [!IMPORTANT] +> Breaking change: `cmd.log` now expects `raw_flags` as a `string[]` argv list. +> The old `raw = "..."` string format is no longer supported. + ## Configuration Examples ### New Command Options @@ -872,17 +877,21 @@ The `new` function accepts an options table: ```lua local cmd = require("jj.cmd") cmd.new({ - show_log = false, -- Display log after creating new change (default: false) - with_input = false, -- Prompt for parent revision (default: false) - args = "" -- Additional arguments to pass to jj new + show_log = false, -- Display log after creating new change (default: false) + with_input = false, -- Prompt for parent revision (default: false) + args = { "--before", "@" } -- Additional argv arguments passed to `jj new` }) -- Examples: -cmd.new({ show_log = true }) -- Create new and show log -cmd.new({ show_log = true, with_input = true }) -- Prompt for parent -cmd.new({ args = "--before @" }) -- Pass custom args +cmd.new({ show_log = true }) +cmd.new({ show_log = true, with_input = true }) +cmd.new({ args = { "--before", "@" } }) ``` +> [!IMPORTANT] +> Breaking change: `cmd.new` now expects `args` as a `string[]` argv list. +> The old `args = "--before @"` string format is no longer supported. + ### Resolve Command Options The `resolve` function accepts an options table: diff --git a/lua/jj/annotate.lua b/lua/jj/annotate.lua index 06573e7..3a2e964 100644 --- a/lua/jj/annotate.lua +++ b/lua/jj/annotate.lua @@ -4,6 +4,7 @@ local utils = require("jj.utils") local runner = require("jj.core.runner") local buffer = require("jj.core.buffer") local parser = require("jj.core.parser") +local jj_args = require("jj.core.args") --TODO: Maybe if annotating on the file is slow we could cache the annotations. @@ -41,15 +42,17 @@ end --- @param path string --- @param template string --- @param rev? string ---- @return string +--- @return string[] local function build_annotate_cmd(path, template, rev) - local rev_flag = rev and rev ~= "" and string.format("-r %s ", vim.fn.shellescape(rev)) or "" - return string.format( - "jj file annotate %s%s -T %s", - rev_flag, - vim.fn.shellescape(path), - vim.fn.shellescape(template) - ) + local cmd = { "jj", "file", "annotate" } + if rev and rev ~= "" then + table.insert(cmd, "-r") + table.insert(cmd, rev) + end + table.insert(cmd, path) + table.insert(cmd, "-T") + table.insert(cmd, template) + return cmd end --- Sets the highlights for the blame bufer @@ -170,10 +173,17 @@ local function handle_enter() -- Get the local name local filename = vim.b[0].jj_annotation_file - local cmd = string.format("jj diff --git -r %s %s", parts.rev.value, filename) + local cmd = { + "jj", + "diff", + "--git", + "-r", + parts.rev.value, + jj_args.fileset(filename), + } -- Run the command - local output, success = runner.execute_command(cmd, "Could not run diff from annotation") + local output, success = runner.execute(cmd, "Could not run diff from annotation") if not success or not output or output == "" then return end @@ -221,7 +231,7 @@ function M.file() end local raw_output, success = - runner.execute_command(build_annotate_cmd(filename, template, revision), "Failed to annotate file") + runner.execute(build_annotate_cmd(filename, template, revision), "Failed to annotate file") if not success or not raw_output then return end @@ -355,7 +365,7 @@ function M.line() local line_num = vim.fn.line(".") local raw_output, success = - runner.execute_command(build_annotate_cmd(filename, template, revision), "Failed to annotate line") + runner.execute(build_annotate_cmd(filename, template, revision), "Failed to annotate line") if not success or not raw_output then return end @@ -374,10 +384,16 @@ function M.line() return end - local desc, ok = runner.execute_command( - string.format("jj log -r %s -T 'self.description()' --no-graph", parsed_line.rev.value), - "Failed getting description" - ) + local cmd = { + "jj", + "log", + "-r", + parsed_line.rev.value, + "-T", + "self.description()", + "--no-graph", + } + local desc, ok = runner.execute(cmd, "Failed getting description") if not ok or not desc then return end diff --git a/lua/jj/cmd/describe.lua b/lua/jj/cmd/describe.lua index ecefa74..764223e 100644 --- a/lua/jj/cmd/describe.lua +++ b/lua/jj/cmd/describe.lua @@ -19,21 +19,24 @@ local default_describe_opts = { --- @param revset? string The revision to describe --- @param sync? boolean Whether to execute command synchronously local function execute_describe(description, revset, sync) - local cmd = "jj describe" + local cmd = { + "jj", + "describe", + } if revset then - cmd = cmd .. " -r " .. revset + table.insert(cmd, "-r") + table.insert(cmd, revset) end - cmd = cmd .. " --stdin" + table.insert(cmd, "--stdin") -- Use --stdin to properly handle multi-line and special characters if sync then - runner.execute_command_sync(cmd, function() - utils.notify("Description set.", vim.log.levels.INFO) - end, "Failed to describe", description) + runner.execute(cmd, "Failed to describe", description) + utils.notify("Description set.", vim.log.levels.INFO) return end - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.notify("Description set.", vim.log.levels.INFO) end, "Failed to describe", description) end diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index 49a8609..48b19b0 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -354,7 +354,7 @@ end --- @class jj.cmd.new_opts --- @field show_log? boolean Whether or not to display the log command after creating a new --- @field with_input? boolean Whether or not to use nvim input to decide the parent of the new commit ---- @field args? string The arguments to append to the new command +--- @field args? string[] The arguments to append to the new command -- Jujutsu new --- @param opts? jj.cmd.new_opts @@ -365,9 +365,9 @@ function M.new(opts) opts = opts or {} - --- @param cmd string + --- @param cmd string[] local function execute_new(cmd) - runner.execute_command_async(cmd, function() + runner.execute_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 @@ -386,15 +386,15 @@ function M.new(opts) prompt = "Parent(s) of the new change [default: @]", }, function(input) if input then - execute_new(string.format("jj new %s", input)) + execute_new({ "jj", "new", input }) end terminal.close_terminal_buffer() end) else -- Otherwise follow a classic flow for inputing - local cmd = "jj new" + local cmd = { "jj", "new" } if opts.args then - cmd = string.format("jj new %s", opts.args) + vim.list_extend(cmd, opts.args) end execute_new(cmd) @@ -416,7 +416,7 @@ function M.edit() default = "", }, function(input) if input then - runner.execute_command_async(string.format("jj edit %s", input), function() + runner.execute_async({ "jj", "edit", input }, function() utils.reload_changed_file_buffers() M.log({}) end, "Error editing change") @@ -432,8 +432,8 @@ function M.squash() return end - local cmd = "jj squash" - runner.execute_command_async(cmd, function() + local cmd = { "jj", "squash" } + runner.execute_async(cmd, function() utils.notify("Command `squash` was succesful.", vim.log.levels.INFO) if terminal.is_log_buffer_open() then M.log() @@ -505,9 +505,9 @@ function M.rebase() default = "trunk()", }, function(input) if input then - local cmd = string.format("jj rebase -d '%s'", input) + local cmd = { "jj", "rebase", "-d", input } utils.notify(string.format("Beginning rebase on %s", input), vim.log.levels.INFO) - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.notify("Rebase successful.", vim.log.levels.INFO) M.log({}) end, "Error rebasing") @@ -539,8 +539,8 @@ function M.bookmark_create(opts) default = "@", }, function(revset) revset = revset or "@" - local cmd = string.format("jj b c %s -r %s", input, revset) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "bookmark", "create", input, "-r", revset } + runner.execute_async(cmd, function() utils.notify( string.format("Bookmark `%s` created successfully for %s", input, revset), vim.log.levels.INFO @@ -575,8 +575,8 @@ function M.bookmark_move() default = "@", }, function(revset) if revset then - local cmd = string.format("jj b m %s --to %s -B", choice, revset) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "bookmark", "move", choice, "--to", revset, "-B" } + runner.execute_async(cmd, function() utils.notify( string.format("Bookmark `%s` moved successfully to %s", choice, revset), vim.log.levels.INFO @@ -604,8 +604,8 @@ function M.bookmark_delete() prompt = "Bookmark name: ", }, function(input) if input then - local cmd = string.format("jj b d %s", input) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "bookmark", "delete", input } + runner.execute_async(cmd, function() utils.notify(string.format("Bookmark `%s` deleted successfully.", input), vim.log.levels.INFO) M.log({}) end, "Error deleting bookmark") @@ -631,16 +631,13 @@ function M.bookmark_track() vim.ui.select(bookmarks, { prompt = "Which bookmark do you want to track?" }, function(choice) if choice then - runner.execute_command_async( - string.format("jj bookmark track %s --quiet", vim.fn.shellescape(choice)), - function() - utils.notify(string.format("Bookmark `%s` is now tracked.", choice)) - if log_open then - M.log() - end - end, - "Could not track bookmark" - ) + local cmd = { "jj", "bookmark", "track", choice, "--quiet" } + runner.execute_async(cmd, function() + utils.notify(string.format("Bookmark `%s` is now tracked.", choice)) + if log_open then + M.log() + end + end, "Could not track bookmark") end end) end @@ -661,16 +658,13 @@ function M.bookmark_forget() vim.ui.select(bookmarks, { prompt = "Which bookmark do you want to forget?" }, function(choice) if choice then - runner.execute_command_async( - string.format("jj bookmark forget %s --quiet", vim.fn.shellescape(choice)), - function() - utils.notify(string.format("Bookmark `%s` is now untracked.", choice)) - if log_open then - M.log() - end - end, - "Could not forget bookmark" - ) + local cmd = { "jj", "bookmark", "forget", choice, "--quiet" } + runner.execute_async(cmd, function() + utils.notify(string.format("Bookmark `%s` is now untracked.", choice)) + if log_open then + M.log() + end + end, "Could not forget bookmark") end end) end @@ -681,8 +675,8 @@ function M.undo() return end - local cmd = "jj undo" - runner.execute_command_async(cmd, function() + local cmd = { "jj", "undo" } + runner.execute_async(cmd, function() utils.notify("Command `undo` was succesful.", vim.log.levels.INFO) if terminal.is_log_buffer_open() then M.log({}) @@ -696,8 +690,8 @@ function M.redo() return end - local cmd = "jj redo" - runner.execute_command_async(cmd, function() + local cmd = { "jj", "redo" } + runner.execute_async(cmd, function() utils.notify("Command `redo` was succesful.", vim.log.levels.INFO) if terminal.is_log_buffer_open() then M.log({}) @@ -717,8 +711,8 @@ function M.abandon() default = "", }, function(input) if input then - local cmd = string.format("jj abandon %s", input) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "abandon", input } + runner.execute_async(cmd, function() utils.notify("Change abandoned successfully.", vim.log.levels.INFO) M.log({}) end, "Error abandoning change") @@ -753,8 +747,8 @@ function M.fetch() end, }, function(choice) if choice then - local cmd = string.format("jj git fetch --remote %s", choice.name) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "git", "fetch", "--remote", choice.name } + runner.execute_async(cmd, function() utils.notify(string.format("Fetching from %s...", choice), vim.log.levels.INFO) if log_open then M.log({}) @@ -764,9 +758,9 @@ function M.fetch() end) else -- Only one remote, fetch from it directly - local cmd = "jj git fetch" + local cmd = { "jj", "git", "fetch" } utils.notify("Fetching from remote...", vim.log.levels.INFO) - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.notify("Successfully fetched from remote", vim.log.levels.INFO) if log_open then M.log({}) @@ -839,23 +833,23 @@ function M.push(opts) local notify_msg = "Pushing bookmarks `ALL` bookmarks" - local cmd = "jj git push" + local cmd = { "jj", "git", "push" } if opts.bookmark then notify_msg = string.format("Pushing bookmark `%s`", opts.bookmark) - cmd = string.format("%s --bookmark %s", cmd, opts.bookmark) + vim.list_extend(cmd, { "--bookmark", opts.bookmark }) elseif opts.deleted then notify_msg = "Pushing deleted bookmarks" - cmd = cmd .. " --deleted" + table.insert(cmd, "--deleted") end if opts.remote then - cmd = string.format("%s --remote %s", cmd, opts.remote) + vim.list_extend(cmd, { "--remote", opts.remote }) notify_msg = string.format("%s to remote `%s`", notify_msg, opts.remote) end utils.notify(notify_msg .. "...", vim.log.levels.INFO, 1000) - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.notify("Successfully pushed to remote", vim.log.levels.INFO) if log_open then M.log({}) @@ -894,13 +888,21 @@ function M.open_pr(opts) end -- Get the bookmark from the current change (@) - local bookmark, success = - runner.execute_command("jj log -r @ --no-graph -T 'bookmarks'", "Failed to get current bookmark", nil, true) + local bookmark, success = runner.execute( + { "jj", "log", "-r", "@", "--no-graph", "-T", "bookmarks" }, + "Failed to get current bookmark", + nil, + true + ) if not success or not bookmark or bookmark:match("^%s*$") then -- If no bookmark on @, try @- - bookmark, success = - runner.execute_command("jj log -r @- --no-graph -T 'bookmarks'", "Failed to get parent bookmark", nil, true) + bookmark, success = runner.execute( + { "jj", "log", "-r", "@-", "--no-graph", "-T", "bookmarks" }, + "Failed to get parent bookmark", + nil, + true + ) if not success or not bookmark or bookmark:match("^%s*$") then utils.notify("No bookmark found on @ or @- commits. Cannot open PR.", vim.log.levels.ERROR) @@ -925,8 +927,8 @@ function M.commit(description) local should_refresh = terminal.is_log_buffer_open() if description and description ~= "" then - local cmd = "jj commit --message " .. vim.fn.shellescape(description) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "commit", "--message", description } + runner.execute_async(cmd, function() utils.notify("Committed.", vim.log.levels.INFO) if should_refresh then vim.schedule(function() @@ -942,8 +944,8 @@ function M.commit(description) M.status() vim.ui.input({ prompt = "Description: ", default = "" }, function(input) if input and not input:match("^%s*$") then - local cmd = "jj commit --message " .. vim.fn.shellescape(input) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "commit", "--message", input } + runner.execute_async(cmd, function() utils.notify("Committed.", vim.log.levels.INFO) if should_refresh then vim.schedule(function() @@ -980,8 +982,8 @@ function M.commit(description) utils.notify("Description cannot be empty", vim.log.levels.ERROR) return end - local cmd = "jj commit --message " .. vim.fn.shellescape(trimmed_description) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "commit", "--message", trimmed_description } + runner.execute_async(cmd, function() utils.notify("Committed.", vim.log.levels.INFO) if should_refresh then vim.schedule(function() @@ -1020,8 +1022,8 @@ function M.tag_set(rev) -- Ask the user for the tag name vim.ui.input({ prompt = "Tag name: ", default = "" }, function(input) if input and not input:match("^%s*$") then - local cmd = string.format("jj tag set %s -r %s", input, rev) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "tag", "set", input, "-r", rev } + runner.execute_async(cmd, function() utils.notify(string.format("Tag `%s` set on `%s`.", input, rev), vim.log.levels.INFO) if should_refresh then vim.schedule(function() @@ -1044,8 +1046,8 @@ function M.tag_delete(tag) -- If the tag is provided, delete it directly without asking the user if tag then - local cmd = string.format("jj tag delete %s", tag) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "tag", "delete", tag } + runner.execute_async(cmd, function() utils.notify(string.format("Tag `%s` deleted.", tag), vim.log.levels.INFO) if terminal.is_log_buffer_open() then vim.schedule(function() @@ -1070,8 +1072,8 @@ function M.tag_delete(tag) vim.ui.select(tags, { prompt = "Select tag to delete: " }, function(choice) if choice then - local cmd = string.format("jj tag delete %s", choice) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "tag", "delete", choice } + runner.execute_async(cmd, function() utils.notify(string.format("Tag `%s` deleted.", choice), vim.log.levels.INFO) if should_refresh then vim.schedule(function() @@ -1126,8 +1128,8 @@ function M.tag_push() prompt = "Select tag to push: ", }, function(tag_choice) if tag_choice then - local cmd = string.format("git push %s %s", choice.name, tag_choice) - runner.execute_command_async(cmd, function() + local cmd = { "git", "push", choice.name, tag_choice } + runner.execute_async(cmd, function() utils.notify( string.format("Tag `%s` pushed successfully to remote `%s`.", tag_choice, choice.name), vim.log.levels.INFO @@ -1156,8 +1158,8 @@ function M.tag_push() prompt = "Select tag to push: ", }, function(tag_choice) if tag_choice then - local cmd = string.format("git push %s %s", remotes[1].name, tag_choice) - runner.execute_command_async(cmd, function() + local cmd = { "git", "push", remotes[1].name, tag_choice } + runner.execute_async(cmd, function() utils.notify( string.format("Tag `%s` pushed successfully to remote `%s`.", tag_choice, remotes[1].name), vim.log.levels.INFO @@ -1225,13 +1227,13 @@ function M.fetch_pr(opts) end local ref = string.format("pull/%s/head:pr-%s-%d", pr, pr, count) - local cmd = string.format("git fetch origin %s", ref) + local cmd = { "git", "fetch", "origin", ref } - runner.execute_command_async( + runner.execute_async( cmd, function() -- If we successfully pulled the PR, notify the user and refresh the log if it's open - runner.execute_command_async("jj git import", function() + runner.execute_async({ "jj", "git", "import" }, function() utils.notify( string.format("PR #%s fetched as pr-%s-%d.", pr, pr, count), vim.log.levels.INFO @@ -1323,8 +1325,8 @@ function M.j(args) local cmd = nil if #args == 0 then - local default_cmd_str, success = runner.execute_command( - "jj config list ui.default-command", + local default_cmd_str, success = runner.execute( + { "jj", "config", "list", "ui.default-command" }, "Error getting user's default command", nil, true @@ -1364,7 +1366,7 @@ function M.j(args) end end, new = function() - M.new({ show_log = true, args = remaining_args_str, with_input = false }) + M.new({ show_log = true, args = remaining_args, with_input = false }) end, rebase = function() M.rebase() @@ -1376,7 +1378,7 @@ function M.j(args) M.redo() end, log = function() - M.log({ raw_flags = remaining_args_str ~= "" and remaining_args_str or nil }) + M.log({ raw_flags = #remaining_args > 0 and remaining_args or nil }) end, split = function() local opts = { diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index a88977e..0e8ccfb 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -22,7 +22,7 @@ local HIGHLIGHT_RANGE = 2 -- Revision line + description line --- @field no_graph? boolean --- @field limit? uinteger --- @field revisions? string ---- @field raw_flags? string +--- @field raw_flags? string[] ---@type jj.cmd.log_opts local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = nil, raw_flags = nil } @@ -298,29 +298,39 @@ end --- Build the jj log command string from options --- @param opts? jj.cmd.log_opts Optional command options ---- @return string The full jj log command +--- @return string[] The full jj log command function M.build_log_cmd(opts) - local jj_cmd = "jj log --no-pager" + local jj_cmd = { + "jj", + "log", + "--no-pager", + } local merged_opts = vim.tbl_extend("force", default_log_opts, opts or {}) if merged_opts.raw_flags then - -- Strip --no-pager from raw_flags since it's already in the base command - local flags = vim.trim(merged_opts.raw_flags:gsub("%-%-no%-pager", ""):gsub("%s+", " ")) - if flags ~= "" then - return string.format("%s %s", jj_cmd, flags) + for _, flag in ipairs(merged_opts.raw_flags) do + -- Strip --no-pager from raw_flags since it's already in the base command + if flag ~= "--no-pager" then + table.insert(jj_cmd, flag) + end end return jj_cmd 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 + if merged_opts.summary then + table.insert(jj_cmd, "--summary") + end + if merged_opts.reversed then + table.insert(jj_cmd, "--reversed") + end + if merged_opts.no_graph then + table.insert(jj_cmd, "--no-graph") + end + if merged_opts.limit then + vim.list_extend(jj_cmd, { "--limit", tostring(merged_opts.limit) }) + end + if merged_opts.revisions then + vim.list_extend(jj_cmd, { "--revisions", merged_opts.revisions }) end return jj_cmd @@ -384,22 +394,21 @@ function M.handle_log_new(flag, ignore_immut) local cfg = flag_map[flag] or flag_map.default -- Build command parts - local cmd_parts = { "jj", "new" } + local cmd = { "jj", "new" } if cfg.opt ~= "" then -- For -A flag, each revset needs its own -A prefix for rev in revsets:gmatch("%S+") do - table.insert(cmd_parts, cfg.opt) - table.insert(cmd_parts, rev) + table.insert(cmd, cfg.opt) + table.insert(cmd, rev) end else - table.insert(cmd_parts, revsets) + table.insert(cmd, revsets) end if ignore_immut then - table.insert(cmd_parts, "--ignore-immutable") + table.insert(cmd, "--ignore-immutable") end - local cmd = table.concat(cmd_parts, " ") - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.notify(string.format(cfg.ok, revsets), vim.log.levels.INFO) -- Refresh the log buffer after creating the change. require("jj.cmd").log() @@ -480,18 +489,15 @@ function M.handle_log_edit(ignore_immut, close_on_exit) -- If we found a revision, edit it. -- Build command parts. - local cmd_parts = { "jj", "edit" } + local cmd = { "jj", "edit" } if ignore_immut then - table.insert(cmd_parts, "--ignore-immutable") + table.insert(cmd, "--ignore-immutable") end - table.insert(cmd_parts, revset) - - -- Build cmd string - local cmd = table.concat(cmd_parts, " ") + table.insert(cmd, revset) -- Try to execute cmd - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.reload_changed_file_buffers() -- Close the terminal buffer @@ -521,18 +527,15 @@ function M.handle_log_abandon(ignore_immut) -- If we found revision(s), abandon it. -- Build command parts. - local cmd_parts = { "jj", "abandon" } + local cmd = { "jj", "abandon" } if ignore_immut then - table.insert(cmd_parts, "--ignore-immutable") + table.insert(cmd, "--ignore-immutable") end - table.insert(cmd_parts, revsets) - - -- Build cmd string - local cmd = table.concat(cmd_parts, " ") + table.insert(cmd, revsets) -- Try to execute cmd - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() local text = "Abandoned change: `%s`" if revsets:find(" ", 1) then text = "Abandoned changes: `%s`" @@ -559,8 +562,8 @@ function M.handle_log_fetch() end, }, function(choice) if choice then - local cmd = string.format("jj git fetch --remote %s", choice.name) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "git", "fetch", "--remote", choice.name } + runner.execute_async(cmd, function() utils.notify(string.format("Fetching from %s...", choice), vim.log.levels.INFO) M.log({}) end, "Error fetching from remote") @@ -568,9 +571,9 @@ function M.handle_log_fetch() end) else -- Only one remote, fetch from it directly - local cmd = "jj git fetch" + local cmd = { "jj", "git", "fetch" } utils.notify("Fetching from remote...", vim.log.levels.INFO) - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.notify("Successfully fetched from remote", vim.log.levels.INFO) M.log({}) end, "Error fetching from remote") @@ -581,7 +584,7 @@ end --- Ask the user which local bookmark to push. function M.handle_log_push_from_all() local bookmarks = utils.get_all_bookmarks_with_status() - local cmd = "jj git push" + local cmd = { "jj", "git", "push" } if not bookmarks or #bookmarks == 0 then utils.notify("No bookmarks found to push", vim.log.levels.ERROR) return @@ -597,9 +600,11 @@ function M.handle_log_push_from_all() end, }, function(choice) if choice then - local push_cmd = string.format("%s -b %s", cmd, choice.name) + -- Add the bookmark name to the command + vim.list_extend(cmd, { "-b", choice.name }) + utils.notify(string.format("Pushing bookmark `%s`...", choice.name), vim.log.levels.INFO) - runner.execute_command_async(push_cmd, function(output) + runner.execute_async(cmd, function(output) if output and string.find(output, "Nothing changed%.") then utils.notify("Nothing changed.", vim.log.levels.INFO) else @@ -627,8 +632,9 @@ function M.handle_log_push_bookmark() end -- Function to push the bookmark, takes the full command as argument + --- @param cmd string[] The command to execute local function push(cmd) - runner.execute_command_async(cmd, function(output) + runner.execute_async(cmd, function(output) if output and string.find(output, "Nothing changed%.") then utils.notify("Nothing changed.", vim.log.levels.INFO) else @@ -652,14 +658,14 @@ function M.handle_log_push_bookmark() end, }, function(choice) if choice then - local cmd = "jj git push" + local cmd = { "jj", "git", "push" } if choice.name == "[All]" then -- Push all bookmarks - cmd = string.format("%s --all", cmd) + table.insert(cmd, "--all") utils.notify("Pushing `ALL` bookmarks", vim.log.levels.INFO) else + vim.list_extend(cmd, { "-b", choice.name }) utils.notify(string.format("Pushing bookmark `%s`...", choice.name), vim.log.levels.INFO) - cmd = string.format("%s -b %s", cmd, choice.name) end push(cmd) else @@ -669,7 +675,7 @@ function M.handle_log_push_bookmark() else -- If there's only one bookmark simply push it local b = bookmarks[1].name - local cmd = string.format("jj git push -b %s", b) + local cmd = { "jj", "git", "push", "-b", b } utils.notify(string.format("Pushing bookmark `%s`...", b), vim.log.levels.INFO) push(cmd) end @@ -768,14 +774,12 @@ function M.handle_log_bookmark_del() ---@param b_names string[] Bookmark names to delete local function delete_bookmarks(b_names) - local escaped_list = {} + local cmd = { "jj", "bookmark", "delete" } for _, b in ipairs(b_names) do - table.insert(escaped_list, vim.fn.shellescape(b)) + table.insert(cmd, b) end - local escaped_bookmarks = table.concat(escaped_list, " ") - local cmd = string.format("jj bookmark delete %s", escaped_bookmarks) local b_str = table.concat(b_names, " ") - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.notify(string.format("Deleted bookmark `%s`", b_str), vim.log.levels.INFO) M.log({}) end, string.format("Error deleting bookmark `%s`", b_str)) @@ -830,8 +834,8 @@ function M.handle_log_bookmark() -- Prompt for new bookmark name vim.ui.input({ prompt = "Enter new bookmark name: ", default = prefix }, 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() + local cmd = { "jj", "bookmark", "create", input, "-r", revset } + runner.execute_async(cmd, function() utils.notify( string.format("Created bookmark `%s` at `%s`", input, revset), vim.log.levels.INFO @@ -842,8 +846,8 @@ function M.handle_log_bookmark() end) else -- Move existing bookmark to the revision - local cmd = string.format("jj bookmark move %s --to %s -B", choice, revset) - runner.execute_command_async(cmd, function() + local cmd = { "jj", "bookmark", "move", choice, "--to", revset, "-B" } + runner.execute_async(cmd, function() utils.notify(string.format("Moved bookmark `%s` to `%s`", choice, revset), vim.log.levels.INFO) M.log({}) end, "Error moving bookmark") @@ -907,9 +911,9 @@ function M.handle_log_quick_squash() return end - local cmd = string.format("jj squash -r %s -u --ignore-immutable", revset) + local cmd = { "jj", "squash", "-r", revset, "-u", "--ignore-immutable" } utils.notify(string.format("Squashing `%s` into it's parent...", revset), vim.log.levels.INFO) - runner.execute_command_async(cmd, function() + runner.execute_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)) @@ -1050,12 +1054,12 @@ function M.handle_summary_edit(revset, ignore_immut) -- Close the log buffer terminal.close_terminal_buffer() - local cmd = string.format("jj edit %s", revset) + local cmd = { "jj", "edit", revset } if ignore_immut then - cmd = cmd .. " --ignore-immutable" + table.insert(cmd, "--ignore-immutable") end - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.reload_changed_file_buffers() utils.notify(string.format("Editing revset: `%s`", revset), vim.log.levels.INFO) -- Open the file in the current window @@ -1119,7 +1123,7 @@ end --- Build the summary command for a revset --- @param revset string The revision to show summary for ---- @return string The jj log command +--- @return string[] The jj log command local function build_summary_cmd(revset) local template = '"Commit ID: " ++ commit_id ++ "\\n" ' .. '++ "Change ID: " ++ change_id ++ "\\n" ' @@ -1128,7 +1132,15 @@ local function build_summary_cmd(revset) .. "++ description " .. '++ "\\n" ++ self.diff().summary()' - return string.format("jj log -r %s --no-graph -T '%s'", revset, template) + return { + "jj", + "log", + "-r", + revset, + "--no-graph", + "-T", + template, + } end --- Handle showing summary tooltip for revision under cursor @@ -1155,7 +1167,7 @@ function M.handle_log_summary() local cmd = build_summary_cmd(revset) -- Run command synchronously first to calculate dimensions - local output, success = runner.execute_command(cmd, nil, nil, false) + local output, success = runner.execute(cmd, nil, nil, false) if not success or not output then return end @@ -1614,15 +1626,22 @@ function M.handle_rebase_execute(mode, ignore_immut) end utils.notify(string.format("Rebasing...", revsets, mode, destination_revset), vim.log.levels.INFO, 500) - local cmd = string.format("jj rebase -r '%s' %s %s", revsets, mode_flat, destination_revset) + local cmd = { + "jj", + "rebase", + "-r", + revsets, + mode_flat, + destination_revset, + } -- 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" + table.insert(cmd, "--ignore-immutable") end - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.notify( string.format("Rebased `%s` %s `%s` successfully", revsets, mode, destination_revset), vim.log.levels.INFO @@ -1653,19 +1672,19 @@ function M.handle_squash_execute(mode, ignore_immut) 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) + local cmd = { "jj", "squash", "-f", revsets, "-u" } if mode == "into" then - cmd = cmd .. string.format(" -t %s", destination_revset) + vim.list_extend(cmd, { "-t", 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" + table.insert(cmd, "--ignore-immutable") end - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.notify( string.format("Squashed `%s` into `%s` successfully", revsets, destination_revset), vim.log.levels.INFO @@ -1702,15 +1721,15 @@ function M.handle_duplicate_execute(mode, ignore_immut) end utils.notify(string.format("Duplicating...", revsets, mode, destination_revset), vim.log.levels.INFO, 500) - local cmd = string.format("jj duplicate %s %s %s", revsets, mode_flat, destination_revset) + local cmd = { "jj", "duplicate", revsets, mode_flat, destination_revset } -- 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" + table.insert(cmd, "--ignore-immutable") end - runner.execute_command_async(cmd, function() + runner.execute_async(cmd, function() utils.notify( string.format("Duplicated `%s` %s `%s` successfully", revsets, mode, destination_revset), vim.log.levels.INFO diff --git a/lua/jj/cmd/resolve.lua b/lua/jj/cmd/resolve.lua index 453067f..af9132c 100644 --- a/lua/jj/cmd/resolve.lua +++ b/lua/jj/cmd/resolve.lua @@ -1,6 +1,7 @@ local M = {} local utils = require("jj.utils") +local jj_args = require("jj.core.args") local terminal = require("jj.ui.terminal") local runner = require("jj.core.runner") @@ -16,27 +17,22 @@ function M.resolve(opts) return end - local cmd_args = { "jj", "resolve", "--revision", rev } + local cmd = { "jj", "resolve", "--revision", rev } -- Extra arguments - vim.list_extend(cmd_args, args) - -- Append the filestes - for _, fileset in ipairs(filesets) do - table.insert(cmd_args, utils.escape_fileset(fileset)) - end + vim.list_extend(cmd, args) - local escaped_cmd_args = {} - for _, arg in ipairs(cmd_args) do - table.insert(escaped_cmd_args, vim.fn.shellescape(arg)) + -- Append filesets as jj string literal + for _, fileset in ipairs(filesets) do + table.insert(cmd, jj_args.fileset(fileset)) end - local shell_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( - shell_cmd, + runner.execute_async( + cmd, function(output) if output and output ~= "" then utils.notify(output, vim.log.levels.INFO) @@ -56,7 +52,7 @@ function M.resolve(opts) ) else -- Otherwise, run in a floating terminal - terminal.run_floating(cmd_args, nil, { + terminal.run_floating(cmd, nil, { title = " JJ Resolve ", modifiable = true, keep_modifiable = true, diff --git a/lua/jj/cmd/split.lua b/lua/jj/cmd/split.lua index 557ed8e..9663ef6 100644 --- a/lua/jj/cmd/split.lua +++ b/lua/jj/cmd/split.lua @@ -2,7 +2,11 @@ local M = {} local utils = require("jj.utils") local terminal = require("jj.ui.terminal") +local jj_args = require("jj.core.args") +--- Build the jj split command based on the provided options. +---@param opts jj.cmd.split.opts +---@return string[] The constructed jj split command. local function build_split_command(opts) local args = { "jj", "split" } @@ -25,11 +29,11 @@ local function build_split_command(opts) if opts.filesets then for _, fileset in ipairs(opts.filesets) do - table.insert(args, utils.escape_fileset(fileset)) + table.insert(args, jj_args.fileset(fileset)) end end - return table.concat(args, " ") + return args end --- Split natively @@ -47,6 +51,8 @@ function M.split(opts) return end + --- Run the jj split command in a floating terminal + ---@param cmd string[] local function run_split(cmd) terminal.run_floating(cmd, nil, { title = " JJ Split ", diff --git a/lua/jj/cmd/status.lua b/lua/jj/cmd/status.lua index 3d34f3b..fc37049 100644 --- a/lua/jj/cmd/status.lua +++ b/lua/jj/cmd/status.lua @@ -5,6 +5,7 @@ local utils = require("jj.utils") local runner = require("jj.core.runner") local parser = require("jj.core.parser") local terminal = require("jj.ui.terminal") +local jj_args = require("jj.core.args") --- Handle restoring a file from the jj status buffer --- Supports both renamed and non-renamed files @@ -15,29 +16,27 @@ function M.handle_status_restore() end if file_info.is_rename then - -- For renamed files, remove the new file and restore the old one from parent revision - local rm_cmd = string.format("rm %s", vim.fn.shellescape(file_info.new_path)) - local restore_cmd = string.format("jj restore --from @- %s", utils.escape_fileset(file_info.old_path)) + local restore_cmd = { + "jj", + "restore", + "--from", + "@-", + jj_args.fileset(file_info.old_path), + jj_args.fileset(file_info.new_path), + } - local _, rm_success = runner.execute_command(rm_cmd, "Failed to remove renamed file") - if rm_success then - local _, restore_success = runner.execute_command(restore_cmd, "Failed to restore original file") - if restore_success then - utils.notify( - "Reverted rename: " .. file_info.new_path .. " -> " .. file_info.old_path, - vim.log.levels.INFO - ) - require("jj.cmd").status() - end + local _, restore_success = runner.execute(restore_cmd, "Failed to restore original file") + if restore_success then + utils.notify("Reverted rename: " .. file_info.new_path .. " -> " .. file_info.old_path, vim.log.levels.INFO) + require("jj.cmd").status() end else -- For non-renamed files, use regular restore - utils.notify(utils.escape_fileset(file_info.old_path)) - local restore_cmd = string.format("jj restore %s", utils.escape_fileset(file_info.old_path)) + local restore_cmd = { "jj", "restore", jj_args.fileset(file_info.old_path) } - local _, success = runner.execute_command(restore_cmd, "Failed to restore") + local _, success = runner.execute(restore_cmd, "Failed to restore") if success then - utils.notify("Restored: " .. file_info.old_path, vim.log.levels.INFO) + utils.notify("Restored: `" .. file_info.old_path .. "`", vim.log.levels.INFO) require("jj.cmd").status() end end @@ -95,16 +94,16 @@ function M.status(opts) return end - local cmd_str = "jj status --no-pager" + local cmd = { "jj", "status", "--no-pager" } if opts and opts.notify then - local output, success = runner.execute_command(cmd_str, "Failed to get status") + local output, success = runner.execute(cmd, "Failed to get status") if success then utils.notify(output and output or "", vim.log.levels.INFO) end else -- Default behavior: show in buffer - terminal.run(cmd_str, M.status_keymaps()) + terminal.run(cmd, M.status_keymaps()) end end diff --git a/lua/jj/core/args.lua b/lua/jj/core/args.lua new file mode 100644 index 0000000..b7ca6ba --- /dev/null +++ b/lua/jj/core/args.lua @@ -0,0 +1,12 @@ +local M = {} + +--- Build a jj fileset string literal for a path. +--- jj path arguments use fileset syntax, so special characters like `$` +--- must be wrapped in jj string quotes. +---@param path string +---@return string +function M.fileset(path) + return string.format('"%s"', path:gsub("\\", "\\\\"):gsub('"', '\\"')) +end + +return M diff --git a/lua/jj/core/runner.lua b/lua/jj/core/runner.lua index b9a644f..62ac525 100644 --- a/lua/jj/core/runner.lua +++ b/lua/jj/core/runner.lua @@ -8,122 +8,80 @@ local function error_notify(msg, error_prefix, silent) end end ---- Execute a system command and return output with error handling ---- @param cmd string The command to execute ---- @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 +--- Execute a system command with arguments and return output with error handling +---@param argv string[] The command and its arguments to execute +---@param error_prefix string|nil +---@param input string|nil +---@param silent boolean|nil --- @return string|nil output The command output, or nil if failed --- @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 success = vim.v.shell_error == 0 +function M.execute(argv, error_prefix, input, silent) + local result = vim.system(argv, { stdin = input, text = true }):wait() - if not success then - local stderr_lines = vim.fn.readfile(stderr_file) - vim.fn.delete(stderr_file) - local error_output = table.concat(stderr_lines, "\n") - local msg = error_output ~= "" and error_output or output - error_notify(msg, error_prefix, silent) - return nil, false - end - - vim.fn.delete(stderr_file) - return output, success -end - ---- Execute a system command and return its raw stdout bytes. ---- Skips replacement of NUL bytes with SOH (0x01), ---- which corrupts UTF-16 and other binary-ish content. ---- @param cmd string The command to execute ---- @param error_prefix string|nil Optional error message prefix ---- @param silent boolean|nil Optional to silent the notification ---- @return string|nil output Raw stdout bytes, or nil if failed ---- @return boolean success Whether the command succeeded ---- @return string stderr The command's stderr (empty on success) -function M.execute_command_raw(cmd, error_prefix, silent) - local result = vim.system({ "sh", "-c", cmd }):wait() if result.code ~= 0 then local msg = result.stderr ~= "" and result.stderr or result.stdout or "" error_notify(msg, error_prefix, silent) - return nil, false, msg + return nil, false end - return result.stdout or "", true, "" + return result.stdout or "", true end ---- Execute a system command synchronously and call success callback. ---- @param cmd string The command to execute +--- Execute an argv command with arguments asynchronously and call success callback. +--- @param argv string[] The command and its arguments 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 ---- @return string|nil output The command output, or nil if failed ---- @return boolean success Whether the command succeeded -function M.execute_command_sync(cmd, on_success, error_prefix, input, silent) - local output, success = M.execute_command(cmd, error_prefix, input, silent) - if success and on_success then - on_success(output) - end - return output, success -end - ---- 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 ---- @param on_error function|nil Callback on error, receives ouptut as the parameter -function M.execute_command_async(cmd, on_success, error_prefix, input, silent, on_error) - local stdout_lines = {} - local stderr_lines = {} - - local job_id = vim.fn.jobstart({ "sh", "-c", cmd }, { - stdout_buffered = true, - stderr_buffered = true, - on_stdout = function(_, data) - vim.list_extend(stdout_lines, data) - end, - on_stderr = function(_, data) - vim.list_extend(stderr_lines, data) - end, - on_exit = function(_, exit_code) - local output = table.concat(stdout_lines, "\n") - if exit_code == 0 then +--- @param on_error function|nil Callback on error, receives the error message +function M.execute_async(argv, on_success, error_prefix, input, silent, on_error) + vim.system( + argv, + { stdin = input, text = true }, + vim.schedule_wrap(function(res) + if res.code == 0 then if on_success then - on_success(output) + on_success(res.stdout or "") end else - local error_output = table.concat(stderr_lines, "\n") - local msg = error_output ~= "" and error_output or output + local msg = res.stderr ~= "" and res.stderr or res.stdout or "" error_notify(msg, error_prefix, silent) if on_error then on_error(msg) end end - end, - }) - - -- Send stdin if provided - if input then - vim.fn.chansend(job_id, input) - vim.fn.chanclose(job_id, "stdin") - end + end) + ) end ---- Execute a system command asynchronously and receive raw stdout bytes. +--- Execute an argv command with arguments and return raw stdout bytes. --- Skips replacement of NUL bytes with SOH (0x01). ---- @param cmd string The command to execute +--- @param argv string[] The command and its arguments to execute +--- @param error_prefix string|nil Optional error message prefix +--- @param silent boolean|nil Optional to silent the notification +--- @return string|nil output Raw stdout bytes, or nil if failed +--- @return boolean success Whether the command succeeded +--- @return string stderr The command's stderr (empty on success) +function M.execute_raw(argv, error_prefix, silent) + local result = vim.system(argv, { text = false }):wait() + if result.code ~= 0 then + local msg = result.stderr ~= "" and result.stderr or result.stdout or "" + error_notify(msg, error_prefix, silent) + return nil, false, msg + end + return result.stdout or "", true, result.stderr or "" +end + +--- Execute an argv command with arguments asynchronously and receive raw stdout bytes. +--- Skips replacement of NUL bytes with SOH (0x01). +--- @param argv string[] The command and its arguments to execute --- @param on_success function|nil Callback on success, receives raw stdout bytes --- @param error_prefix string|nil Optional error message prefix --- @param silent boolean|nil Optional to silent the notification --- @param on_error function|nil Callback on error, receives the error message -function M.execute_command_raw_async(cmd, on_success, error_prefix, silent, on_error) +function M.execute_raw_async(argv, on_success, error_prefix, silent, on_error) vim.system( - { "sh", "-c", cmd }, - {}, + argv, + { text = false }, vim.schedule_wrap(function(res) if res.code == 0 then if on_success then diff --git a/lua/jj/diff/native.lua b/lua/jj/diff/native.lua index cb54d72..5ae3a3d 100644 --- a/lua/jj/diff/native.lua +++ b/lua/jj/diff/native.lua @@ -10,11 +10,20 @@ local file = require("jj.file") --- @param path string The file path (absolute or repo-relative) --- @param enc? jj.file.enc Encoding settings for the file content local function open_revision(rev, path, enc) - local raw_ids, ok = runner.execute_command( - string.format([[jj log --no-graph -r %s -T 'change_id ++ "\n"' --quiet]], vim.fn.shellescape(rev)), - "jj: failed to resolve revision" - ) - if not ok or not raw_ids then return end + local cmd = { + "jj", + "log", + "--no-graph", + "-r", + rev, + "-T", + 'change_id ++ "\n"', + "--quiet", + } + local raw_ids, ok = runner.execute(cmd, "jj: failed to resolve revision") + if not ok or not raw_ids then + return + end local ids = vim.split(vim.trim(raw_ids), "\n", { trimempty = true }) if #ids ~= 1 then utils.notify(string.format("Revision '%s' is ambiguous", rev), vim.log.levels.ERROR) @@ -144,13 +153,13 @@ diff.register_backend("native", { show_revision = function(opts) local terminal = require("jj.ui.terminal") - local cmd = string.format("jj show -r %s --quiet --no-pager", opts.rev) + local cmd = { "jj", "show", "-r", opts.rev, "--quiet", "--no-pager" } terminal.run_floating(cmd) end, diff_revisions = function(opts) local terminal = require("jj.ui.terminal") - local cmd = string.format("jj diff -f %s -t %s --quiet --no-pager", opts.left, opts.right) + local cmd = { "jj", "diff", "-f", opts.left, "-t", opts.right, "--quiet", "--no-pager" } terminal.run_floating(cmd) end, diff_history_revisions = function(_) diff --git a/lua/jj/file.lua b/lua/jj/file.lua index 7c7b740..8e9866b 100644 --- a/lua/jj/file.lua +++ b/lua/jj/file.lua @@ -5,6 +5,7 @@ local runner = require("jj.core.runner") local buffer = require("jj.core.buffer") local utils = require("jj.utils") local parser = require("jj.core.parser") +local jj_args = require("jj.core.args") --- @class jj.file.read_target_opts --- @field rev? string Revision to read the file from @@ -70,8 +71,12 @@ end --- @return "le"|"be"|nil order local function unicode_width(fenc) local f = fenc:lower():gsub("%-", "") - if f == "utf16le" or f == "ucs2le" then return 2, "le" end - if f == "utf32le" or f == "ucs4le" then return 4, "le" end + if f == "utf16le" or f == "ucs2le" then + return 2, "le" + end + if f == "utf32le" or f == "ucs4le" then + return 4, "le" + end if f == "utf16be" or f == "ucs2be" or f == "utf16" or f == "ucs2" or f == "unicode" then return 2, "be" end @@ -181,7 +186,7 @@ local function encode(lines, eol, enc) local width, order = unicode_width(enc.fenc) if width then -- Always serialise via the little-endian variant, - -- see https://github.com/neovim/neovim/issues/40262. + -- see https://github.com/neovim/neovim/issues/40262. local converted = vim.iconv(text, "utf-8", width == 2 and "utf-16le" or "utf-32le") if not converted then return nil, string.format("Could not convert content from utf-8 to '%s'", enc.fenc) @@ -224,11 +229,15 @@ M._encode = encode --- @return boolean absent True when the path does not exist in `rev` (e.g. a --- file added since `rev`). local function get_file_content(rev, path, enc) - local raw, ok, stderr = runner.execute_command_raw( - string.format("jj file show -r %s %s", vim.fn.shellescape(rev), vim.fn.shellescape(path)), - nil, - true - ) + local cmd = { + "jj", + "file", + "show", + "-r", + rev, + jj_args.fileset(path), + } + local raw, ok, stderr = runner.execute_raw(cmd, nil, true) if not ok or not raw then local absent = stderr ~= nil and stderr:find("No such path", 1, true) ~= nil return {}, false, false, enc or { fenc = "", bomb = false, ff = "unix" }, absent @@ -238,7 +247,11 @@ local function get_file_content(rev, path, enc) utils.notify(had_eol --[[@as string]], vim.log.levels.ERROR) return {}, false, false, used_enc, false end - return lines, had_eol --[[@as boolean]], true, used_enc, false + return lines, + had_eol, --[[@as boolean]] + true, + used_enc, + false end M.get_file_content = get_file_content @@ -262,8 +275,15 @@ function M.read_target(opts) enc = M.get_buf_encoding(buf) end - local cmd = string.format("jj file show -r %s %s", vim.fn.shellescape(revision), vim.fn.shellescape(path)) - runner.execute_command_raw_async(cmd, function(raw) + local cmd = { + "jj", + "file", + "show", + "-r", + revision, + jj_args.fileset(path), + } + runner.execute_raw_async(cmd, function(raw) local lines, had_eol, used_enc = decode(raw, enc) if not lines then utils.notify(had_eol --[[@as string]], vim.log.levels.ERROR) @@ -314,23 +334,31 @@ local function write_revision_file(buf, change_id, rel_path, force) -- so the parent directory for rel_path already exists there. local prog_config = 'merge-tools.jj-nvim-write.program="cp"' -- json_encode produces valid TOML inline arrays. - local args_config = "merge-tools.jj-nvim-write.edit-args=" - .. vim.fn.json_encode({ tmp, "$right/" .. rel_path }) + local args_config = "merge-tools.jj-nvim-write.edit-args=" .. vim.fn.json_encode({ tmp, "$right/" .. rel_path }) - local _, ok = runner.execute_command( - string.format( - "jj diffedit --from 'root()' --to %s --config %s --config %s --tool jj-nvim-write -- %s", - vim.fn.shellescape(change_id), - vim.fn.shellescape(prog_config), - vim.fn.shellescape(args_config), - vim.fn.shellescape(rel_path) - ), - "jj: failed to edit revision" - ) + local cmd = { + "jj", + "diffedit", + "--from", + "root()", + "--to", + change_id, + "--config", + prog_config, + "--config", + args_config, + "--tool", + "jj-nvim-write", + "--", + jj_args.fileset(rel_path), + } + local _, ok = runner.execute(cmd, "jj: failed to edit revision") os.remove(tmp) - if not ok then return end + if not ok then + return + end vim.bo[buf].modified = false utils.notify(string.format("Written to revision %s", change_id)) end @@ -347,13 +375,20 @@ function M.open_target(opts) return end - local raw_ids, ok = runner.execute_command( - string.format([[jj log --no-graph -r %s -T 'change_id ++ "\n"' --quiet]], vim.fn.shellescape(revision)), - "jj: failed to resolve revision", - nil, - true - ) - if not ok or not raw_ids then return end + local cmd = { + "jj", + "log", + "--no-graph", + "-r", + revision, + "-T", + 'change_id ++ "\n"', + "--quiet", + } + local raw_ids, ok = runner.execute(cmd, "jj: failed to resolve revision", nil, true) + if not ok or not raw_ids then + return + end local ids = vim.split(vim.trim(raw_ids), "\n", { trimempty = true }) if #ids ~= 1 then utils.notify(string.format("Revision '%s' is ambiguous", revision), vim.log.levels.ERROR) @@ -405,12 +440,14 @@ local function complete_target(arglead) return {} end - local out, ok = runner.execute_command( - string.format("jj file list -r %s", vim.fn.shellescape(rev)), - nil, - nil, - true - ) + local cmd = { + "jj", + "file", + "list", + "-r", + rev, + } + local out, ok = runner.execute(cmd, nil, nil, true) if not ok or not out then return {} end @@ -438,7 +475,9 @@ function M.register_command() callback = function() local name = vim.api.nvim_buf_get_name(0) local change_id, path = utils.parse_jj_uri(name) - if not change_id or not path then return end + if not change_id or not path then + return + end -- Keep reloads of an added-file diff buffer empty rather than erroring. local lines, had_eol, ok_read, used_enc, absent = get_file_content(change_id, path) if not ok_read and not absent then diff --git a/lua/jj/picker.lua b/lua/jj/picker.lua index d8272ec..4a455c0 100644 --- a/lua/jj/picker.lua +++ b/lua/jj/picker.lua @@ -1,6 +1,7 @@ local utils = require("jj.utils") local runner = require("jj.core.runner") local parser = require("jj.core.parser") +local jj_args = require("jj.core.args") --- @class jj.picker @@ -12,7 +13,7 @@ local parser = require("jj.core.parser") --- @field file string The current path of the file --- @field status string JJ-style status code (e.g. "M ", "R ") for picker formatting --- @field rename? string Previous path when this item is a rename ---- @field diff_cmd string The command to get the diff of the file +--- @field diff_cmd string[] The command to get the diff of the file --- @field confirm_action string The default picker action for the item --- @class jj.picker.log_line @@ -97,7 +98,7 @@ end --- Gets the files in the current jj repository --- @return jj.picker.file[]|nil A list of files with their changes or nil if not in a jj repo local function get_files() - local diff_ouptut, ok = runner.execute_command("jj --no-pager diff --summary --quiet") + local diff_ouptut, ok = runner.execute({ "jj", "--no-pager", "diff", "--summary", "--quiet" }) if not ok then return end @@ -121,7 +122,7 @@ local function get_files() text = line:sub(3), file = file_path, status = change .. " ", - diff_cmd = string.format("jj --no-pager diff %s", utils.escape_fileset(file_path)), + diff_cmd = { "jj", "--no-pager", "diff", jj_args.fileset(file_path) }, confirm_action = "open_and_diff", } @@ -168,13 +169,18 @@ end ---@param file_path string The path of the file to log ---@return jj.picker.log_line[]|nil A list of log lines or nil if not in a jj repo local function log_history(file_path) - local format = table.concat({ - "jj --no-pager log %s", - "-r 'all() ~ @'", + local cmd = { + "jj", + "log", + "--no-pager", + "-r", + "all() ~ @", "--no-graph", - [[ -T 'change_id.shortest() ++ "\t" ++ coalesce(author.name(), "(no author)") ++ "\t" ++ committer.timestamp() ++ "\t" ++ coalesce(description.first_line(), "(no description)") ++ "\n"' ]], - }, " ") - local output, ok = runner.execute_command(string.format(format, utils.escape_fileset(file_path))) + "-T", + [[change_id.shortest() ++ "\t" ++ coalesce(author.name(), "(no author)") ++ "\t" ++ committer.timestamp() ++ "\t" ++ coalesce(description.first_line(), "(no description)") ++ "\n"]], + jj_args.fileset(file_path), + } + local output, ok = runner.execute(cmd) if not ok then return end @@ -205,7 +211,7 @@ local function log_history(file_path) "jj", "--no-pager", "diff", - utils.escape_fileset(file_path), + jj_args.fileset(file_path), "-r", rev, "--stat", @@ -245,8 +251,8 @@ function M.file_history() return end - local _, ok = runner.execute_command( - string.format("jj edit %s --ignore-immutable", item.rev), + local _, ok = runner.execute( + { "jj", "edit", item.rev, "--ignore-immutable" }, string.format("could not edit revision '%s'", item.rev) ) @@ -261,9 +267,17 @@ end --- Gets the list of conflicted revisions --- @return jj.picker.conflict[]|nil A list of conflicted revisions or nil if not in a jj repo local function get_conflicts() - local cmd = - [[jj log -r 'conflicts()' --no-graph -T 'change_id.shortest() ++ "\t" ++ coalesce(author.name(), "(no author)") ++ "\t" ++ coalesce(description.first_line(), "(no description)") ++ "\n"']] - local output, ok = runner.execute_command(cmd) + local cmd = { + "jj", + "log", + "-r", + "conflicts()", + "--no-graph", + "-T", + [[change_id.shortest() ++ "\t" ++ coalesce(author.name(), "(no author)") ++ "\t" ++ coalesce(description.first_line(), "(no description)") ++ "\n"]], + } + + local output, ok = runner.execute(cmd) if not ok then return end @@ -337,9 +351,17 @@ end --- it. --- @return jj.picker.conflict_section[]|nil A list of conflict sections or nil if not in a jj repo local function get_conflict_sections() - local output, ok, _ = runner.execute_command_raw( - [[jj log --no-graph --quiet -r @ -T 'self.conflicted_files().map(|e| e.path().display() ++ "\0" ++ e.path().absolute() ++ "\0")']] - ) + local cmd = { + "jj", + "log", + "--no-graph", + "--quiet", + "-r", + "@", + "-T", + [[self.conflicted_files().map(|e| e.path().display() ++ "\0" ++ e.path().absolute() ++ "\0")]], + } + local output, ok, _ = runner.execute_raw(cmd) if not ok then return end diff --git a/lua/jj/picker/snacks.lua b/lua/jj/picker/snacks.lua index 1473317..a3d7aeb 100644 --- a/lua/jj/picker/snacks.lua +++ b/lua/jj/picker/snacks.lua @@ -163,8 +163,8 @@ function M.file_log_history(opts, log_lines) return end - local _, ok = runner.execute_command( - string.format("jj edit %s --ignore-immutable", item.rev), + local _, ok = runner.execute( + { "jj", "edit", item.rev, "--ignore-immutable" }, string.format("could not edit revision '%s'", item.rev) ) @@ -217,8 +217,8 @@ function M.conflict(opts, conflicts) return end - local _, ok = runner.execute_command( - string.format("jj edit %s", item.rev), + local _, ok = runner.execute( + { "jj", "edit", item.rev, "--ignore-immutable" }, string.format("could not edit revision '%s'", item.rev) ) diff --git a/lua/jj/ui/terminal.lua b/lua/jj/ui/terminal.lua index 8d0f563..aa2443d 100644 --- a/lua/jj/ui/terminal.lua +++ b/lua/jj/ui/terminal.lua @@ -120,7 +120,7 @@ function M.keymap_help() end -- create a buffer and floating window to show the key mappings - local buf, win = buffer.create_float({ + local buf, _ = buffer.create_float({ title = " Key mappings ", title_pos = "left", enter = true, @@ -249,7 +249,7 @@ function M.is_log_buffer_open() end --- Run the command in a floating window ---- @param cmd string|string[] The command to run in the 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 --- @param float_opts? {title?: string, modifiable?: boolean, keep_modifiable?: boolean, on_exit?: fun(exit_code: integer), interactive?: boolean} function M.run_floating(cmd, keymaps, float_opts) @@ -455,7 +455,7 @@ end --- Run a command and show it's output in a terminal buffer --- If a previous command already existed it smartly reuses the buffer cleaning the previous output ---- @param cmd string|string[] The command to run in the terminal buffer +--- @param cmd string[] The command to run in the terminal buffer --- @param keymaps jj.core.buffer.keymap[]|nil Additional keymaps to set for this command buffer --- @return integer|nil buf The buffer handle, or nil on failure function M.run(cmd, keymaps) @@ -669,7 +669,7 @@ end --- @field height? number Tooltip height (default: 80% of lines) --- Run a command in a PTY-based tooltip window ---- @param cmd string The command to run +--- @param cmd string[] The command to run --- @param tool_opts? jj.ui.terminal.tooltip_opts Tooltip options --- @return number|nil buf Buffer handle, or nil on failure --- @return number|nil win Window handle, or nil on failure diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index e67e860..bf9c1a7 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -64,7 +64,7 @@ function M.is_jj_repo() end -- We require the runner here to avoid a circular dependency loop at startup - local _, success = runner.execute_command("jj status") + local _, success = runner.execute({ "jj", "status" }) return success end @@ -76,7 +76,7 @@ function M.get_jj_root() end -- We require the runner here to avoid a circular dependency loop at startup - local output, success = runner.execute_command("jj root") + local output, success = runner.execute({ "jj", "root" }) if success and output then return vim.trim(output) end @@ -91,7 +91,7 @@ function M.get_modified_files() end -- We require the runner here to avoid a circular dependency loop at startup - local result, success = runner.execute_command("jj diff --name-only", "Error getting diff") + local result, success = runner.execute({ "jj", "diff", "--name-only" }, "Error getting diff") if not success or not result then return {} end @@ -344,13 +344,14 @@ end function M.get_all_bookmarks() -- Use a custom template to output just the bookmark names, one per line -- This is more reliable than parsing the default output format - local bookmarks_output, success = runner.execute_command( - [[jj bookmark list -T 'if(!self.remote(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")']], - "Failed to get bookmarks", - nil, - true - ) - + local cmd = { + "jj", + "bookmark", + "list", + "-T", + 'if(!self.remote(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")', + } + local bookmarks_output, success = runner.execute(cmd, "Failed to get bookmarks", nil, true) if not success or not bookmarks_output then return {} end @@ -375,12 +376,15 @@ end --- Get all bookmarks in the repository, including deleted ones --- @return {name: string, is_deleted: boolean}[] bookmarks List of bookmarks function M.get_all_bookmarks_with_status() - local bookmarks_output, success = runner.execute_command( - [[jj bookmark list -T 'if(!self.remote(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")' --quiet]], - "Failed to get bookmarks", - nil, - true - ) + local cmd = { + "jj", + "bookmark", + "list", + "-T", + 'if(!self.remote(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")', + "--quiet", + } + local bookmarks_output, success = runner.execute(cmd, "Failed to get bookmarks", nil, true) if not success or not bookmarks_output then return {} @@ -435,13 +439,17 @@ end --- @return {name: string, is_deleted: boolean}[]|nil ret List of bookmark objects, or nil on failure function M.get_bookmarks_for_rev(revset) -- Retrieve name and deleted status - local cmd = string.format( - [[jj log -r %s -T 'bookmarks.map(|b| b.name() ++ "::" ++ b.present()).join(" ")' --no-graph]], - vim.fn.shellescape(revset) - ) + local cmd = { + "jj", + "log", + "-r", + revset, + "-T", + 'bookmarks.map(|b| b.name() ++ "::" ++ b.present()).join(" ")', + "--no-graph", + } - local output, success = - runner.execute_command(cmd, string.format("Error retrieving bookmark for `%s`", revset), nil, false) + local output, success = runner.execute(cmd, string.format("Error retrieving bookmark for `%s`", revset), nil, false) if not success or not output then return nil @@ -455,13 +463,17 @@ end function M.get_all_tags() -- Use a custom template to output just the bookmark names, one per line -- This is more reliable than parsing the default output format - local bookmarks_output, success = runner.execute_command( - [[jj tag list --quiet --sort committer-date- -T 'if(!self.remote(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")']], - "Failed to get bookmarks", - nil, - true - ) - + local cmd = { + "jj", + "tag", + "list", + "--quiet", + "--sort", + "committer-date-", + "-T", + 'if(!self.remote(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")', + } + local bookmarks_output, success = runner.execute(cmd, "Failed to get bookmarks", nil, true) if not success or not bookmarks_output then return {} end @@ -486,8 +498,12 @@ end --- Whether or not the repository is colocated --- @return boolean function M.is_colocated() - local output, success = - runner.execute_command("jj git colocation status ", "Failed to determine if repository is colocated", nil, true) + local output, success = runner.execute( + { "jj", "git", "colocation", "status" }, + "Failed to determine if repository is colocated", + nil, + true + ) if not success or not output then return false @@ -501,13 +517,16 @@ end function M.get_untracked_bookmarks() -- Use a custom template to output just the bookmark names, one per line -- This is more reliable than parsing the default output format - local bookmarks_output, success = runner.execute_command( - [[jj bookmark list -a -T 'if(self.remote() && !self.tracked(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")']], - "Failed to get untracked bookmarks", - nil, - true - ) + local cmd = { + "jj", + "bookmark", + "list", + "-a", + "-T", + 'if(self.remote() && !self.tracked(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")', + } + local bookmarks_output, success = runner.execute(cmd, "Failed to get untracked bookmarks", nil, true) if not success or not bookmarks_output then return {} end @@ -533,7 +552,7 @@ end --- @return {name: string, url: string}[]|nil A list of remotes with name and URL function M.get_remotes() local remote_list, remote_success = - runner.execute_command("jj git remote list", "Failed to get git remote", nil, true) + runner.execute({ "jj", "git", "remote", "list" }, "Failed to get git remote", nil, true) if not remote_success or not remote_list then return @@ -612,13 +631,18 @@ end --- @param revset string The revset to check --- @return boolean True if the change is immutable, false otherwise function M.is_change_immutable(revset) - local output, success = runner.execute_command( - string.format("jj log --no-graph -r '%s' -T 'immutable' --quiet", revset), - "Error checking change immutability", - nil, - true - ) + local cmd = { + "jj", + "log", + "--no-graph", + "-r", + revset, + "-T", + "immutable", + "--quiet", + } + local output, success = runner.execute(cmd, "Error checking change immutability", nil, true) if not success or not output then return false end @@ -630,13 +654,18 @@ end --- @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 - ) + local cmd = { + "jj", + "log", + "--no-graph", + "-r", + revset, + "-T", + "empty", + "--quiet", + } + local output, success = runner.execute(cmd, "Error checking if revset is empty", nil, true) if not success or not output then return false end @@ -648,12 +677,18 @@ end --- @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 - ) + local cmd = { + "jj", + "log", + "--no-graph", + "-r", + revset, + "-T", + "conflict", + "--quiet", + } + + local output, success = runner.execute(cmd, "Error checking if revset has conflicts", nil, true) if not success or not output then return false @@ -670,23 +705,38 @@ function M.get_describe_text(revset) revset = "@" end - local parser = require("jj.core.parser") - local old_description_raw, success = runner.execute_command( - "jj log -r " .. revset .. " --quiet --no-graph -T 'coalesce(description, \"\\n\")'", - "Failed to get old description" - ) + local old_desc_cmd = { + "jj", + "log", + "-r", + revset, + "--quiet", + "--no-graph", + "-T", + 'coalesce(description, "\\n")', + } + local old_description_raw, success = runner.execute(old_desc_cmd, "Failed to get old description") if not old_description_raw or not success then return nil end - local status_result, success2 = runner.execute_command( - "jj log -r " .. revset .. " --quiet --no-graph -T 'self.diff().summary()'", - "Error getting status" - ) + local cmd_files = { + "jj", + "log", + "-r", + revset, + "--quiet", + "--no-graph", + "-T", + "self.diff().summary()", + } + + local status_result, success2 = runner.execute(cmd_files, "Error getting status") if not success2 then return nil end + local parser = require("jj.core.parser") local status_files = parser.get_status_files(status_result) local old_description = vim.trim(old_description_raw) local description_lines = vim.split(old_description, "\n") @@ -711,13 +761,17 @@ end --- @param revset string The revset to extract the commit id from --- @return string|nil function M.get_commit_id(revset) - local output, success = runner.execute_command( - string.format([[jj log --no-graph -r '%s' -T 'commit_id ++ "\n"' --quiet]], revset), - "Error extracting commit id", - nil, - true - ) - + local cmd = { + "jj", + "log", + "--no-graph", + "-r", + revset, + "-T", + 'commit_id ++ "\\n"', + "--quiet", + } + local output, success = runner.execute(cmd, "Error extracting commit id", nil, true) if not success or not output then return nil end @@ -742,13 +796,17 @@ end --- Get the commit id of the current revision --- @return string|nil function M.get_current_commit_id() - local output, success = runner.execute_command( - "jj log --no-graph -r '@' -T 'commit_id' --quiet", - "Error extracting current revision's commit id", - nil, - true - ) - + local cmd = { + "jj", + "log", + "--no-graph", + "-r", + "@", + "-T", + 'commit_id ++ "\\n"', + "--quiet", + } + local output, success = runner.execute(cmd, "Error extracting current revision's commit id", nil, true) if not success or not output then return nil end @@ -787,13 +845,18 @@ function M.get_pushed_commit_id(start_revset, remote_name, max_walkback) for _ = 0, max_walkback do -- Use a single-quoted StringLiteral for the revset, so we can keep the -- remote name quoted inside the revset expression. - local cmd = string.format( - "jj log -r %s --no-graph --quiet -T %s", - vim.fn.shellescape(current), - vim.fn.shellescape(templ) - ) + local cmd = { + "jj", + "log", + "-r", + current, + "--no-graph", + "--quiet", + "-T", + templ, + } - local out, ok = runner.execute_command(cmd, "Error determining remote-reachable commit", nil, true) + local out, ok = runner.execute(cmd, "Error determining remote-reachable commit", nil, true) if ok and out and not out:match("^%s*$") then return vim.trim(out) end @@ -818,9 +881,18 @@ function M.get_unique_remote_bookmark_name(revset, remote_name) -- Render remote bookmarks as tab-separated pairs: \t\n local tmpl = [[self.remote_bookmarks().map(|b| b.remote() ++ "\t" ++ b.name() ++ "\n").join("")]] - local cmd = - string.format("jj log -r %s --no-graph --quiet -T %s", vim.fn.shellescape(revset), vim.fn.shellescape(tmpl)) - local out, ok = runner.execute_command(cmd, "Error getting remote bookmarks", nil, true) + local cmd = { + "jj", + "log", + "-r", + revset, + "--no-graph", + "--quiet", + "-T", + tmpl, + } + + local out, ok = runner.execute(cmd, "Error getting remote bookmarks", nil, true) if not ok or not out or out:match("^%s*$") then return nil end @@ -889,12 +961,20 @@ function M.list_github_prs(opts) end -- Start with a hardcoded limit of 100 - local cmd = - [[gh pr list -L %s --json number,title,author --jq '.[] | "#\(.number);;;\(.title);;;(@\(.author.login))"']] - cmd = string.format(cmd, limit) + local cmd = { + "gh", + "pr", + "list", + "-L", + tostring(limit), + "--json", + "number,title,author", + "--jq", + [[.[] | "#\(.number);;;\(.title);;;(@\(.author.login))"]], + } -- Run the command to get the pr's - local output, success = runner.execute_command_sync(cmd, nil, "Failed to get prs") + local output, success = runner.execute(cmd, "Failed to get prs") if not success or not output then return end @@ -930,16 +1010,18 @@ function M.open_first_conflicted_file(revset) end local repo_root = M.get_jj_root() - local quoted_revset = vim.fn.shellescape(revset) + local cmd = { + "jj", + "resolve", + "-r", + revset, + "--list", + } -- Resolve the first conflicted path before editing so we can still open it -- reliably when the current working directory is outside the repo root. - local list_output, list_ok = runner.execute_command( - string.format("jj resolve -r %s --list", quoted_revset), - string.format("could not list conflicted files for '%s'", revset), - nil, - true - ) + local list_output, list_ok = + runner.execute(cmd, string.format("could not list conflicted files for '%s'", revset), nil, true) local first_conflicted_path if list_ok and type(list_output) == "string" and list_output ~= "" then @@ -952,8 +1034,8 @@ function M.open_first_conflicted_file(revset) end end - local _, ok = runner.execute_command( - string.format("jj edit %s --ignore-immutable", quoted_revset), + local _, ok = runner.execute( + { "jj", "edit", revset, "--ignore-immutable" }, string.format("could not edit revision '%s'", revset) ) @@ -982,14 +1064,4 @@ function M.open_first_conflicted_file(revset) end end ---- Build a shell-safe jj fileset argument for a literal path. ---- jj path arguments use fileset syntax, so special characters like `$` ---- must be wrapped in jj string quotes before shell-escaping. ----@param path string ----@return string -function M.escape_fileset(path) - local fileset_literal = string.format('"%s"', path:gsub("\\", "\\\\"):gsub('"', '\\"')) - return vim.fn.shellescape(fileset_literal) -end - return M diff --git a/tests/run_tests.lua b/tests/run_tests.lua index e0563d6..ff9069a 100755 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -10,6 +10,7 @@ package.path = package.path .. ";lua/?.lua;lua/?/init.lua" local parser = require("jj.core.parser") local utils = require("jj.utils") +local jj_args = require("jj.core.args") local tests_passed = 0 local tests_failed = 0 @@ -529,37 +530,33 @@ print("\n=== Running build_log_cmd tests ===\n") local log = require("jj.cmd.log") run_test("build_log_cmd: raw_flags with --no-pager does not duplicate it", function() - local cmd = log.build_log_cmd({ raw_flags = "--no-pager --limit 18" }) - -- Should contain exactly one --no-pager - local _, count = cmd:gsub("%-%-no%-pager", "") - assert_equals(1, count, "Expected exactly one --no-pager") - assert_equals("jj log --no-pager --limit 18", cmd) + local cmd = log.build_log_cmd({ raw_flags = { "--no-pager", "--limit", "18" } }) + assert_table_equals({ "jj", "log", "--no-pager", "--limit", "18" }, cmd) end) run_test("build_log_cmd: raw_flags without --no-pager works normally", function() - local cmd = log.build_log_cmd({ raw_flags = "--limit 18" }) - assert_equals("jj log --no-pager --limit 18", cmd) + local cmd = log.build_log_cmd({ raw_flags = { "--limit", "18" } }) + assert_table_equals({ "jj", "log", "--no-pager", "--limit", "18" }, cmd) end) run_test("build_log_cmd: raw_flags that is only --no-pager", function() - local cmd = log.build_log_cmd({ raw_flags = "--no-pager" }) - assert_equals("jj log --no-pager", cmd) + local cmd = log.build_log_cmd({ raw_flags = { "--no-pager" } }) + assert_table_equals({ "jj", "log", "--no-pager" }, cmd) end) run_test("build_log_cmd: structured opts with limit", function() local cmd = log.build_log_cmd({ limit = 10 }) - assert_equals(true, cmd:find("--limit 10") ~= nil, "Expected --limit 10 in command") - assert_equals(true, cmd:find("--no%-pager") ~= nil, "Expected --no-pager in command") + assert_table_equals({ "jj", "log", "--no-pager", "--limit", "10" }, cmd) end) run_test("build_log_cmd: structured opts with revisions", function() local cmd = log.build_log_cmd({ revisions = "main" }) - assert_equals(true, cmd:find("--revisions main") ~= nil, "Expected --revisions main in command") + assert_table_equals({ "jj", "log", "--no-pager", "--revisions", "main" }, cmd) end) run_test("build_log_cmd: default opts produces valid command", function() local cmd = log.build_log_cmd({}) - assert_equals(true, cmd:find("^jj log %-%-no%-pager") ~= nil, "Expected command to start with jj log --no-pager") + assert_table_equals({ "jj", "log", "--no-pager" }, cmd) end) print("\n=== Running resolve arg parsing tests ===\n") @@ -623,14 +620,14 @@ end) local resolve = require("jj.cmd.resolve") -run_test("resolve: shellescapes args for external execution", function() +run_test("resolve: passes jj-quoted argv for external execution", function() local runner = require("jj.core.runner") - local original_execute_command_async = runner.execute_command_async + local original_execute_argv_async = runner.execute_async local original_notify = utils.notify local original_ensure_jj = utils.ensure_jj local captured_cmd = nil - runner.execute_command_async = function(cmd) + runner.execute_async = function(cmd) captured_cmd = cmd end utils.notify = function() end @@ -645,13 +642,19 @@ run_test("resolve: shellescapes args for external execution", function() filesets = { "dir with spaces/", "glob:*" }, external = true, }) - assert_equals( - "'jj' 'resolve' '--revision' 'abc 123' '--tool' 'my tool' 'dir with spaces/' 'glob:*'", - captured_cmd - ) + assert_table_equals({ + "jj", + "resolve", + "--revision", + "abc 123", + "--tool", + "my tool", + jj_args.fileset("dir with spaces/"), + jj_args.fileset("glob:*"), + }, captured_cmd) end) - runner.execute_command_async = original_execute_command_async + runner.execute_async = original_execute_argv_async utils.notify = original_notify utils.ensure_jj = original_ensure_jj if not ok then @@ -659,7 +662,7 @@ run_test("resolve: shellescapes args for external execution", function() end end) -run_test("resolve: passes argv for floating execution", function() +run_test("resolve: passes jj-quoted filesets for floating execution", function() local terminal = require("jj.ui.terminal") local original_run_floating = terminal.run_floating local original_notify = utils.notify @@ -687,8 +690,8 @@ run_test("resolve: passes argv for floating execution", function() "abc 123", "--tool", "my tool", - "dir with spaces/", - "glob:*", + jj_args.fileset("dir with spaces/"), + jj_args.fileset("glob:*"), }, captured_cmd) end) @@ -704,10 +707,10 @@ print("\n=== Running utils helper tests ===\n") run_test("is_change_conflicted: returns true when jj reports conflict", function() local runner = require("jj.core.runner") - local original_execute_command = runner.execute_command + local original_execute_argv = runner.execute - runner.execute_command = function(cmd, error_prefix, input, silent) - assert_equals("jj log --no-graph -r 'abc123' -T 'conflict' --quiet", cmd) + runner.execute = function(argv, error_prefix, input, silent) + assert_table_equals({ "jj", "log", "--no-graph", "-r", "abc123", "-T", "conflict", "--quiet" }, argv) assert_equals("Error checking if revset has conflicts", error_prefix) assert_is_nil(input) assert_equals(true, silent) @@ -717,7 +720,7 @@ run_test("is_change_conflicted: returns true when jj reports conflict", function local ok, err = pcall(function() assert_equals(true, utils.is_change_conflicted("abc123")) end) - runner.execute_command = original_execute_command + runner.execute = original_execute_argv if not ok then error(err) end @@ -725,16 +728,16 @@ end) run_test("is_change_conflicted: returns false when jj reports no conflict", function() local runner = require("jj.core.runner") - local original_execute_command = runner.execute_command + local original_execute_argv = runner.execute - runner.execute_command = function() + runner.execute = function() return "false\n", true end local ok, err = pcall(function() assert_equals(false, utils.is_change_conflicted("abc123")) end) - runner.execute_command = original_execute_command + runner.execute = original_execute_argv if not ok then error(err) end @@ -742,16 +745,16 @@ end) run_test("is_change_conflicted: returns false when jj command fails", function() local runner = require("jj.core.runner") - local original_execute_command = runner.execute_command + local original_execute_argv = runner.execute - runner.execute_command = function() + runner.execute = function() return nil, false end local ok, err = pcall(function() assert_equals(false, utils.is_change_conflicted("abc123")) end) - runner.execute_command = original_execute_command + runner.execute = original_execute_argv if not ok then error(err) end @@ -870,8 +873,9 @@ print("\n=== Running get_file_content tests ===\n") run_test("get_file_content: reads existing file content", function() local runner = require("jj.core.runner") - local original = runner.execute_command_raw - runner.execute_command_raw = function() + local original = runner.execute_raw + runner.execute_raw = function(cmd) + assert_table_equals({ "jj", "file", "show", "-r", "abc123", jj_args.fileset("src/file.py") }, cmd) return "a\nb\n", true, "" end local ok_test, err = pcall(function() @@ -881,7 +885,7 @@ run_test("get_file_content: reads existing file content", function() assert_equals(true, ok) assert_equals(false, absent) end) - runner.execute_command_raw = original + runner.execute_raw = original if not ok_test then error(err) end @@ -889,8 +893,9 @@ end) run_test("get_file_content: absent path in revision reports absent (not a read error)", function() local runner = require("jj.core.runner") - local original = runner.execute_command_raw - runner.execute_command_raw = function() + local original = runner.execute_raw + runner.execute_raw = function(cmd) + assert_table_equals({ "jj", "file", "show", "-r", "abc123", jj_args.fileset("src/new_file.py") }, cmd) return nil, false, "Error: No such path: src/new_file.py\n" end local ok_test, err = pcall(function() @@ -900,7 +905,7 @@ run_test("get_file_content: absent path in revision reports absent (not a read e assert_equals(false, ok) assert_equals(true, absent) end) - runner.execute_command_raw = original + runner.execute_raw = original if not ok_test then error(err) end @@ -908,8 +913,9 @@ end) run_test("get_file_content: genuine read error returns failure without absent", function() local runner = require("jj.core.runner") - local original = runner.execute_command_raw - runner.execute_command_raw = function() + local original = runner.execute_raw + runner.execute_raw = function(cmd) + assert_table_equals({ "jj", "file", "show", "-r", "nope", jj_args.fileset("src/file.py") }, cmd) return nil, false, "Error: Revision `nope` doesn't exist\n" end local ok_test, err = pcall(function() @@ -917,7 +923,7 @@ run_test("get_file_content: genuine read error returns failure without absent", assert_equals(false, ok) assert_equals(false, absent) end) - runner.execute_command_raw = original + runner.execute_raw = original if not ok_test then error(err) end