From 19db4e8178c17dd827bed27bb21edb242a4dec40 Mon Sep 17 00:00:00 2001 From: Nicolas GB Date: Sun, 22 Feb 2026 08:53:51 -0800 Subject: [PATCH] feat(tag): Implement set/delete/push (push only colocated repistories) (#84) --- README.md | 40 +++++++++ lua/jj/cmd/init.lua | 200 ++++++++++++++++++++++++++++++++++++++++++++ lua/jj/cmd/log.lua | 18 ++++ lua/jj/utils.lua | 46 ++++++++++ 4 files changed, 304 insertions(+) diff --git a/README.md b/README.md index 514af20..9b03c6a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ - [Abandon changes from the log buffer](#abandon-changes-from-the-log-buffer) - [Fetch and push from the log buffer](#fetch-and-push-from-the-log-buffer) - [Manage bookmarks from the log buffer](#manage-bookmarks-from-the-log-buffer) + - [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) - [Rebase changes from the log buffer](#rebase-changes-from-the-log-buffer) @@ -64,6 +65,7 @@ - `split` - Split a change interactively in a floating terminal - `rebase` - Rebase changes to a destination - `bookmark create/delete` - Create and delete bookmarks + - `tag set/delete/push` - Create, delete, and push tags (push requires colocated repos) - `undo` - Undo the last operation - `redo` - Redo the last undone operation - `open_pr` - Open a PR/MR on your remote (GitHub, GitLab, Gitea, Forgejo, etc.) @@ -153,6 +155,12 @@ You can fetch and push directly from the log buffer: - Select from existing bookmarks to move them - Or create a new bookmark at that revision +### Manage tags from the log buffer + +- `` - Create a new tag on the revision under cursor + +Deleting and pushing tags (for colocated repositories) is also supported and changes are reflected if the log buffer is open. Set up some keybinds and you're good to go, please see [here](#tag-management-command-options). + ### Squash changes from the log buffer Enter an interactive squash mode to squash one or more changes into a destination: @@ -302,6 +310,10 @@ The plugin provides a `:J` command that accepts jj subcommands: :J open_pr --list " Select bookmark from all and open PR :J split " Split a change interactively :J bookmark create/move/delete +: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 @@ -433,6 +445,7 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing }, quick_squash = "", -- Quick squash revision under cursor into its parent (ignore immutability) split = "", -- Split the revision under cursor + tag_create = "", -- Create a tag on the revision under cursor summary = "", -- Show summary tooltip for revision under cursor summary_tooltip = { diff = "", -- Diff file at this revision @@ -611,6 +624,30 @@ local cmd = require("jj.cmd") cmd.bookmark_delete() -- Select bookmark to delete ``` +### Tag Management Command Options + +The `tag_set` function creates a tag on a revision: + +```lua +local cmd = require("jj.cmd") +cmd.tag_set() -- Prompts for revision and tag name +cmd.tag_set("abc123") -- Set a tag on a specific revision (prompts for tag name) +``` + +The `tag_delete` function deletes a tag via picker: + +```lua +local cmd = require("jj.cmd") +cmd.tag_delete() -- Select tag to delete from picker +``` + +The `tag_push` function pushes a tag to a remote (colocated repositories only): + +```lua +local cmd = require("jj.cmd") +cmd.tag_push() -- Select tag to push from picker (prompts for remote if multiple) +``` + ### Open PR/MR Command Options The `open_pr` function accepts an options table: @@ -825,6 +862,9 @@ vim.keymap.set("n", "jA", annotate.line, { desc = "JJ annotate line" }) vim.keymap.set("n", "jbc", cmd.bookmark_create, { desc = "JJ bookmark create" }) vim.keymap.set("n", "jbd", cmd.bookmark_delete, { desc = "JJ bookmark delete" }) vim.keymap.set("n", "jbm", cmd.bookmark_move, { desc = "JJ bookmark move" }) + vim.keymap.set("n", "jts", cmd.tag_set, { desc = "JJ tag set" }) + vim.keymap.set("n", "jtd", cmd.tag_delete, { desc = "JJ tag delete" }) + vim.keymap.set("n", "jtp", cmd.tag_push, { desc = "JJ tag push" }) vim.keymap.set("n", "ja", cmd.abandon, { desc = "JJ abandon" }) vim.keymap.set("n", "jf", cmd.fetch, { desc = "JJ fetch" }) vim.keymap.set("n", "jp", cmd.push, { desc = "JJ push" }) diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index 79d01a5..9dd9b87 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -55,6 +55,7 @@ local split_module = require("jj.cmd.split") --- @field quick_squash? string|string[] --- @field summary? string|string[] --- @field summary_tooltip? jj.cmd.summary_tooltip.keymaps +--- @field tag_set? string|string[] --- @class jj.cmd.rebase.keymaps --- @field onto? string|string[] @@ -189,6 +190,7 @@ M.config = { edit_immutable = "", }, split = "", + tag_set = "", }, status = { open_file = "", @@ -824,6 +826,187 @@ function M.commit(description) end, keymaps) end +--- Jujutsu tag set +--- @param rev string|nil +function M.tag_set(rev) + if not utils.ensure_jj() then + return + end + + local should_refresh = terminal.is_log_buffer_open() + + -- If the revision is not provided, ask the user for it, + if not rev then + if not should_refresh then + M.log({}) + end + + vim.ui.input({ prompt = "Revision to tag: " }, function(input) + if input and not input:match("^%s*$") then + rev = input + M.tag_set(rev) + else + end + end) + return + end + + -- 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() + utils.notify(string.format("Tag `%s` set on `%s`.", input, rev), vim.log.levels.INFO) + if should_refresh then + vim.schedule(function() + M.log() + end) + end + end, "Failed to set tag") + elseif input then + utils.notify("Tag name cannot be empty", vim.log.levels.ERROR) + end + end) +end + +--- Jujutsu tag delete +--- @param tag string|nil If provided, it will delete the given tag without asking the user +function M.tag_delete(tag) + if not utils.ensure_jj() then + return + end + + -- 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() + utils.notify(string.format("Tag `%s` deleted.", tag), vim.log.levels.INFO) + if terminal.is_log_buffer_open() then + vim.schedule(function() + M.log() + end) + end + end, "Failed to delete tag") + return + end + + local should_refresh = terminal.is_log_buffer_open() + if not should_refresh then + M.log({}) + should_refresh = true + end + + local tags = utils.get_all_tags() + if #tags == 0 then + utils.notify("No tags found to delete", vim.log.levels.ERROR) + return + end + + 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() + utils.notify(string.format("Tag `%s` deleted.", choice), vim.log.levels.INFO) + if should_refresh then + vim.schedule(function() + M.log() + end) + end + end, "Failed to delete tag") + end + end) +end + +--- Push all tags but only on collocated reposiotries +function M.tag_push() + if not utils.ensure_jj() then + return + end + + local should_refresh = terminal.is_log_buffer_open() + local is_colocated = utils.is_colocated() + local has_git = utils.has_executable("git") + + if not is_colocated then + utils.notify("Current repository is not colocated. Cannot push tags.", vim.log.levels.ERROR) + return + elseif not has_git then + utils.notify("Git executable not found. Cannot push tags.", vim.log.levels.ERROR) + return + end + + local remotes = utils.get_remotes() + if not remotes or #remotes == 0 then + utils.notify("No git remotes found. Cannot push tags.", vim.log.levels.ERROR) + return + end + + -- If many remotes we are forced to request the user what to do + if remotes and #remotes > 1 then + vim.ui.select(remotes, { + prompt = "Select remote to push tags to: ", + format_item = function(item) + return string.format("%s (%s)", item.name, item.url) + end, + }, function(choice) + if choice then + local tags = utils.get_all_tags() + if not tags or #tags == 0 then + utils.notify("No tags found to push", vim.log.levels.ERROR) + return + end + + vim.ui.select(tags, { + 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() + utils.notify( + string.format("Tag `%s` pushed successfully to remote `%s`.", tag_choice, choice.name), + vim.log.levels.INFO + ) + if should_refresh then + vim.schedule(function() + M.log() + end) + end + end, "Failed to push tag") + end + end) + end + end) + end + + -- Otherwise we can push directly to the only remote + if remotes and #remotes == 1 then + local tags = utils.get_all_tags() + if not tags or #tags == 0 then + utils.notify("No tags found to push", vim.log.levels.ERROR) + return + end + + vim.ui.select(tags, { + 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() + utils.notify( + string.format("Tag `%s` pushed successfully to remote `%s`.", tag_choice, remotes[1].name), + vim.log.levels.INFO + ) + if should_refresh then + vim.schedule(function() + M.log() + end) + end + end, "Failed to push tag") + end + end) + end +end + --- @param args string|string[] jj command arguments function M.j(args) if not utils.ensure_jj() then @@ -964,6 +1147,22 @@ function M.j(args) commit = function() M.commit(remaining_args_str ~= "" and remaining_args_str or nil) end, + tag = function() + if remaining_args[1] == "set" or remaining_args[1] == "s" then + -- If the user provided a revision, set the tag on that revision, otherwise ask for it in the flow of the command + if remaining_args[2] then + M.tag_set(remaining_args[2]) + else + M.tag_set(remaining_args[2]) + end + elseif remaining_args[1] == "delete" or remaining_args[1] == "d" then + if remaining_args[2] then + M.tag_delete(remaining_args[2]) + else + M.tag_delete() + end + end + end, } if handlers[subcommand] then @@ -1012,6 +1211,7 @@ function M.register_command() "annotate", "annotate_line", "commit", + "tag", } local matches = {} for _, cmd in ipairs(subcommands) do diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index 3f4bbb0..4930096 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -582,6 +582,19 @@ function M.handle_log_push_bookmark() end end +--- Handle seting a tag from `jj log` buffer for the revision under cursor +--- @param revset? string Revset to set the tag on, if not provided it will do nothing. +function M.handle_log_tag_set(revset) + if not revset or revset == "" then + revset = revset or get_revset() + if not revset or revset == "" then + return + end + end + + require("jj.cmd").tag_set(revset) +end + --- Handle opening a PR/MR from `jj log` buffer for the revision under cursor --- @param list_bookmarks? boolean If true, prompt to select from all bookmarks instead of using current revision function M.handle_log_open_pr(list_bookmarks) @@ -1032,6 +1045,11 @@ function M.log_keymaps() handler = M.handle_log_split, modes = { "n" }, }, + tag_set = { + desc = "Set a tag under the current revset", + handler = M.handle_log_tag_set, + modes = { "n" }, + }, } return cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps()) diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index e3a2323..551e261 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -157,6 +157,52 @@ function M.get_all_bookmarks() return bookmarks end +--- Get all tags in a repository +--- @return string[] List of bookmarks, or empty list if none found +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 + ) + + if not success or not bookmarks_output then + return {} + end + + -- Parse tags from template output + local tags = {} + local seen = {} + for line in bookmarks_output:gmatch("[^\n]+") do + local tag = vim.trim(line) + if tag ~= "" and not seen[tag] then + -- Skip tags containing "(deleted)" + if not tag:match("%(deleted%)") then + table.insert(tags, tag) + seen[tag] = true + end + end + end + + return tags +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) + + if not success or not output then + return false + end + + return vim.startswith(vim.trim(output), "Workspace is currently colocated with Git.") +end + --- Get all bookmarks in the repository, filters out deleted bookmarks --- @return string[] List of bookmarks, or empty list if none found function M.get_untracked_bookmarks()