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({ "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({ "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({ "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 --- Parse a jj:// virtual buffer URI. --- Returns nil, nil if the name is not a jj:// URI or has an invalid format. --- @param name string --- @return string|nil change_id --- @return string|nil path function M.parse_jj_uri(name) if not vim.startswith(name, "jj://") then return nil, nil end return name:match("^jj://([^/]+)/(.+)$") end --- Normalize a user-provided file path to a cwd-relative path for jj commands. --- --- Rules: --- - `%` resolves to the current buffer absolute file path. --- - Absolute paths are converted to cwd-relative paths. --- - Relative paths are kept relative (already cwd-relative). --- --- @param path string|nil Path provided by the user (`%`, absolute, or relative) --- @return string|nil normalized_path Cwd-relative normalized path --- @return string|nil err Error message when normalization fails function M.normalize_relative_path(path) path = vim.trim(path or "") if path == "" then return nil, "Path is empty" end if path == "%" then path = vim.api.nvim_buf_get_name(0) if path == "" then return nil, "Current buffer has no file path" end local _, jj_path = M.parse_jj_uri(path) if jj_path then return jj_path, nil end end path = vim.fs.normalize(path) local is_absolute = vim.startswith(path, "/") or path:match("^%a:/") ~= nil if is_absolute then local cwd = vim.uv.cwd() if not cwd or cwd == "" then return nil, "Could not determine current working directory" end cwd = vim.fs.normalize(cwd) local rel = M.relpath(cwd, path) if not rel then return nil, "Path is outside current working directory" end path = rel end path = path:gsub("^%./", "") if path == "" then return nil, "Path is empty after normalization" end return path, nil 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 local is_windows = vim.fn.has("win32") == 1 or vim.fn.has("win64") == 1 if is_windows then -- Avoid `cmd /c start` parsing issues with `&` and other special chars in URLs. local job_id = vim.fn.jobstart({ "rundll32.exe", "url.dll,FileProtocolHandler", url }, { detach = true }) if job_id <= 0 then -- Fallback for environments where rundll32 is unavailable. vim.fn.jobstart({ "cmd.exe", "/c", "start", "", url }, { detach = true }) end return end if vim.ui and type(vim.ui.open) == "function" then vim.ui.open(url) 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 cmd = { "jj", "bookmark", "list", "-T", 'if(!self.remote(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")', } local bookmarks_output, success = runner.execute(cmd, "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 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 cmd = { "jj", "bookmark", "list", "-T", 'if(!self.remote(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")', "--quiet", } local bookmarks_output, success = runner.execute(cmd, "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 and ?? local base_name = parts[1]:gsub("%*", ""):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 = { "jj", "log", "-r", revset, "-T", 'bookmarks.map(|b| b.name() ++ "::" ++ b.present()).join(" ")', "--no-graph", } local output, success = runner.execute(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() -- Use a custom template to output just the bookmark names, one per line -- This is more reliable than parsing the default output format local cmd = { "jj", "tag", "list", "--quiet", "--sort", "committer-date-", "-T", 'if(!self.remote(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")', } local bookmarks_output, success = runner.execute(cmd, "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( { "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 cmd = { "jj", "bookmark", "list", "-a", "-T", 'if(self.remote() && !self.tracked(), name ++ if(!self.present(), " (deleted)", "") ++ "\n")', } local bookmarks_output, success = runner.execute(cmd, "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({ "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 cmd = { "jj", "log", "--no-graph", "-r", revset, "-T", "immutable", "--quiet", } local output, success = runner.execute(cmd, "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 cmd = { "jj", "log", "--no-graph", "-r", revset, "-T", "empty", "--quiet", } local output, success = runner.execute(cmd, "Error checking if revset is empty", nil, true) if not success or not output then return false end return vim.trim(output) == "true" end --- Check if a given revset has conflicts --- @param revset string The revset to check --- @return boolean True if the revset has conflicts, false otherwise function M.is_change_conflicted(revset) local cmd = { "jj", "log", "--no-graph", "-r", revset, "-T", "conflict", "--quiet", } local output, success = runner.execute(cmd, "Error checking if revset has conflicts", 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 old_desc_cmd = { "jj", "log", "-r", revset, "--quiet", "--no-graph", "-T", 'coalesce(description, "\\n")', } local old_description_raw, success = runner.execute(old_desc_cmd, "Failed to get old description") if not old_description_raw or not success then return nil end local cmd_files = { "jj", "log", "-r", revset, "--quiet", "--no-graph", "-T", "self.diff().summary()", } local status_result, success2 = runner.execute(cmd_files, "Error getting status") if not success2 then return nil end local parser = require("jj.core.parser") 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 cmd = { "jj", "log", "--no-graph", "-r", revset, "-T", 'commit_id ++ "\\n"', "--quiet", } local output, success = runner.execute(cmd, "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 cmd = { "jj", "log", "--no-graph", "-r", "@", "-T", 'commit_id ++ "\\n"', "--quiet", } local output, success = runner.execute(cmd, "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="")`. --- - 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 = { "jj", "log", "-r", current, "--no-graph", "--quiet", "-T", templ, } local out, ok = runner.execute(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: \t\n local tmpl = [[self.remote_bookmarks().map(|b| b.remote() ++ "\t" ++ b.name() ++ "\n").join("")]] local cmd = { "jj", "log", "-r", revset, "--no-graph", "--quiet", "-T", tmpl, } local out, ok = runner.execute(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", tostring(limit), "--json", "number,title,author", "--jq", [[.[] | "#\(.number);;;\(.title);;;(@\(.author.login))"]], } -- Run the command to get the pr's local output, success = runner.execute(cmd, "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 -- Open first conflicted file, runs edit on the conflicted revision and opens the first conflicted file in a buffer. --- @param revset string The revset to check for conflicts function M.open_first_conflicted_file(revset) if not revset or revset == "" then M.notify("No revset provided to open conflicted file", vim.log.levels.ERROR) return elseif not M.is_change_conflicted(revset) then return end local repo_root = M.get_jj_root() local cmd = { "jj", "resolve", "-r", revset, "--list", } -- Resolve the first conflicted path before editing so we can still open it -- reliably when the current working directory is outside the repo root. local list_output, list_ok = runner.execute(cmd, string.format("could not list conflicted files for '%s'", revset), nil, true) local first_conflicted_path if list_ok and type(list_output) == "string" and list_output ~= "" then for _, line in ipairs(vim.split(list_output, "\n", { trimempty = true })) do local rel_path = vim.trim(line:gsub("^[-*]%s+", "")) if rel_path ~= "" then first_conflicted_path = rel_path break end end end local _, ok = runner.execute( { "jj", "edit", revset, "--ignore-immutable" }, string.format("could not edit revision '%s'", revset) ) if not ok then return end M.reload_changed_file_buffers() M.notify(string.format("Editing conflicted revision `%s`", revset), vim.log.levels.INFO) if first_conflicted_path then local first_conflicted_file = repo_root and vim.fs.joinpath(repo_root, first_conflicted_path) or first_conflicted_path local escaped = vim.fn.fnameescape(first_conflicted_file) local existing_buf = vim.fn.bufnr(first_conflicted_file) if existing_buf ~= -1 and vim.api.nvim_buf_is_loaded(existing_buf) then local modified = vim.bo[existing_buf].modified if not modified then vim.cmd("bdelete! " .. existing_buf) end end vim.cmd("edit! " .. escaped) vim.cmd("checktime " .. escaped) end end --- Extract selected lines from visual mode, and the marks for the start and end lines. --- @param bufnr integer The buffer number to get the selection from --- @return string[]|nil The selected lines, or nil if not in visual mode --- @return integer|nil The start line of the selection (1-based) --- @return integer|nil The end line of the selection (1-based) function M.get_visual_selection(bufnr) local mode = vim.fn.mode() if mode ~= "v" and mode ~= "V" and mode ~= "\22" then return nil, nil, nil end local selected_start_line = vim.fn.line("v") local selected_end_line = vim.fn.line(".") if selected_start_line > selected_end_line then selected_start_line, selected_end_line = selected_end_line, selected_start_line end local lines = vim.api.nvim_buf_get_lines(bufnr, selected_start_line - 1, selected_end_line, false) return lines, selected_start_line, selected_end_line end return M