diff --git a/README.md b/README.md index 73242fe..fb6d3d2 100644 --- a/README.md +++ b/README.md @@ -380,11 +380,36 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing ```sh :Jdiff " Vertical diff against @- (parent) -:Jdiff @-2 " Vertical diff against specific revision +:Jdiff @-- " Vertical diff against specific revision :Jvdiff main " Vertical diff against main bookmark :Jhdiff trunk() " Horizontal diff against trunk ``` +### File Commands + +Fugitive-inspired commands for viewing and editing files at specific jj revisions: + +```sh +:Jread " Read current file at @ into current buffer (undoable) +:Jread main:src/init.lua " Read a file at a revision into the current buffer +:Jedit " Open current file at @ in the current window +:Jedit @--:% " Open current file as it was at @-- +:Jtabedit main:README.md " Open a file at a revision in a new tab +:Jsplit main:README.md " Open a file at a revision in a horizontal split +:Jvsplit trunk():lua/jj/file.lua " Open a file at a revision in a vertical split +``` + +Target format is `:`: + +- `` is any valid jj revset (`@`, `main`, `@--`, `trunk()`, etc.) +- `` can be `%` (current buffer file), repo-relative, or absolute +- If no argument is passed, defaults to `@:%` + +The open commands (`:Jedit`, `:Jtabedit`, `:Jsplit`, `:Jvsplit`) create a `jj:///` +buffer. The revision expression is resolved to a stable change ID so the buffer name is unaffected +by bookmark movement. Mutable revisions are editable — `:w` writes the buffer back into the +revision via `jj diffedit`. Immutable revisions show an error on write. + ## Default Config ```lua @@ -883,6 +908,28 @@ diff.diff_current({ backend = "my-backend", rev = "main" }) All four backend functions are optional—missing ones fall back to the `native` implementation. +### File Module + +The file module provides file-at-revision workflows similar to Fugitive's `Gread`/`Gedit`. + +```lua +local file = require("jj.file") + +-- Read a file from a revision into the current buffer (undoable) +file.read_target({ rev = "main", path = "lua/jj/init.lua" }) +file.read_target({ rev = "@--", path = "%" }) -- current file at @-- + +-- Open buffers at a given revision +file.open_target({ rev = "main", path = "README.md" }) -- current window (default) +file.open_target({ rev = "@", path = "%", split = "horizontal" }) -- split +file.open_target({ rev = "trunk()", path = "%", split = "vertical" }) -- vsplit +file.open_target({ rev = "@--", path = "%", split = "tab" }) -- new tab +``` + +`split` accepts: `"current"` (default), `"tab"`, `"horizontal"`, or `"vertical"`. + +Annotate also works from `jj://` virtual buffers and resolves the revision and path automatically. + ### Annotations View file blame and line history using the annotate module. Can be invoked via command or Lua API. diff --git a/lua/jj/annotate.lua b/lua/jj/annotate.lua index afed814..29d1820 100644 --- a/lua/jj/annotate.lua +++ b/lua/jj/annotate.lua @@ -10,6 +10,48 @@ local parser = require("jj.core.parser") -- Track the last annotation tooltip buffer local last_tooltip_buf = nil +--- Resolve current buffer into an annotate target. +--- Supports normal file buffers and jj:// virtual buffers. +--- @return string|nil path Repository-relative or absolute path for `jj file annotate` +--- @return string|nil rev Optional revision for virtual buffers +--- @return string|nil err Error message when target cannot be resolved +local function get_annotate_target() + local filename = vim.api.nvim_buf_get_name(0) + if filename == "" then + return nil, nil, "Could not extract file from buffer" + end + + local change_id, path = utils.parse_jj_uri(filename) + if change_id then + return path, change_id, nil + end + if vim.startswith(filename, "jj://") then + return nil, nil, "Invalid jj:// buffer name" + end + + local normalized, err = utils.normalize_repo_path(filename) + if not normalized then + return nil, nil, err or "Could not normalize file path" + end + + return normalized, nil, nil +end + +--- Build `jj file annotate` command with optional revision. +--- @param path string +--- @param template string +--- @param rev? string +--- @return string +local function build_annotate_cmd(path, template, rev) + local rev_flag = rev and rev ~= "" and string.format("-r %s ", vim.fn.shellescape(rev)) or "" + return string.format( + "jj file annotate %s%s -T %s", + rev_flag, + vim.fn.shellescape(path), + vim.fn.shellescape(template) + ) +end + --- Sets the highlights for the blame bufer --- @param buf integer --- @param annotations string[] @@ -136,11 +178,11 @@ local function handle_enter() return end - -- Create a new buffer with the filetype gitdiff and the output + -- Create a new buffer with the filetype diff and the output local buf, win = buffer.create({ name = "jj-diff://" .. vim.fn.fnamemodify(filename, ":t") .. "//" .. parts.rev.value, split = "tab", - filetype = "gitdiff", + filetype = "diff", bufhidden = "wipe", }) @@ -172,16 +214,14 @@ function M.file() local template = 'join(" | ", commit.change_id().short(6), commit.author().name(), commit.author().timestamp().format("%Y-%m-%d %H:%M:%S %Z")) ++ "\n"' - local filename = vim.api.nvim_buf_get_name(0) - if filename == "" then - utils.notify("Could extract file from buffer", vim.log.levels.ERROR) + local filename, revision, err = get_annotate_target() + if not filename then + utils.notify(err or "Could not extract file from buffer", vim.log.levels.ERROR) return end - local raw_output, success = runner.execute_command( - string.format("jj file annotate %s -T '%s'", filename, template), - "Failed to annotate file" - ) + local raw_output, success = + runner.execute_command(build_annotate_cmd(filename, template, revision), "Failed to annotate file") if not success or not raw_output then return end @@ -307,17 +347,15 @@ function M.line() local template = 'join(" | ", commit.change_id().short(6), commit.author().name(), commit.author().timestamp().format("%Y-%m-%d %H:%M:%S %Z")) ++ "\n"' - local filename = vim.api.nvim_buf_get_name(0) - if filename == "" then - utils.notify("Could not extract file from buffer", vim.log.levels.ERROR) + local filename, revision, err = get_annotate_target() + if not filename then + utils.notify(err or "Could not extract file from buffer", vim.log.levels.ERROR) return end local line_num = vim.fn.line(".") - local raw_output, success = runner.execute_command( - string.format("jj file annotate %s -T '%s'", filename, template), - "Failed to annotate line" - ) + local raw_output, success = + runner.execute_command(build_annotate_cmd(filename, template, revision), "Failed to annotate line") if not success or not raw_output then return end diff --git a/lua/jj/core/parser.lua b/lua/jj/core/parser.lua index eb8e3d0..b32ca0e 100644 --- a/lua/jj/core/parser.lua +++ b/lua/jj/core/parser.lua @@ -245,4 +245,32 @@ function M.parse_diff_range(range_str) return nil end +--- Parse a `:` argument as used by file commands. +--- With no colon, the whole input is treated as a revision with no file. +--- A trailing colon (`:`) is accepted and treated as no file path. +--- @param input string +--- @return {rev: string|nil, path: string|nil}|nil +function M.parse_file_module_input(input) + if not input then + return nil + end + + input = vim.trim(input) + if input == "" then + return nil + end + + local rev, file = input:match("^([^:]+):(.*)$") + if rev then + if file == "" then + return { rev = rev, path = nil } + end + return { rev = rev, path = file } + end + if input:find(":", 1, true) then + return nil + end + return { rev = input, path = nil } +end + return M diff --git a/lua/jj/core/runner.lua b/lua/jj/core/runner.lua index 972f5da..52ffde9 100644 --- a/lua/jj/core/runner.lua +++ b/lua/jj/core/runner.lua @@ -9,23 +9,23 @@ local M = {} --- @return string|nil output The command output, or nil if failed --- @return boolean success Whether the command succeeded function M.execute_command(cmd, error_prefix, input, silent) - local output = vim.fn.system({ "sh", "-c", cmd }, input) + local stderr_file = vim.fn.tempname() + local output = vim.fn.system({ "sh", "-c", string.format("(%s) 2>%s", cmd, vim.fn.shellescape(stderr_file)) }, input) local success = vim.v.shell_error == 0 if not success then - local error_message - if error_prefix then - error_message = string.format("%s: %s", error_prefix, output) - else - error_message = output - end + local stderr_lines = vim.fn.readfile(stderr_file) + vim.fn.delete(stderr_file) + local error_output = table.concat(stderr_lines, "\n") + local msg = error_output ~= "" and error_output or output + local error_message = error_prefix and string.format("%s: %s", error_prefix, msg) or msg if not silent then vim.notify(error_message, vim.log.levels.ERROR, { title = "JJ" }) end - return nil, false end + vim.fn.delete(stderr_file) return output, success end @@ -53,41 +53,33 @@ end --- @param silent boolean|nil Optional to silent the notification --- @param on_error function|nil Callback on error, receives ouptut as the parameter function M.execute_command_async(cmd, on_success, error_prefix, input, silent, on_error) - local output_lines = {} + local stdout_lines = {} + local stderr_lines = {} local job_id = vim.fn.jobstart({ "sh", "-c", cmd }, { + stdout_buffered = true, + stderr_buffered = true, on_stdout = function(_, data) - for _, line in ipairs(data) do - if line ~= "" then - table.insert(output_lines, line) - end - end + vim.list_extend(stdout_lines, data) end, on_stderr = function(_, data) - for _, line in ipairs(data) do - if line ~= "" then - table.insert(output_lines, line) - end - end + vim.list_extend(stderr_lines, data) end, on_exit = function(_, exit_code) - local output = table.concat(output_lines, "\n") + local output = table.concat(stdout_lines, "\n") if exit_code == 0 then if on_success then on_success(output) end else - local error_message - if error_prefix then - error_message = string.format("%s: %s", error_prefix, output) - else - error_message = output - end + local error_output = table.concat(stderr_lines, "\n") + local msg = error_output ~= "" and error_output or output + local error_message = error_prefix and string.format("%s: %s", error_prefix, msg) or msg if not silent then vim.notify(error_message, vim.log.levels.ERROR, { title = "JJ" }) end if on_error then - on_error(output) + on_error(msg) end end end, diff --git a/lua/jj/diff/native.lua b/lua/jj/diff/native.lua index 0958ce6..bb7c4d2 100644 --- a/lua/jj/diff/native.lua +++ b/lua/jj/diff/native.lua @@ -1,46 +1,60 @@ local utils = require("jj.utils") local buffer = require("jj.core.buffer") +local runner = require("jj.core.runner") local diff = require("jj.diff") +local file = require("jj.file") ---- Get the content of a file at a specific revision +--- Open a writable buffer for a specific revision of a file. --- @param rev string The revision ---- @param path string The file path ---- @return table lines The file content -local function get_file_content(rev, path) - local cmd = string.format("jj file show -r %s %s", vim.fn.shellescape(rev), vim.fn.shellescape(path)) - local content = vim.fn.system(cmd) - local success = vim.v.shell_error == 0 - if success then - return vim.split(content, "\n", { trimempty = true }) - else - return {} - end -end - ---- Open a read-only buffer for a specific revision of a file ---- @param rev string The revision ---- @param path string The file path +--- @param path string The file path (absolute or repo-relative) local function open_revision(rev, path) - local lines = get_file_content(rev, path) + local raw_ids, ok = runner.execute_command( + string.format([[jj log --no-graph -r %s -T 'change_id ++ "\n"' --quiet]], vim.fn.shellescape(rev)), + "jj: failed to resolve revision" + ) + if not ok then return end + local ids = vim.split(vim.trim(raw_ids), "\n", { trimempty = true }) + if #ids ~= 1 then + utils.notify(string.format("Revision '%s' is ambiguous", rev), vim.log.levels.ERROR) + return + end + local change_id = ids[1] + + local rel_path, err = utils.normalize_repo_path(path) + if not rel_path then + utils.notify(err or "Could not resolve path", vim.log.levels.ERROR) + return + end + + local lines, had_eol = file.get_file_content(change_id, rel_path) local buf = vim.api.nvim_create_buf(false, true) - local buf_name = string.format("jj://%s/%s", rev, path) + local buf_name = string.format("jj://%s/%s", change_id, rel_path) vim.api.nvim_buf_set_name(buf, buf_name) vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].eol = had_eol - local ft = vim.filetype.match({ filename = path }) + local ft = vim.filetype.match({ filename = rel_path }) if ft then vim.bo[buf].filetype = ft end - vim.bo[buf].buftype = "nofile" + vim.bo[buf].buftype = "acwrite" vim.bo[buf].bufhidden = "wipe" - vim.bo[buf].readonly = true + vim.bo[buf].readonly = false vim.bo[buf].swapfile = false vim.bo[buf].modifiable = true + vim.api.nvim_create_autocmd("BufWriteCmd", { + buffer = buf, + callback = function() + file.write_revision_file(buf, change_id, rel_path) + end, + }) + + vim.bo[buf].modified = false vim.api.nvim_win_set_buf(0, buf) end @@ -61,8 +75,10 @@ diff.register_backend("native", { local prev_buf = vim.api.nvim_get_current_buf() local prev_cur_pos = buffer.get_cursor(prev_buf) or { 1, 0 } - local rev = opts.rev or "@-" - local path = opts.path or vim.api.nvim_buf_get_name(0) + local buf_name = vim.api.nvim_buf_get_name(0) + local change_id, jj_path = utils.parse_jj_uri(buf_name) + local rev = opts.rev or (change_id and (change_id .. "-")) or "@-" + local path = opts.path or jj_path or buf_name local layout = opts.layout or "vertical" local split_fun = layout == "horizontal" and vim.cmd.split or vim.cmd.vsplit diff --git a/lua/jj/file.lua b/lua/jj/file.lua new file mode 100644 index 0000000..8c6541b --- /dev/null +++ b/lua/jj/file.lua @@ -0,0 +1,270 @@ +--- @class jj.file +local M = {} + +local runner = require("jj.core.runner") +local buffer = require("jj.core.buffer") +local utils = require("jj.utils") +local parser = require("jj.core.parser") + +--- @class jj.file.read_target_opts +--- @field rev? string Revision to read the file from +--- @field path? string Path to the file (`%`, absolute, or repository-relative) + +--- @class jj.file.open_target_opts +--- @field rev? string Revision to open the file from +--- @field path? string Path to the file (`%`, absolute, or repository-relative) +--- @field split? "horizontal"|"vertical"|"tab"|"current" Open in split direction (default: "current") + +--- Fetch file content from jj synchronously. +--- Returns lines with blank lines preserved; trailing empty line removed. +--- @param rev string The revision (change ID or other revset) +--- @param path string Repository-relative path +--- @return string[] lines +--- @return boolean had_eol Whether the content had a trailing newline +local function get_file_content(rev, path) + local content, ok = runner.execute_command( + string.format("jj file show -r %s %s", vim.fn.shellescape(rev), vim.fn.shellescape(path)), + nil, + nil, + true + ) + if not ok or not content then + return {}, false + end + local lines = vim.split(content, "\n", { plain = true, trimempty = false }) + local had_eol = #lines > 0 and lines[#lines] == "" + if had_eol then + table.remove(lines, #lines) + end + return lines, had_eol +end +M.get_file_content = get_file_content + +--- Reads a target file revision into the current buffer (undoable). +--- @param opts? jj.file.read_target_opts +function M.read_target(opts) + local revision = opts and opts.rev or "@" + local raw_path = opts and opts.path or "%" + local path, normalize_err = utils.normalize_repo_path(raw_path) + if not path then + utils.notify(normalize_err or "Could not normalize path", vim.log.levels.ERROR) + return + end + + local buf = vim.api.nvim_get_current_buf() + local cmd = string.format("jj file show -r %s %s", vim.fn.shellescape(revision), vim.fn.shellescape(path)) + runner.execute_command_async(cmd, function(out) + local lines = vim.split(out, "\n", { plain = true, trimempty = false }) + if #lines > 0 and lines[#lines] == "" then + table.remove(lines, #lines) + end + if vim.bo[buf].modifiable == false then + utils.notify("Current buffer is not modifiable", vim.log.levels.ERROR) + return + end + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modified = true + end, string.format("Could not read `%s` from `%s`", path, revision)) +end + +--- Write buffer content back into a jj revision, bypassing the working copy. +--- @param buf integer +--- @param change_id string +--- @param rel_path string Repository-relative path of the file +--- @param force boolean Whether to bypass the immutability check (`:w!`) +local function write_revision_file(buf, change_id, rel_path, force) + if utils.is_change_immutable(change_id) then + if not force then + utils.notify("Cannot write to immutable revision: " .. change_id, vim.log.levels.ERROR) + return + end + end + + local new_content = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") + if vim.bo[buf].eol then + new_content = new_content .. "\n" + end + + local tmp = vim.fn.tempname() + local cf = io.open(tmp, "w") + if not cf then + utils.notify("Failed to create temp file", vim.log.levels.ERROR) + return + end + cf:write(new_content) + cf:close() + + -- Configure `cp` as the diffedit tool inline via --config. + -- jj expands $right to the directory it populates with 's content, + -- so the parent directory for rel_path already exists there. + local prog_config = 'merge-tools.jj-nvim-write.program="cp"' + -- json_encode produces valid TOML inline arrays. + local args_config = "merge-tools.jj-nvim-write.edit-args=" + .. vim.fn.json_encode({ tmp, "$right/" .. rel_path }) + + local _, ok = runner.execute_command( + string.format( + "jj diffedit --from 'root()' --to %s --config %s --config %s --tool jj-nvim-write -- %s", + vim.fn.shellescape(change_id), + vim.fn.shellescape(prog_config), + vim.fn.shellescape(args_config), + vim.fn.shellescape(rel_path) + ), + "jj: failed to edit revision" + ) + + os.remove(tmp) + + if not ok then return end + vim.bo[buf].modified = false + utils.notify(string.format("Written to revision %s", change_id)) +end +M.write_revision_file = write_revision_file + +--- Opens a target file revision in a new buffer. +--- @param opts jj.file.open_target_opts +function M.open_target(opts) + local revision = opts.rev or "@" + local raw_path = opts.path or "%" + local path, normalize_err = utils.normalize_repo_path(raw_path) + if not path then + utils.notify(normalize_err or "Could not normalize path", vim.log.levels.ERROR) + return + end + + local raw_ids, ok = runner.execute_command( + string.format([[jj log --no-graph -r %s -T 'change_id ++ "\n"' --quiet]], vim.fn.shellescape(revision)), + "jj: failed to resolve revision", + nil, + true + ) + if not ok then return end + local ids = vim.split(vim.trim(raw_ids), "\n", { trimempty = true }) + if #ids ~= 1 then + utils.notify(string.format("Revision '%s' is ambiguous", revision), vim.log.levels.ERROR) + return + end + local change_id = ids[1] + + local lines, had_eol = get_file_content(change_id, path) + local ft = vim.filetype.match({ filename = path }) + + local buf, _ = buffer.create({ + name = string.format("jj://%s/%s", change_id, path), + split = opts.split or "current", + modifiable = true, + buftype = "acwrite", + bufhidden = "wipe", + filetype = ft, + }) + vim.bo[buf].buflisted = true + vim.bo[buf].swapfile = false + local ul = vim.bo[buf].undolevels + vim.bo[buf].undolevels = -1 + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].undolevels = ul + vim.bo[buf].eol = had_eol + + vim.api.nvim_create_autocmd("BufWriteCmd", { + buffer = buf, + callback = function() + write_revision_file(buf, change_id, path, vim.v.cmdbang == 1) + end, + }) + + vim.bo[buf].modified = false + vim.bo[buf].modifiable = not utils.is_change_immutable(change_id) +end + +--- Complete `:` arguments for file commands. +--- @param arglead string +--- @return string[] +local function complete_target(arglead) + local rev, file_prefix = arglead:match("^([^:]+):(.*)$") + if not rev then + return {} + end + + local out, ok = runner.execute_command( + string.format("jj file list -r %s", vim.fn.shellescape(rev)), + nil, + nil, + true + ) + if not ok or not out then + return {} + end + + local items = {} + local seen = {} + for line in out:gmatch("[^\r\n]+") do + local file = vim.trim(line) + if file ~= "" and vim.startswith(file, file_prefix) then + local candidate = rev .. ":" .. file + if not seen[candidate] then + table.insert(items, candidate) + seen[candidate] = true + end + end + end + return items +end + +function M.register_command() + -- Allow :e on jj:// buffers to reload their content. + vim.api.nvim_create_autocmd("BufReadCmd", { + pattern = "jj://*", + nested = true, + callback = function() + local name = vim.api.nvim_buf_get_name(0) + local change_id, path = utils.parse_jj_uri(name) + if not change_id then return end + local lines, had_eol = get_file_content(change_id, path) + local buf = vim.api.nvim_get_current_buf() + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].eol = had_eol + vim.bo[buf].modified = false + vim.bo[buf].modifiable = not utils.is_change_immutable(change_id) + vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf }) + end, + }) + + vim.api.nvim_create_user_command("Jread", function(opts) + local parsed = parser.parse_file_module_input(opts.args) + M.read_target({ + rev = parsed and parsed.rev, + path = parsed and parsed.path, + }) + end, { + desc = "Read a jj file revision into the current buffer", + nargs = "?", + complete = function(arglead, _, _) + return complete_target(arglead) + end, + }) + + local function create_open_command(name, split, desc) + vim.api.nvim_create_user_command(name, function(opts) + local parsed = parser.parse_file_module_input(opts.args) + M.open_target({ + rev = parsed and parsed.rev, + path = parsed and parsed.path, + split = split, + }) + end, { + desc = desc, + nargs = "?", + complete = function(arglead, _, _) + return complete_target(arglead) + end, + }) + end + + create_open_command("Jedit", "current", "Open a jj file revision in the current window") + create_open_command("Jtabedit", "tab", "Open a jj file revision in a new tab") + create_open_command("Jsplit", "horizontal", "Open a jj file revision in a horizontal split") + create_open_command("Jvsplit", "vertical", "Open a jj file revision in a vertical split") +end + +return M diff --git a/lua/jj/init.lua b/lua/jj/init.lua index bbb6f01..f780354 100644 --- a/lua/jj/init.lua +++ b/lua/jj/init.lua @@ -5,6 +5,7 @@ local editor = require("jj.ui.editor") local terminal = require("jj.ui.terminal") local diff = require("jj.diff") local browse = require("jj.browse") +local file = require("jj.file") --- Jujutsu plugin configuration --- @class jj.Config @@ -61,6 +62,7 @@ function M.setup(opts) cmd.register_command() browse.register_command() diff.register_command() + file.register_command() end return M diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index 7137a17..091bc58 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -149,6 +149,72 @@ function M.is_file(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 repository-relative path for jj commands. +--- +--- Rules: +--- - `%` resolves to the current buffer absolute file path. +--- - Absolute paths are converted to repository-relative paths. +--- - Relative paths are kept relative. +--- +--- @param path string|nil Path provided by the user (`%`, absolute, or relative) +--- @param root? string Repository root (defaults to current jj repo root) +--- @return string|nil normalized_path Repository-relative normalized path +--- @return string|nil err Error message when normalization fails +function M.normalize_repo_path(path, root) + 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) + + root = root or M.get_jj_root() + if not root or root == "" then + return nil, "Not in a jj repository" + end + + root = vim.fs.normalize(root) + + local is_absolute = vim.startswith(path, "/") or path:match("^%a:/") ~= nil + if is_absolute then + local rel = M.relpath(root, path) + if not rel then + return nil, "Path is outside repository root" + 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