mirror of
https://github.com/zoriya/jj.nvim.git
synced 2026-08-05 10:46:08 +00:00
This will raise an error, as expected, when trying to diff with a revset that has multiple commits associated
758 lines
22 KiB
Lua
758 lines
22 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
|
|
|
|
--- URL-encode a file path for use in URLs.
|
|
--- Encodes each path segment and preserves `/` separators.
|
|
--- Also normalizes Windows path separators (`\` -> `/`).
|
|
--- @param path string
|
|
--- @return string
|
|
function M.url_encode_path(path)
|
|
path = (path or ""):gsub("\\", "/")
|
|
local parts = vim.split(path, "/", { plain = true })
|
|
for i, seg in ipairs(parts) do
|
|
parts[i] = M.url_encode(seg)
|
|
end
|
|
return table.concat(parts, "/")
|
|
end
|
|
|
|
--- Check whether a path points to an existing file.
|
|
--- @param path string|nil
|
|
--- @return boolean
|
|
function M.is_file(path)
|
|
if not path or path == "" then
|
|
return false
|
|
end
|
|
local st = vim.uv.fs_stat(path)
|
|
return st ~= nil and st.type == "file"
|
|
end
|
|
|
|
--- Compute a repository-relative path.
|
|
--- Returns nil if `path` is outside of `root`.
|
|
--- @param root string
|
|
--- @param path string
|
|
--- @return string|nil
|
|
function M.relpath(root, path)
|
|
if not root or root == "" or not path or path == "" then
|
|
return nil
|
|
end
|
|
|
|
local ok_norm_root, norm_root = pcall(vim.fs.normalize, root)
|
|
local ok_norm_path, norm_path = pcall(vim.fs.normalize, path)
|
|
if not ok_norm_root or not ok_norm_path then
|
|
norm_root, norm_path = root, path
|
|
end
|
|
|
|
local ok_rel, rel = pcall(vim.fs.relpath, norm_root, norm_path)
|
|
if ok_rel and rel and rel ~= "" and not rel:match("^%.%.") then
|
|
return rel
|
|
end
|
|
|
|
local prefix = norm_root
|
|
if not prefix:match("/$") then
|
|
prefix = prefix .. "/"
|
|
end
|
|
if vim.startswith(norm_path, prefix) then
|
|
return norm_path:sub(#prefix + 1)
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
--- Normalize a git remote URL to an HTTPS repo URL.
|
|
--- Supports:
|
|
--- - `git@host:owner/repo(.git)`
|
|
--- - `ssh://git@host/owner/repo(.git)`
|
|
--- - `https://host/owner/repo(.git)`
|
|
--- @param raw_url string
|
|
--- @return string|nil repo_url HTTPS base repo URL (no trailing .git)
|
|
--- @return string|nil host Hostname extracted from the remote
|
|
function M.normalize_remote_url(raw_url)
|
|
if not raw_url or raw_url == "" then
|
|
return nil, nil
|
|
end
|
|
|
|
raw_url = raw_url:gsub("%.git$", "")
|
|
|
|
-- scp-like SSH: git@host:owner/repo
|
|
if raw_url:match("^git@") then
|
|
local host = raw_url:match("^git@([^:]+):")
|
|
local repo_path = raw_url:match("^git@[^:]+:(.+)$")
|
|
if host and repo_path then
|
|
return "https://" .. host .. "/" .. repo_path, host
|
|
end
|
|
end
|
|
|
|
-- ssh://git@host/owner/repo
|
|
if raw_url:match("^ssh://") then
|
|
local host = raw_url:match("^ssh://[^@]+@([^/]+)/") or raw_url:match("^ssh://([^/]+)/")
|
|
local rest = raw_url:gsub("^ssh://[^@]+@", ""):gsub("^ssh://", "")
|
|
local repo_path = rest:match("^[^/]+/(.+)$")
|
|
if host and repo_path then
|
|
repo_path = repo_path:gsub("%.git$", "")
|
|
return "https://" .. host .. "/" .. repo_path, host
|
|
end
|
|
end
|
|
|
|
-- HTTPS
|
|
local host = raw_url:match("^https?://([^/]+)")
|
|
if host then
|
|
return raw_url, host
|
|
end
|
|
|
|
return nil, nil
|
|
end
|
|
|
|
--- Open a URL using the system default handler.
|
|
--- Prefers `vim.ui.open()` when available.
|
|
--- @param url string
|
|
function M.open_url(url)
|
|
if not url or url == "" then
|
|
return
|
|
end
|
|
|
|
if vim.ui and type(vim.ui.open) == "function" then
|
|
vim.ui.open(url)
|
|
return
|
|
end
|
|
|
|
if vim.fn.has("win32") == 1 then
|
|
-- `start` is a cmd.exe builtin
|
|
vim.fn.jobstart({ "cmd.exe", "/c", "start", "", url }, { detach = true })
|
|
return
|
|
end
|
|
|
|
local open_cmd = vim.fn.has("mac") == 1 and "open" or "xdg-open"
|
|
vim.fn.jobstart({ open_cmd, url }, { detach = true })
|
|
end
|
|
|
|
--- Best-effort unquoting of a jj `RefSymbol` rendering.
|
|
---
|
|
--- `RefSymbol` values are displayed as revset symbols, which may be quoted and
|
|
--- escaped if necessary. For browser URLs, we generally want the raw name.
|
|
---
|
|
--- This handles the common cases of surrounding single/double quotes.
|
|
--- @param s string|nil
|
|
--- @return string
|
|
function M.unquote_refsymbol(s)
|
|
s = vim.trim(s or "")
|
|
if s == "" then
|
|
return ""
|
|
end
|
|
local first = s:sub(1, 1)
|
|
local last = s:sub(-1)
|
|
if (first == '"' and last == '"') or (first == "'" and last == "'") then
|
|
s = s:sub(2, -2)
|
|
-- Minimal unescaping (covers typical `\"` and `\\` sequences)
|
|
s = s:gsub("\\\\", "\\"):gsub('\\"', '"'):gsub("\\'", "'")
|
|
end
|
|
return s
|
|
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 not remotes or #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)
|
|
local repo_url, host = M.normalize_remote_url(raw_url)
|
|
if not repo_url or not host then
|
|
M.notify("Unsupported remote URL: " .. (raw_url or ""), vim.log.levels.ERROR)
|
|
return
|
|
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
|
|
|
|
M.open_url(pr_url)
|
|
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. Returns nil and notifies an error if multiple commit_ids are found for a single revset
|
|
--- @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 ++ "\n"' --quiet]], revset),
|
|
"Error extracting commit id",
|
|
nil,
|
|
true
|
|
)
|
|
|
|
if not success or not output then
|
|
return nil
|
|
end
|
|
|
|
local id = vim.split(output, "\n", { trimempty = true })
|
|
if #id > 1 then
|
|
M.notify(
|
|
string.format(
|
|
"A unique `commit_id` for revision `%s` was expected, but it has multiple ones.\nThis is not currently supported.",
|
|
revset
|
|
),
|
|
vim.log.levels.ERROR,
|
|
5000
|
|
)
|
|
return
|
|
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
|
|
|
|
--- Return a commit id that is expected to exist on a given remote.
|
|
---
|
|
--- This is a best-effort heuristic used for browser URLs: if a commit isn't
|
|
--- reachable from the remote's bookmarks, most web UIs will 404.
|
|
---
|
|
--- Strategy:
|
|
--- - Starting from `start_revset`, walk back first-parent (`revset-`) up to
|
|
--- `max_walkback` times until the commit is contained in
|
|
--- `::remote_bookmarks(remote="<remote_name>")`.
|
|
--- - If still not found, return the commit id of `trunk()`.
|
|
---
|
|
--- @param start_revset string Starting point revset (e.g. "@")
|
|
--- @param remote_name string Remote name (e.g. "origin")
|
|
--- @param max_walkback? integer Maximum number of parents to traverse (default: 20)
|
|
--- @return string|nil commit_id
|
|
function M.get_pushed_commit_id(start_revset, remote_name, max_walkback)
|
|
max_walkback = max_walkback or 20
|
|
start_revset = (start_revset and start_revset ~= "") and start_revset or "@"
|
|
remote_name = (remote_name and remote_name ~= "") and remote_name or "origin"
|
|
|
|
-- Build a revset expression string we can embed in a template StringLiteral.
|
|
local remote_literal = remote_name:gsub("\\", "\\\\"):gsub('"', '\\"')
|
|
local templ = [[if(self.contained_in("::remote_bookmarks(remote='%s')"), commit_id)]]
|
|
templ = string.format(templ, remote_literal)
|
|
|
|
-- If `start_revset` is already a commit id (or change id), we still let jj
|
|
-- resolve it; otherwise, treat it as a revset expression.
|
|
local current = start_revset
|
|
for _ = 0, max_walkback do
|
|
-- Use a single-quoted StringLiteral for the revset, so we can keep the
|
|
-- remote name quoted inside the revset expression.
|
|
local cmd = string.format(
|
|
"jj log -r %s --no-graph --quiet -T %s",
|
|
vim.fn.shellescape(current),
|
|
vim.fn.shellescape(templ)
|
|
)
|
|
|
|
local out, ok = runner.execute_command(cmd, "Error determining remote-reachable commit", nil, true)
|
|
if ok and out and not out:match("^%s*$") then
|
|
return vim.trim(out)
|
|
end
|
|
current = current .. "-"
|
|
end
|
|
|
|
return M.get_commit_id("trunk()")
|
|
end
|
|
|
|
--- Return a remote bookmark name pointing at `revset` on `remote_name`.
|
|
---
|
|
--- If there are 0 or multiple matching bookmarks and none of those are neither `main` nor `master`, returns nil.
|
|
--- This avoids choosing an arbitrary name when multiple bookmarks point to the
|
|
--- same commit.
|
|
---
|
|
--- @param revset string
|
|
--- @param remote_name string
|
|
--- @return string|nil bookmark_name
|
|
function M.get_unique_remote_bookmark_name(revset, remote_name)
|
|
revset = (revset and revset ~= "") and revset or "@"
|
|
remote_name = (remote_name and remote_name ~= "") and remote_name or "origin"
|
|
|
|
-- Render remote bookmarks as tab-separated pairs: <remote>\t<name>\n
|
|
local tmpl = [[self.remote_bookmarks().map(|b| b.remote() ++ "\t" ++ b.name() ++ "\n").join("")]]
|
|
local cmd =
|
|
string.format("jj log -r %s --no-graph --quiet -T %s", vim.fn.shellescape(revset), vim.fn.shellescape(tmpl))
|
|
local out, ok = runner.execute_command(cmd, "Error getting remote bookmarks", nil, true)
|
|
if not ok or not out or out:match("^%s*$") then
|
|
return nil
|
|
end
|
|
|
|
local matches = {}
|
|
for line in out:gmatch("[^\r\n]+") do
|
|
local remote_sym, name_sym = line:match("^(.-)\t(.*)$")
|
|
local r = M.unquote_refsymbol(remote_sym)
|
|
local n = M.unquote_refsymbol(name_sym)
|
|
if r == remote_name and n ~= "" then
|
|
table.insert(matches, n)
|
|
end
|
|
end
|
|
|
|
if #matches == 1 then
|
|
return matches[1]
|
|
elseif #matches > 1 then
|
|
for _, name in ipairs(matches) do
|
|
-- Unless it's main or master which takes precedence
|
|
if name == "main" or name == "master" then
|
|
return name
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
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
|
|
|
|
--- Reload loaded file buffers after working-copy changes.
|
|
function M.reload_changed_file_buffers()
|
|
vim.cmd.checktime()
|
|
end
|
|
|
|
--- Using the gh cli lists all open prs
|
|
--- @param opts {limit: integer|nil}
|
|
--- @return {number: integer, title: string, author: string}[]|nil
|
|
function M.list_github_prs(opts)
|
|
if not M.has_executable("gh") then
|
|
M.notify("Missing `gh` executable to list prs", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
-- Set the default limit
|
|
local limit = 100
|
|
|
|
if opts and opts.limit then
|
|
limit = opts.limit
|
|
end
|
|
|
|
-- Start with a hardcoded limit of 100
|
|
local cmd =
|
|
[[gh pr list -L %s --json number,title,author --jq '.[] | "#\(.number);;;\(.title);;;(@\(.author.login))"']]
|
|
cmd = string.format(cmd, limit)
|
|
|
|
-- Run the command to get the pr's
|
|
local output, success = runner.execute_command_sync(cmd, nil, "Failed to get prs")
|
|
if not success or not output then
|
|
return
|
|
end
|
|
|
|
local open_prs = {}
|
|
-- Split each line
|
|
local lines = vim.split(output, "\n", { trimempty = true })
|
|
for _, line in ipairs(lines) do
|
|
local parts = vim.split(line, ";;;", { trimempty = true })
|
|
if #parts == 3 then
|
|
local number = tonumber(parts[1]:match("#(%d+)"))
|
|
local title = parts[2]
|
|
local author = parts[3]
|
|
if number and title and author then
|
|
table.insert(open_prs, { number = number, title = title, author = author })
|
|
end
|
|
else
|
|
M.notify("Unexpected PR list format: " .. line, vim.log.levels.WARN)
|
|
end
|
|
end
|
|
|
|
return open_prs
|
|
end
|
|
|
|
return M
|