diff --git a/README.md b/README.md index 5c08d69..ebc2024 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,8 @@ You can fetch and push directly from the log buffer: - `b` - Create a new bookmark or move an existing one to the revision under cursor - Select from existing bookmarks to move them - Or create a new bookmark at that revision +- `B` - Delete a bookmark at the revision under cursor + - If multiple bookmarks are present, select from the list or delete all of them ### Manage tags from the log buffer @@ -512,6 +514,7 @@ revision via `jj diffedit`. Immutable revisions show an error on write. redo = "", -- Redo last undone operation abandon = "a", -- Abandon revision under cursor bookmark = "b", -- Create or move bookmark to revision under cursor + bookmark_del = "B", -- Delete bookmark of revision under cursor fetch = "f", -- Fetch from remote push = "p", -- Push bookmark of revision under cursor push_all = "", -- Push all changes to remote diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index db10989..9213055 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -47,6 +47,7 @@ local split_module = require("jj.cmd.split") --- @field open_pr? string|string[] --- @field open_pr_list? string|string[] --- @field bookmark? string|string[] +--- @field bookmark_del? string|string[] --- @field rebase? string|string[] --- @field rebase_mode? jj.cmd.rebase.keymaps --- @field squash? string|string[] @@ -164,6 +165,7 @@ M.config = { open_pr = "o", open_pr_list = "", bookmark = "b", + bookmark_del = "B", rebase = "r", rebase_mode = { onto = { "", "o" }, diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index 74903e1..83db400 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -578,29 +578,35 @@ function M.handle_log_fetch() end --- Handle pushing from `jj log` buffer. ---- Askip the user for wich local bookmark to push. +--- Ask the user which local bookmark to push. function M.handle_log_push_from_all() - local bookkmarks = utils.get_all_bookmarks() + local bookmarks = utils.get_all_bookmarks_with_status() local cmd = "jj git push" - if not bookkmarks or #bookkmarks == 0 then + if not bookmarks or #bookmarks == 0 then utils.notify("No bookmarks found to push", vim.log.levels.ERROR) return end - vim.ui.select(bookkmarks, { + vim.ui.select(bookmarks, { prompt = "Select bookmark to push: ", + format_item = function(item) + if item.is_deleted then + return item.name .. " (deleted)" + end + return item.name + end, }, function(choice) if choice then - local push_cmd = string.format("%s -b %s", cmd, choice) - utils.notify(string.format("Pushing bookmark `%s`...", choice), vim.log.levels.INFO) + local push_cmd = string.format("%s -b %s", cmd, choice.name) + utils.notify(string.format("Pushing bookmark `%s`...", choice.name), vim.log.levels.INFO) runner.execute_command_async(push_cmd, function(output) if output and string.find(output, "Nothing changed%.") then utils.notify("Nothing changed.", vim.log.levels.INFO) else - utils.notify(string.format("Successfully pushed bookmark `%s`", choice), vim.log.levels.INFO) + utils.notify(string.format("Successfully pushed bookmark `%s`", choice.name), vim.log.levels.INFO) M.log({}) end - end, string.format("Error pushing bookmark `%s`", choice)) + end, string.format("Error pushing bookmark `%s`", choice.name)) else return end @@ -614,14 +620,9 @@ function M.handle_log_push_bookmark() return end - -- If we found a revfision get it's bookmark and push it - local bookmark, success = runner.execute_command( - string.format("jj log -r %s -T 'bookmarks' --no-graph", revset), - string.format("Error retrieving bookmark for `%s`", revset), - nil, - false - ) - if not success or not bookmark then + local bookmarks = utils.get_bookmarks_for_rev(revset) + if not bookmarks or #bookmarks == 0 then + utils.notify("No bookmark found for revision", vim.log.levels.ERROR) return end @@ -637,33 +638,28 @@ function M.handle_log_push_bookmark() end, string.format("Error pushing bookmark for `%s`", revset)) end - -- If there's a * trim it (bookmarks with modifications have *) - bookmark = bookmark:gsub("%*", ""):gsub("^%s+", ""):gsub("%s+$", "") - - if bookmark == "" then - utils.notify("No bookmark found for revision", vim.log.levels.ERROR) - return - end - -- If there are multiple bookmarks user must choose - if bookmark:find(" ") then - -- Split by whitespace - local bookmarks = {} - bookmarks = vim.split(bookmark, "%s+", { trimempty = true }) - table.insert(bookmarks, "[All]") + if #bookmarks > 1 then + table.insert(bookmarks, { name = "[All]", is_deleted = false }) vim.ui.select(bookmarks, { prompt = "Which bookmark do you want to push?", + format_item = function(item) + if item.is_deleted then + return item.name .. " (deleted)" + end + return item.name + end, }, function(choice) if choice then local cmd = "jj git push" - if choice == "[All]" then + if choice.name == "[All]" then -- Push all bookmarks cmd = string.format("%s --all", cmd) utils.notify("Pushing `ALL` bookmarks", vim.log.levels.INFO) else - utils.notify(string.format("Pushing bookmark `%s`...", choice), vim.log.levels.INFO) - cmd = string.format("%s -b %s", cmd, choice) + 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 @@ -672,9 +668,9 @@ function M.handle_log_push_bookmark() end) else -- If there's only one bookmark simply push it - -- Push the bookmark from the revset found - local cmd = string.format("jj git push -b %s", bookmark) - utils.notify(string.format("Pushing bookmark `%s`...", bookmark), vim.log.levels.INFO) + local b = bookmarks[1].name + local cmd = string.format("jj git push -b %s", b) + utils.notify(string.format("Pushing bookmark `%s`...", b), vim.log.levels.INFO) push(cmd) end end @@ -732,27 +728,86 @@ function M.handle_log_open_pr(list_bookmarks) end -- Get the bookmark for this revision - local bookmark, success = runner.execute_command( - string.format("jj log -r %s -T 'bookmarks' --no-graph", revset), - string.format("Error retrieving bookmark for `%s`", revset), - nil, - false - ) - - if not success or not bookmark then - return - end - - -- Trim and clean the bookmark (remove asterisks and whitespace) - bookmark = bookmark:match("^%*?(.-)%*?$"):gsub("%s+", "") - - if bookmark == "" then + local bookmarks = utils.get_bookmarks_for_rev(revset) + if not bookmarks or #bookmarks == 0 then utils.notify("[OPEN PR] No bookmark found for revision", vim.log.levels.ERROR) return end - -- Open the PR using the utility function - utils.open_pr_for_bookmark(bookmark) + if #bookmarks > 1 then + vim.ui.select(bookmarks, { + prompt = "Which bookmark do you want to open PR for?", + format_item = function(item) + if item.is_deleted then + return item.name .. " (deleted)" + end + return item.name + end, + }, function(choice) + if choice then + utils.open_pr_for_bookmark(choice.name) + end + end) + else + -- Open the PR using the utility function + utils.open_pr_for_bookmark(bookmarks[1].name) + end +end + +function M.handle_log_bookmark_del() + local revset = get_revset() + if not revset or revset == "" then + return + end + + local curr_bookmarks = utils.get_bookmarks_for_rev(revset) + if not curr_bookmarks or #curr_bookmarks == 0 then + utils.notify("No bookmark found for revision", vim.log.levels.ERROR) + return + end + + ---@param b_names string[] Bookmark names to delete + local function delete_bookmarks(b_names) + local escaped_list = {} + for _, b in ipairs(b_names) do + table.insert(escaped_list, vim.fn.shellescape(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() + utils.notify(string.format("Deleted bookmark `%s`", b_str), vim.log.levels.INFO) + M.log({}) + end, string.format("Error deleting bookmark `%s`", b_str)) + end + + table.insert(curr_bookmarks, { name = "[All]", is_deleted = false }) + + vim.ui.select(curr_bookmarks, { + prompt = "Which bookmark do you want to delete?", + format_item = function(item) + if item.is_deleted then + return item.name .. " (deleted)" + end + return item.name + end, + }, function(choice) + if choice then + if choice.name == "[All]" then + local all_names = vim.iter(curr_bookmarks) + :filter(function(b) + return b.name ~= "[All]" + end) + :map(function(b) + return b.name + end) + :totable() + delete_bookmarks(all_names) + else + delete_bookmarks({ choice.name }) + end + end + end) end -- Create or move bookmark at revision under cursor in `jj log` buffer @@ -1191,6 +1246,11 @@ function M.log_keymaps() handler = M.handle_log_bookmark, modes = { "n" }, }, + bookmark_del = { + desc = "Delete bookmark at revision under cursor", + handler = M.handle_log_bookmark_del, + modes = { "n" }, + }, rebase = { desc = "Rebase bookmark(s)", handler = M.handle_log_rebase, diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index 091bc58..598c017 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -370,6 +370,84 @@ function M.get_all_bookmarks() return bookmarks 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 + ) + + if not success or not bookmarks_output then + return {} + end + + local bookmarks = {} + local seen = {} + for line in bookmarks_output:gmatch("[^\n]+") do + local trimmed = vim.trim(line) + if trimmed ~= "" then + local is_deleted = trimmed:match(" %(deleted%)$") ~= nil + local name = trimmed:gsub(" %(deleted%)$", "") + if not seen[name] then + table.insert(bookmarks, { name = name, is_deleted = is_deleted }) + seen[name] = true + end + end + end + + return bookmarks +end + +--- Get unique base bookmark names from a string of space-separated bookmarks. +--- Handles the output of the template `bookmarks.map(|b| b.name() ++ "::" ++ b.present()).join(" ")` +--- Strips asterisks, strips @remote, and deduplicates. +--- @param bookmark_str string +--- @return {name: string, is_deleted: boolean}[] List of unique bookmark objects +function M.parse_bookmark_names(bookmark_str) + local raw_items = vim.split(bookmark_str, "%s+", { trimempty = true }) + local unique_bookmarks = {} + local seen = {} + + for _, item in ipairs(raw_items) do + local parts = vim.split(item, "::", { plain = true }) + if #parts == 2 then + -- Strip asterisks and @remote + local base_name = parts[1]:gsub("%*", ""):gsub("@.*$", "") + local is_deleted = parts[2] == "false" + + if base_name ~= "" and not seen[base_name] then + table.insert(unique_bookmarks, { name = base_name, is_deleted = is_deleted }) + seen[base_name] = true + end + end + end + + return unique_bookmarks +end + +--- Get unique base bookmark names and statuses for a given revset +--- @param revset string +--- @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 output, success = + runner.execute_command(cmd, string.format("Error retrieving bookmark for `%s`", revset), nil, false) + + if not success or not output then + return nil + end + + return M.parse_bookmark_names(output) +end + --- Get all tags in a repository --- @return string[] List of bookmarks, or empty list if none found function M.get_all_tags() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 0158f34..ef396fe 100755 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -9,6 +9,8 @@ package.path = package.path .. ";lua/?.lua;lua/?/init.lua" -- Load the parser module local parser = require("jj.core.parser") +local utils = require("jj.utils") + local tests_passed = 0 local tests_failed = 0 local failures = {} @@ -29,15 +31,19 @@ end local function assert_table_equals(expected, actual, msg) if type(expected) ~= "table" or type(actual) ~= "table" then - error(string.format("%s\nExpected table, got: %s and %s", msg or "Assertion failed", type(expected), type(actual))) + error( + string.format("%s\nExpected table, got: %s and %s", msg or "Assertion failed", type(expected), type(actual)) + ) end - if #expected ~= #actual then - error(string.format("%s\nLength mismatch: expected %d, got %d", msg or "Assertion failed", #expected, #actual)) - end - for i, v in ipairs(expected) do - if v ~= actual[i] then - error(string.format("%s\nAt index %d: expected %s, got %s", msg or "Assertion failed", i, tostring(v), tostring(actual[i]))) - end + if not vim.deep_equal(expected, actual) then + error( + string.format( + "%s\nExpected: %s\nGot: %s", + msg or "Assertion failed", + vim.inspect(expected), + vim.inspect(actual) + ) + ) end end @@ -432,6 +438,66 @@ run_test("build_log_cmd: default opts produces valid command", function() assert_equals(true, cmd:find("--limit 20") ~= nil, "Expected default --limit 20") end) +print("\n=== Running utils.parse_bookmark_names tests ===\n") + +run_test("parse_bookmark_names: parses simple bookmark", function() + local input = "main::true" + assert_table_equals({ { name = "main", is_deleted = false } }, utils.parse_bookmark_names(input)) +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) + ) +end) + +run_test("parse_bookmark_names: strips asterisks", function() + local input = "main*::true feature-1*::true" + assert_table_equals( + { { name = "main", is_deleted = false }, { name = "feature-1", is_deleted = false } }, + utils.parse_bookmark_names(input) + ) +end) + +run_test("parse_bookmark_names: strips remote suffixes", function() + local input = "main@origin::true feature-1@remote::false" + assert_table_equals( + { { name = "main", is_deleted = false }, { name = "feature-1", is_deleted = true } }, + utils.parse_bookmark_names(input) + ) +end) + +run_test("parse_bookmark_names: deduplicates bookmarks", function() + local input = "main::true main@origin::true main*::true" + assert_table_equals({ { name = "main", is_deleted = false } }, utils.parse_bookmark_names(input)) +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) + ) +end) + +run_test("parse_bookmark_names: handles empty input", function() + assert_table_equals({}, utils.parse_bookmark_names("")) +end) + +run_test("parse_bookmark_names: handles whitespace only", function() + assert_table_equals({}, utils.parse_bookmark_names(" ")) +end) + -- Print summary print(string.format("\n=== Test Summary ===")) print(string.format("Passed: %d", tests_passed))