Files
jj.nvim/lua/jj/utils.lua
T

475 lines
13 KiB
Lua

local runner = require("jj.core.runner")
--- @class jj.utils
local M = {
executable_cache = {},
dependency_cache = {},
}
-- No-op setup, but keep it for API consistency in case we need it later.
function M.setup(_) end
--- Cache for executable checks to avoid repeated system calls
--- Check if an executable exists in PATH
--- @param name string The name of the executable to check
--- @return boolean True if executable exists, false otherwise
function M.has_executable(name)
if M.executable_cache[name] ~= nil then
return M.executable_cache[name]
end
local exists = vim.fn.executable(name) == 1
M.executable_cache[name] = exists
return exists
end
--- Check if the dependency is currently installed
--- @param module string The dependency module
--- @return boolean
function M.has_dependency(module)
if M.dependency_cache[module] ~= nil then
return true
end
local exists, _ = pcall(require, module)
if not exists then
M.notify(string.format("Module %s not installed", module), vim.log.levels.ERROR)
return false
end
return true
end
--- Clear the executable cache (useful for testing or if PATH changes)
function M.clear_executable_cache()
M.executable_cache = {}
end
--- Check if jj executable exists, show error if not
--- @return boolean True if jj exists, false otherwise
function M.ensure_jj()
if not M.has_executable("jj") then
M.notify("jj command not found", vim.log.levels.ERROR)
return false
end
return true
end
--- Check if we're in a jj repository
--- @return boolean True if in jj repo, false otherwise
function M.is_jj_repo()
if not M.ensure_jj() then
return false
end
-- We require the runner here to avoid a circular dependency loop at startup
local _, success = runner.execute_command("jj status")
return success
end
--- Get jj repository root path
--- @return string|nil The repository root path, or nil if not in a repo
function M.get_jj_root()
if not M.ensure_jj() then
return nil
end
-- We require the runner here to avoid a circular dependency loop at startup
local output, success = runner.execute_command("jj root")
if success and output then
return vim.trim(output)
end
return nil
end
--- Get a list of files modified in the current jj repository.
--- @return string[] A list of modified file paths
function M.get_modified_files()
if not M.ensure_jj() then
return {}
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")
if not success or not result then
return {}
end
local files = {}
-- Split the result into lines and add each file to the table
for file in result:gmatch("[^\r\n]+") do
table.insert(files, file)
end
return files
end
---- Notify function to display messages with a title
--- @param message string The message to display
--- @param level? number The log level (default: INFO)
--- @param timeout number? The timeout duration in milliseconds (default: 3000)
function M.notify(message, level, timeout)
level = level or vim.log.levels.INFO
timeout = timeout or 3000
vim.notify(message, level, { title = "JJ", timeout = timeout })
end
--- URL encode a string for use in URLs
--- @param str string The string to encode
--- @return string The URL-encoded string
function M.url_encode(str)
return (str:gsub("([^%w%-_.~])", function(c)
return string.format("%%%02X", string.byte(c))
end))
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_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
)
if not success or not bookmarks_output then
return {}
end
-- Parse bookmarks from template output
local bookmarks = {}
local seen = {}
for line in bookmarks_output:gmatch("[^\n]+") do
local bookmark = vim.trim(line)
if bookmark ~= "" and not seen[bookmark] then
-- Skip bookmarks containing "Hint:" or "(deleted)"
if not bookmark:match("Hint:") and not bookmark:match("%(deleted%)") then
table.insert(bookmarks, bookmark)
seen[bookmark] = true
end
end
end
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()
-- 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
)
if not success or not bookmarks_output then
return {}
end
-- Parse bookmarks from template output
local bookmarks = {}
local seen = {}
for line in bookmarks_output:gmatch("[^\n]+") do
local bookmark = vim.trim(line)
if bookmark ~= "" and not seen[bookmark] then
-- Skip bookmarks containing "Hint:" or "(deleted)"
if not bookmark:match("Hint:") and not bookmark:match("%(deleted%)") then
table.insert(bookmarks, bookmark)
seen[bookmark] = true
end
end
end
return bookmarks
end
--- Get git remotes for the current jj repository
--- @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)
if not remote_success or not remote_list then
return
end
-- Parse remotes into a table
local remotes = {}
for line in remote_list:gmatch("[^\n]+") do
local name, url = line:match("^(%S+)%s+(.+)$")
if name and url then
table.insert(remotes, { name = name, url = url })
end
end
return remotes
end
--- Open a PR/MR on the remote for a given bookmark
--- @param bookmark string The bookmark to create a PR for
function M.open_pr_for_bookmark(bookmark)
-- Get all git remotes
local remotes = M.get_remotes()
if #remotes == 0 then
M.notify("No git remotes found", vim.log.levels.ERROR)
return
end
-- Helper function to open PR for a given remote URL
local function open_pr_with_url(raw_url)
-- Remove .git suffix if present
raw_url = raw_url:gsub("%.git$", "")
-- Convert SSH URL to HTTPS and detect platform
local repo_url, host
if raw_url:match("^git@") then
-- Extract host and path from git@host:path
host = raw_url:match("^git@([^:]+):")
local repo_path = raw_url:match("^git@[^:]+:(.+)$")
repo_url = "https://" .. host .. "/" .. repo_path
else
-- Extract host from https://host/path
host = raw_url:match("https?://([^/]+)")
repo_url = raw_url
end
-- Construct the appropriate PR/MR URL based on the platform
local encoded_bookmark = M.url_encode(bookmark)
local pr_url
if host:match("gitlab") then
-- GitLab merge request URL
pr_url = repo_url .. "/-/merge_requests/new?merge_request[source_branch]=" .. encoded_bookmark
elseif host:match("gitea") or host:match("forgejo") then
-- Gitea/Forgejo compare URL
pr_url = repo_url .. "/compare/" .. encoded_bookmark
else
-- Default to GitHub-style compare URL (works for GitHub, Gitea, etc.)
pr_url = repo_url .. "/compare/" .. encoded_bookmark .. "?expand=1"
end
-- Open the URL using xdg-open or the system's default browser
local open_cmd
if vim.fn.has("mac") == 1 then
open_cmd = "open"
elseif vim.fn.has("win32") == 1 then
open_cmd = "start"
else
open_cmd = "xdg-open"
end
vim.fn.jobstart({ open_cmd, pr_url }, { detach = true })
M.notify(string.format("Opening PR for bookmark `%s`", bookmark), vim.log.levels.INFO)
end
-- If only one remote, use it directly
if #remotes == 1 then
open_pr_with_url(remotes[1].url)
return
end
-- Multiple remotes: prompt user to select
vim.ui.select(remotes, {
prompt = "Select remote to open PR on: ",
format_item = function(item)
return item.name .. " (" .. item.url .. ")"
end,
}, function(choice)
if choice then
open_pr_with_url(choice.url)
end
end)
end
--- Check if a given revset represents an immutable change
--- @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
)
if not success or not output then
return false
end
return vim.trim(output) == "true"
end
--- Check if a given revset is empty
--- @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
)
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
function M.get_describe_text(revset)
if not revset or revset == "" then
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"
)
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"
)
if not success2 then
return nil
end
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")
local text = {}
for _, line in ipairs(description_lines) do
table.insert(text, line)
end
table.insert(text, "")
table.insert(text, "JJ: Change ID: " .. revset)
table.insert(text, "JJ: This commit contains the following changes:")
for _, item in ipairs(status_files) do
table.insert(text, string.format("JJ: %s %s", item.status, item.file))
end
table.insert(text, "JJ:")
table.insert(text, 'JJ: Lines starting with "JJ:" (like this one) will be removed')
return text
end
---
--- Get the commit id from a given revision
--- @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' --quiet", revset),
"Error extracting commit id",
nil,
true
)
-- Test quie
if not success or not output then
return nil
end
return vim.trim(output)
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
)
if not success or not output then
return nil
end
return vim.trim(output)
end
--- Extract the description from the describe text
--- @param lines string[] The lines of a described change
--- @return string|nil
function M.extract_description_from_describe(lines)
local final_lines = {}
for _, line in ipairs(lines) do
if not line:match("^JJ:") then
table.insert(final_lines, line)
end
end
-- Join lines and trim leading/trailing whitespace
local trimmed_description = table.concat(final_lines, "\n"):gsub("^%s+", ""):gsub("%s+$", "")
if trimmed_description == "" then
-- If nothing return nil
return
end
return trimmed_description
end
return M