diff --git a/README.md b/README.md index 7e019f1..4d053a4 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - [Manage tags from the log buffer](#manage-tags-from-the-log-buffer) - [Squash changes from the log buffer](#squash-changes-from-the-log-buffer) - [Split changes from the log buffer](#split-changes-from-the-log-buffer) + - [Resolve conflicts from the log buffer](#resolve-conflicts-from-the-log-buffer) - [Rebase changes from the log buffer](#rebase-changes-from-the-log-buffer) - [Duplicate changes from the log buffer](#duplicate-changes-from-the-log-buffer) - [Open a PR/MR from the log buffer](#open-a-prmr-from-the-log-buffer) @@ -39,6 +40,7 @@ - [Configuration Examples](#configuration-examples) - [Editor Options](#editor-options) - [New Command Options](#new-command-options) + - [Resolve Command Options](#resolve-command-options) - [Push Command Options](#push-command-options) - [Bookmark Management Command Options](#bookmark-management-command-options) - [Open PR/MR Command Options](#open-prmr-command-options) @@ -68,6 +70,7 @@ - `edit` - Edit a change - `squash` - Squash the current diff to its parent or interactive squash mode from the log buffer - `split` - Split a change interactively in a floating terminal + - `resolve` - Resolve conflicts interactively in a floating terminal or via external strategy - `rebase` - Rebase changes to a destination - `duplicate` - Duplicate one or more changes to a destination revision - `bookmark create/delete/track/forget` - Create, delete, track, and forget (untrack) bookmarks @@ -250,6 +253,23 @@ cmd.split({ filesets = { "src/" } }) -- Only include specific filese cmd.split({ ignore_immutable = true }) -- Split an immutable revision ``` +### Resolve conflicts from the log buffer + +Resolve conflicts for the revision under the cursor directly from the log buffer: + +- `gr` - Resolve conflicts for the selected revision + +How it integrates with the log buffer: + +- Uses `jj resolve --revision ` for the revision under cursor +- If `cmd.resolve_strategies` has more than one strategy, `vim.ui.select` prompts you to choose one +- If a strategy is selected, its `args` and `external` options are passed to `cmd.resolve(...)` +- On success, the log buffer is automatically reloaded so conflict state is refreshed +- When using floating terminals, cancelled/failed resolve attempts restore the log view + +> [!NOTE] +> See the [Example config](#example-config) below for a complete `resolve_strategies` setup. + ### Rebase changes from the log buffer Enter an interactive rebase mode directly from the log buffer to rebase one or more changes: @@ -401,17 +421,25 @@ The plugin provides a `:J` command that accepts jj subcommands: :Jbrowse " Open current file on remote at cursor line :Jbrowse main " Open current file on remote at the given revset :J split " Split a change interactively -:J diff_history " Prompt for a `left..right` range and open a history-aware diff +:J resolve " Resolve conflicts for @ +:J resolve -r abc123 --tool mergiraf --external src/ " Resolve rev/filesets with an external tool +:J resolve " Resolve conflicts for @ +:J resolve -r abc123 " Resolve a specific revision +:J resolve --revision abc123 src/ " Resolve only matching filesets +:J resolve --tool mergiraf --external " Resolve using an external tool +:J resolve --tool meld --ext src/ " --ext alias + fileset filtering +:J diff_history " Prompt for a `left..right` range and open a history-aware diff :J diff_history main..@ " Open a history-aware diff between main and the working copy :J bookmark create/move/delete/track/forget -:J tag set " Set a tag (prompts for revision and tag name) -:J tag set abc123 " Set a tag on a specific revision -:J tag delete " Delete a tag via picker -:J tag delete v1.0 " Delete a specific tag +:J tag set " Set a tag (prompts for revision and tag name) +:J tag set abc123 " Set a tag on a specific revision +:J tag delete " Delete a tag via picker +:J tag delete v1.0 " Delete a specific tag :J # This will use your defined default command :J -:J commit " Opens your configured editor describes @ and then creates a new change -A immediately +:J commit " Opens your configured editor describes @ and then creates a new change -A immediately :J commit " Automatically describes @ and creates a new change -A immediately + ``` ### Diff Commands @@ -539,6 +567,20 @@ revision via `jj diffedit`. Immutable revisions show an error on write. close_on_edit = false, -- Close log buffer after editing a change }, + -- Optional resolve strategy picker shared across cmd integrations + resolve_strategies = { + { + name = "Meld", + args = { "--tool", "meld" }, + external = true, + }, + { + name = "Mergiraf", + args = { "--tool", "mergiraf" }, + external = true, + }, + }, + -- Configure bookmark command bookmark = { prefix = "" @@ -594,6 +636,7 @@ revision via `jj diffedit`. Immutable revisions show an error on write. }, quick_squash = "", -- Quick squash revision under cursor into its parent (ignore immutability) split = "", -- Split the revision under cursor + resolve = "gr", -- Resolve conflicts for revision under cursor history = "", -- Show a history-aware diff for the selected revision range change_revset = "", -- Change the revset(s) being viewed in the log buffer tag_set = "", -- Create a tag on the revision under cursor @@ -784,6 +827,39 @@ cmd.new({ show_log = true, with_input = true }) -- Prompt for parent cmd.new({ args = "--before @" }) -- Pass custom args ``` +### Resolve Command Options + +The `resolve` function accepts an options table: + +```lua +local cmd = require("jj.cmd") +cmd.resolve({ + rev = "@", -- Revision to resolve (default: "@") + filesets = { "src/" }, -- Optional filesets to limit what gets resolved + args = { "--tool", "meld" }, -- Extra args passed to `jj resolve` + external = true, -- Run as an external command instead of in an nvim floating terminal +}) + +-- Examples: +cmd.resolve() -- Resolve @ in floating terminal +cmd.resolve({ rev = "abc123" }) -- Resolve a specific revision +cmd.resolve({ rev = "abc123", filesets = { "lua/" } }) -- Resolve only selected filesets +cmd.resolve({ external = true, args = { "--tool", "kdiff3" } }) -- Use an external merge tool +``` + +When called from the log buffer via `gr`, `jj.nvim` can optionally prompt for a strategy using `cmd.resolve_strategies`. + +> [!NOTE] +> See [Example config](#example-config) for a full `cmd.resolve_strategies` example. + +CLI flags for `:J resolve`: + +- `-r ` or `--revision `: target revision (default: `@`) +- `--tool `: pass merge tool to `jj resolve` +- `--external` or `--ext`: run outside the floating terminal +- trailing positional args are treated as filesets +- unknown long options are rejected to avoid ambiguity with filesets + ### Push Command Options The `push` function accepts an options table: @@ -1098,6 +1174,18 @@ vim.keymap.set("n", "jA", annotate.line, { desc = "JJ annotate line" }) bookmark = { prefix = "feat/" }, + resolve_strategies = { + { + name = "Meld", + args = { "--tool", "meld" }, + external = true, + }, + { + name = "Mergiraf", + args = { "--tool", "mergiraf" }, + external = true, + }, + }, keymaps = { log = { edit = "", @@ -1105,6 +1193,7 @@ vim.keymap.set("n", "jA", annotate.line, { desc = "JJ annotate line" }) diff = "", abandon = "", fetch = "", + resolve = "gr", -- Resolve conflicts for revision under cursor }, status = { open_file = "", diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index ae738bb..49a8609 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -11,6 +11,7 @@ local log_module = require("jj.cmd.log") local describe_module = require("jj.cmd.describe") local status_module = require("jj.cmd.status") local split_module = require("jj.cmd.split") +local resolve_module = require("jj.cmd.resolve") -- Config for cmd module --- @class jj.cmd.describe.editor.keymaps @@ -115,9 +116,22 @@ local split_module = require("jj.cmd.split") ---@field parallel? boolean Run operations in parallel ---@field on_exit? fun(exit_code: number) Callback invoked when command exits +--- @class jj.cmd.resolve.strategy +--- @field name string Label shown in vim.ui.select +--- @field args? string[] Extra args passed to jj resolve +--- @field external? boolean Run externally instead of floating terminal + +---@class jj.cmd.resolve.opts +---@field rev string|nil The revision to resolve, defaults to "@" +---@field filesets string[]|nil The filesets to resolve, defaults to all filesets +---@field external boolean|nil Whether to use external merge tool, if this boolean is set the command will not be ran in a floating terminal inside neovim (defaults: false) +---@field args string[]|nil Additional arguments to pass to the resolve command +---@field on_exit? fun(exit_code: number) Callback invoked when command exits + --- @class jj.cmd.opts --- @field describe? jj.cmd.describe --- @field log? jj.cmd.log +--- @field resolve_strategies? jj.cmd.resolve.strategy[] List of conflict resolve strategies shared across cmd integrations --- @field bookmark? jj.cmd.bookmark --- @field keymaps? jj.cmd.keymaps Keymaps for the buffers containing the of the commands --- @@ -157,6 +171,7 @@ M.config = { log = { close_on_edit = false, }, + resolve_strategies = {}, bookmark = { prefix = "", }, @@ -214,6 +229,7 @@ M.config = { edit_file = "o", }, split = "", + resolve = "gr", tag_set = "", history = "", change_revset = "", @@ -248,6 +264,8 @@ M.describe = describe_module.describe M.status = status_module.status -- Rexport split function M.split = split_module.split +-- Reexport resolve function +M.resolve = resolve_module.resolve --- Merge multiple keymap arrays into one --- @param ... jj.core.buffer.keymap[][] Keymap arrays to merge @@ -1240,6 +1258,63 @@ function M.fetch_pr(opts) end) end +--- Parse arguments passed to `:J resolve` +--- @param args string[] +--- @return jj.cmd.resolve.opts|nil opts +--- @return string|nil err +function M.parse_resolve_args(args) + local opts = { rev = "@" } --[[@as jj.cmd.resolve.opts]] + local already_set = { + rev = false, + tool = false, + } + + local i = 1 + while i <= #args do + local arg = args[i] + + if arg == "--external" or arg == "--ext" then + opts.external = true + elseif arg == "--tool" then + local tool = args[i + 1] + if not tool or tool:sub(1, 1) == "-" then + return nil, "Missing value for --tool" + end + if already_set.tool then + return nil, "Tool already set. Cannot specify multiple tools." + end + + opts.args = opts.args or {} + table.insert(opts.args, "--tool") + table.insert(opts.args, tool) + already_set.tool = true + i = i + 1 + elseif arg == "--revision" or arg == "-r" then + local rev = args[i + 1] + if not rev or rev:sub(1, 1) == "-" then + return nil, "Missing value for --revision/-r" + end + if already_set.rev then + return nil, "Revision already set. Cannot specify multiple revisions." + end + + opts.rev = rev + already_set.rev = true + i = i + 1 + elseif arg:sub(1, 2) == "--" then + return nil, string.format("Unknown option: %s", arg) + else + -- Positional args are treated as filesets. + opts.filesets = opts.filesets or {} + table.insert(opts.filesets, arg) + end + + i = i + 1 + end + + return opts, nil +end + --- @param args string|string[] jj command arguments function M.j(args) if not utils.ensure_jj() then @@ -1304,27 +1379,39 @@ function M.j(args) M.log({ raw_flags = remaining_args_str ~= "" and remaining_args_str or nil }) end, split = function() - local rev = remaining_args and remaining_args[1] or "@" - local opts = { - rev = rev, + rev = "@", } - local index = 2 - for i = index, #remaining_args do + local i = 1 + while i <= #remaining_args do local arg = remaining_args[i] + if arg == "--parallel" then opts.parallel = true elseif arg == "--ignore-immutable" then opts.ignore_immutable = true elseif arg == "--message" and remaining_args[i + 1] then opts.message = remaining_args[i + 1] - index = i + 1 + i = i + 1 elseif arg == "--fileset" and remaining_args[i + 1] then opts.filesets = opts.filesets or {} table.insert(opts.filesets, remaining_args[i + 1]) - index = i + 1 + i = i + 1 + elseif arg:sub(1, 2) == "--" then + -- Unknown option: ignore it here. + utils.notify(string.format("Unknown option: %s", arg), vim.log.levels.WARN) + return + else + if opts.rev == "@" then + opts.rev = arg + else + opts.filesets = opts.filesets or {} + table.insert(opts.filesets, arg) + end end + + i = i + 1 end require("jj.cmd.split").split(opts) @@ -1430,6 +1517,14 @@ function M.j(args) fetch_pr = function() M.fetch_pr() end, + resolve = function() + local opts, err = M.parse_resolve_args(remaining_args) + if err then + utils.notify(err, vim.log.levels.ERROR) + return + end + require("jj.cmd.resolve").resolve(opts) + end, } if handlers[subcommand] then @@ -1481,6 +1576,7 @@ function M.register_command() "commit", "tag", "fetch_pr", + "resolve", } local matches = {} for _, cmd in ipairs(subcommands) do diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index 709d625..a88977e 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -943,6 +943,60 @@ function M.handle_log_split() }) end +--- Handle log resolve conflict +function M.handle_log_resolve() + local revset = get_revset() + if not revset or revset == "" then + return + end + + if not utils.is_change_conflicted(revset) then + utils.notify(string.format("Revision `%s` has no conflicts to resolve", revset), vim.log.levels.INFO) + return + end + + local exit_func = function(exit_code) + if exit_code == 0 then + utils.notify(string.format("Successfully resolved `%s`", revset), vim.log.levels.INFO) + vim.schedule(function() + M.log({}) + end) + else + -- Since we previously replaced the floating with the resolve we actually want to re run the log cmd + if require("jj").config.terminal.window.type == "floating" then + vim.schedule(function() + M.log({}) + end) + end + end + end + + local strategies = require("jj.cmd").config.resolve_strategies + if strategies and #strategies > 1 then + vim.ui.select(strategies, { + prompt = "Select a resolve strategy: ", + format_item = function(item) + return item.name + end, + }, function(choice) + if choice then + require("jj.cmd").resolve({ + rev = revset, + args = choice.args, + external = choice.external, + on_exit = exit_func, + }) + end + end) + elseif strategies and #strategies == 1 then + local choice = strategies[1] + require("jj.cmd").resolve({ rev = revset, args = choice.args, external = choice.external, on_exit = exit_func }) + else + -- Simply resolve with the default + require("jj.cmd").resolve({ rev = revset, on_exit = exit_func }) + end +end + --- Handle diff action in summary tooltip --- Diffs the file at revset against its parent (revset-) --- Opens a floating diff, and returns focus to tooltip when closed @@ -1322,6 +1376,11 @@ function M.log_keymaps() handler = M.handle_log_select_prev_revision, modes = { "n", "v" }, }, + resolve = { + desc = "Resolve conflicts for revision under cursor", + handler = M.handle_log_resolve, + modes = { "n" }, + }, } local merged_keymaps = cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps()) diff --git a/lua/jj/cmd/resolve.lua b/lua/jj/cmd/resolve.lua new file mode 100644 index 0000000..6ea7b3f --- /dev/null +++ b/lua/jj/cmd/resolve.lua @@ -0,0 +1,67 @@ +local M = {} + +local utils = require("jj.utils") +local terminal = require("jj.ui.terminal") +local runner = require("jj.core.runner") + +--- Resolve conflicts in the current change. +--- @param opts? jj.cmd.resolve.opts +function M.resolve(opts) + opts = vim.tbl_deep_extend("force", {}, opts or {}) --[[@as jj.cmd.resolve.opts]] + local rev = opts.rev or "@" + local filesets = opts.filesets or {} + local args = opts.args or {} + + if not utils.ensure_jj() then + return + end + + local cmd_args = { "jj", "resolve", "--revision", rev } + -- Extra arguments + vim.list_extend(cmd_args, args) + -- Append the filestes + vim.list_extend(cmd_args, filesets) + + local escaped_cmd_args = {} + for _, arg in ipairs(cmd_args) do + table.insert(escaped_cmd_args, vim.fn.shellescape(arg)) + end + local cmd = table.concat(escaped_cmd_args, " ") + + utils.notify(string.format("Resolving conflicts in change `%s`...", rev), vim.log.levels.INFO) + + -- If external is set, run the command asynchronously and invoke the on_exit callback if provided + if opts.external then + -- Run the command asynchronously and notify the user of the result + runner.execute_command_async( + cmd, + function(output) + if output and output ~= "" then + utils.notify(output, vim.log.levels.INFO) + end + if opts.on_exit then + opts.on_exit(0) + end + end, + string.format("Could not resolve conflicts in `%s`", rev), + nil, + nil, + function() + if opts.on_exit then + opts.on_exit(1) + end + end + ) + else + -- Otherwise, run in a floating terminal + terminal.run_floating(cmd, nil, { + title = " JJ Resolve ", + modifiable = true, + keep_modifiable = true, + interactive = true, + on_exit = opts.on_exit or nil, + }) + end +end + +return M diff --git a/lua/jj/core/runner.lua b/lua/jj/core/runner.lua index 13c1a69..ccf80f0 100644 --- a/lua/jj/core/runner.lua +++ b/lua/jj/core/runner.lua @@ -17,7 +17,8 @@ end --- @return boolean success Whether the command succeeded function M.execute_command(cmd, error_prefix, input, silent) local stderr_file = vim.fn.tempname() - local output = vim.fn.system({ "sh", "-c", string.format("(%s) 2>%s", cmd, vim.fn.shellescape(stderr_file)) }, input) + local output = + vim.fn.system({ "sh", "-c", string.format("(%s) 2>%s", cmd, vim.fn.shellescape(stderr_file)) }, input) local success = vim.v.shell_error == 0 if not success then diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index 5bd0a0b..945cee5 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -644,6 +644,24 @@ function M.is_change_empty(revset) return vim.trim(output) == "true" end +--- Check if a given revset has conflicts +--- @param revset string The revset to check +--- @return boolean True if the revset has conflicts, false otherwise +function M.is_change_conflicted(revset) + local output, success = runner.execute_command( + string.format("jj log --no-graph -r %s -T 'conflict' --quiet", vim.fn.shellescape(revset)), + "Error checking if revset has conflicts", + nil, + true + ) + + if not success or not output then + return false + end + + return vim.trim(output) == "true" +end + --- Build describe text for a given revision --- @param revset? string The revision to describe (default: @) --- @return string[]|nil diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 2680c7b..2ab59cf 100755 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -493,7 +493,195 @@ 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_equals(true, cmd:find("--limit 20") ~= nil, "Expected default --limit 20") +end) + +print("\n=== Running resolve arg parsing tests ===\n") + +local cmd = require("jj.cmd") + +run_test("parse_resolve_args: defaults to @", function() + local opts, err = cmd.parse_resolve_args({}) + assert_is_nil(err) + assert_table_equals({ rev = "@" }, opts) +end) + +run_test("parse_resolve_args: parses revision, tool, external and filesets", function() + local opts, err = cmd.parse_resolve_args({ + "-r", + "abc123", + "--tool", + "mergiraf", + "--external", + "src/", + "README.md", + }) + assert_is_nil(err) + assert_table_equals({ + rev = "abc123", + external = true, + args = { "--tool", "mergiraf" }, + filesets = { "src/", "README.md" }, + }, opts) +end) + +run_test("parse_resolve_args: errors on unknown long option", function() + local opts, err = cmd.parse_resolve_args({ "--summary", "src/" }) + assert_is_nil(opts) + assert_equals("Unknown option: --summary", err) +end) + +run_test("parse_resolve_args: errors on duplicate --tool", function() + local opts, err = cmd.parse_resolve_args({ "--tool", "meld", "--tool", "mergiraf" }) + assert_is_nil(opts) + assert_equals("Tool already set. Cannot specify multiple tools.", err) +end) + +run_test("parse_resolve_args: errors on duplicate revision", function() + local opts, err = cmd.parse_resolve_args({ "-r", "a", "--revision", "b" }) + assert_is_nil(opts) + assert_equals("Revision already set. Cannot specify multiple revisions.", err) +end) + +run_test("parse_resolve_args: errors on missing --tool value", function() + local opts, err = cmd.parse_resolve_args({ "--tool" }) + assert_is_nil(opts) + assert_equals("Missing value for --tool", err) +end) + +run_test("parse_resolve_args: errors on missing -r/--revision value", function() + local opts, err = cmd.parse_resolve_args({ "-r" }) + assert_is_nil(opts) + assert_equals("Missing value for --revision/-r", err) +end) + +local resolve = require("jj.cmd.resolve") + +run_test("resolve: shellescapes args for external execution", function() + local runner = require("jj.core.runner") + local original_execute_command_async = runner.execute_command_async + local original_notify = utils.notify + local original_ensure_jj = utils.ensure_jj + + local captured_cmd = nil + runner.execute_command_async = function(cmd) + captured_cmd = cmd + end + utils.notify = function() end + utils.ensure_jj = function() + return true + end + + local ok, err = pcall(function() + resolve.resolve({ + rev = "abc 123", + args = { "--tool", "my tool" }, + filesets = { "dir with spaces/", "glob:*" }, + external = true, + }) + assert_equals( + "'jj' 'resolve' '--revision' 'abc 123' '--tool' 'my tool' 'dir with spaces/' 'glob:*'", + captured_cmd + ) + end) + + runner.execute_command_async = original_execute_command_async + utils.notify = original_notify + utils.ensure_jj = original_ensure_jj + if not ok then + error(err) + end +end) + +run_test("resolve: shellescapes args for floating execution", function() + local terminal = require("jj.ui.terminal") + local original_run_floating = terminal.run_floating + local original_notify = utils.notify + local original_ensure_jj = utils.ensure_jj + + local captured_cmd = nil + terminal.run_floating = function(cmd) + captured_cmd = cmd + end + utils.notify = function() end + utils.ensure_jj = function() + return true + end + + local ok, err = pcall(function() + resolve.resolve({ + rev = "abc 123", + args = { "--tool", "my tool" }, + filesets = { "dir with spaces/", "glob:*" }, + }) + assert_equals( + "'jj' 'resolve' '--revision' 'abc 123' '--tool' 'my tool' 'dir with spaces/' 'glob:*'", + captured_cmd + ) + end) + + terminal.run_floating = original_run_floating + utils.notify = original_notify + utils.ensure_jj = original_ensure_jj + if not ok then + error(err) + end +end) + +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 + + runner.execute_command = function(cmd, error_prefix, input, silent) + assert_equals("jj log --no-graph -r 'abc123' -T 'conflict' --quiet", cmd) + assert_equals("Error checking if revset has conflicts", error_prefix) + assert_is_nil(input) + assert_equals(true, silent) + return "true\n", true + end + + local ok, err = pcall(function() + assert_equals(true, utils.is_change_conflicted("abc123")) + end) + runner.execute_command = original_execute_command + if not ok then + error(err) + end +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 + + runner.execute_command = 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 + if not ok then + error(err) + end +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 + + runner.execute_command = 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 + if not ok then + error(err) + end end) print("\n=== Running utils.parse_bookmark_names tests ===\n") @@ -505,14 +693,11 @@ end) run_test("parse_bookmark_names: parses multiple bookmarks", function() local input = "main::true feature-1::true feature-2::false" - assert_table_equals( - { - { name = "main", is_deleted = false }, - { name = "feature-1", is_deleted = false }, - { name = "feature-2", is_deleted = true }, - }, - utils.parse_bookmark_names(input) - ) + assert_table_equals({ + { name = "main", is_deleted = false }, + { name = "feature-1", is_deleted = false }, + { name = "feature-2", is_deleted = true }, + }, utils.parse_bookmark_names(input)) end) run_test("parse_bookmark_names: strips asterisks", function() @@ -538,14 +723,11 @@ end) run_test("parse_bookmark_names: handles mixed input", function() local input = "main*::true feature-1::true feature-1@origin::true feature-2*::false" - assert_table_equals( - { - { name = "main", is_deleted = false }, - { name = "feature-1", is_deleted = false }, - { name = "feature-2", is_deleted = true }, - }, - utils.parse_bookmark_names(input) - ) + assert_table_equals({ + { name = "main", is_deleted = false }, + { name = "feature-1", is_deleted = false }, + { name = "feature-2", is_deleted = true }, + }, utils.parse_bookmark_names(input)) end) run_test("parse_bookmark_names: handles empty input", function()