feat(log): add bookmark deletion command (#106)

This commit is contained in:
larpi
2026-05-05 17:25:13 +02:00
committed by GitHub
parent 46932e5410
commit a2d194d49e
5 changed files with 269 additions and 60 deletions
+3
View File
@@ -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 = "<S-r>", -- 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 = "<S-p>", -- Push all changes to remote
+2
View File
@@ -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 = "<S-o>",
bookmark = "b",
bookmark_del = "B",
rebase = "r",
rebase_mode = {
onto = { "<CR>", "o" },
+112 -52
View File
@@ -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,
+78
View File
@@ -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()
+74 -8
View File
@@ -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))