From aad53fc4db3d53a7d72ceed9d5c961c74286a2f7 Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Tue, 3 Nov 2026 11:24:50 -0500 Subject: [PATCH 1/4] Add external merge and diff tool mode bin/diffbandit gains repository-free subcommands so it can serve as jj's merge/diff editor and as a git mergetool/difftool: diffbandit merge BASE LEFT RIGHT OUTPUT [--path P] diffbandit edit LEFT RIGHT diffbandit diff LEFT RIGHT The merge host now takes a pluggable host (context, result path, resolve, after_resolve); the git host keeps the index-stage/`git add` behaviour and Merge.start_files uses a file host that writes OUTPUT on :w. Quitting a merge without resolving exits non-zero so the conflict is kept. Folder diffs get an editable-right mode for diff editors (right files are edited in place, missing ones are created on write, rows rescan on return) plus hide patterns for jj's JJ-INSTRUCTIONS file. --- README.md | 26 +++++ bin/diffbandit | 19 +++- doc/diffbandit.txt | 10 ++ lua/diffbandit/folder/init.lua | 17 ++- lua/diffbandit/init.lua | 29 +++++- lua/diffbandit/launcher.lua | 113 ++++++++++++++++++++ lua/diffbandit/merge/init.lua | 184 ++++++++++++++++++++++++++------- tests/specs/25_launcher.lua | 95 +++++++++++++++++ 8 files changed, 448 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 71ee3ed..6dbf234 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,31 @@ list that command among its load triggers. Set `DIFFBANDIT_NVIM_APPNAME` to use a dedicated config (`NVIM_APPNAME`) or `DIFFBANDIT_NVIM` to pick the nvim executable. +### External merge and diff tool + +The same script can be called with file or folder paths, outside any +repository. This is how jj and git use it as an external tool: + +```sh +diffbandit merge BASE LEFT RIGHT OUTPUT [--path P] # 3-way merge; :w resolves +diffbandit edit LEFT RIGHT # editable folder/file diff (jj split, squash -i) +diffbandit diff LEFT RIGHT # read-only folder/file diff +``` + +A branch or path named `merge`, `edit`, or `diff` needs a leading `--` +(`diffbandit -- diff`). `merge` writes `OUTPUT` when you `:w` the result. Quitting without resolving +exits non-zero, so the conflict is kept. In `edit`, you edit the right side in +place, through hunk apply actions or by hand, and `:w` saves each file. `:cq` +aborts the operation. + +Git: + +```sh +git config --global mergetool.diffbandit.cmd 'diffbandit merge "$BASE" "$LOCAL" "$REMOTE" "$MERGED" --path "$MERGED"' +git config --global mergetool.diffbandit.trustExitCode true +git config --global difftool.diffbandit.cmd 'diffbandit diff "$LOCAL" "$REMOTE"' +``` + ## Default Keys Inside a diff view: @@ -649,6 +674,7 @@ diffbandit.git_compare("main", "feature") diffbandit.git_compare_branches({}) diffbandit.git_checkout("feature") diffbandit.merge("path/to/conflicted-file") +diffbandit.merge_files({ base = "B", left = "L", right = "R", output = "O" }) -- external 3-way merge diffbandit.commit_panel({}) diffbandit.git_panel({}) -- working-tree changes, panel open on the first file diffbandit.launch({ "main...HEAD" }) -- what bin/diffbandit runs (git diff-style args) diff --git a/bin/diffbandit b/bin/diffbandit index 10ad000..80021aa 100755 --- a/bin/diffbandit +++ b/bin/diffbandit @@ -16,6 +16,14 @@ usage: diffbandit [--cached] [--no-untracked] [ [] | .. | ... ... read-only review of against its merge base with show read-only review of a single commit +External tool mode (jj merge-tools, git mergetool/difftool): + diffbandit merge [--path P] + three-way merge; exits non-zero unless resolved with :w + diffbandit edit + diff editor: edit the right folder/file (jj split, squash -i) + diffbandit diff + read-only folder or file diff + Environment: DIFFBANDIT_NVIM nvim executable (default: nvim) DIFFBANDIT_NVIM_APPNAME NVIM_APPNAME for a dedicated nvim config @@ -38,8 +46,11 @@ if [ $# -gt 0 ]; then DIFFBANDIT_ARGV=$(printf '%s\037' "$@") fi DIFFBANDIT_ERRFILE=$(mktemp "${TMPDIR:-/tmp}/diffbandit.XXXXXX") || exit 1 -export DIFFBANDIT_ARGV DIFFBANDIT_ERRFILE -trap 'rm -f "$DIFFBANDIT_ERRFILE"' EXIT +# The merge subcommand records "resolved" here; quitting without resolving +# must fail so jj/git keep the conflict. +DIFFBANDIT_STATUSFILE=$(mktemp "${TMPDIR:-/tmp}/diffbandit-status.XXXXXX") || exit 1 +export DIFFBANDIT_ARGV DIFFBANDIT_ERRFILE DIFFBANDIT_STATUSFILE +trap 'rm -f "$DIFFBANDIT_ERRFILE" "$DIFFBANDIT_STATUSFILE"' EXIT # Prefer the command: lazy plugin managers load the plugin on its commands, # not on require(). @@ -60,4 +71,8 @@ status=$? if [ -s "$DIFFBANDIT_ERRFILE" ]; then cat "$DIFFBANDIT_ERRFILE" >&2 fi +if [ "$1" = merge ] && [ "$status" -eq 0 ] && ! grep -q resolved "$DIFFBANDIT_STATUSFILE"; then + echo "diffbandit: merge not resolved (save the result with :w)" >&2 + status=1 +fi exit $status diff --git a/doc/diffbandit.txt b/doc/diffbandit.txt index 570a590..b9c9283 100644 --- a/doc/diffbandit.txt +++ b/doc/diffbandit.txt @@ -309,6 +309,15 @@ lazy-loaded plugin should list that command among its load triggers. `DIFFBANDIT_NVIM_APPNAME` selects a dedicated config (`NVIM_APPNAME`) and `DIFFBANDIT_NVIM` the nvim executable. +External tool mode works outside any repository: +>sh + diffbandit merge BASE LEFT RIGHT OUTPUT [--path P] # :w resolves + diffbandit edit LEFT RIGHT # edit the right folder/file in place + diffbandit diff LEFT RIGHT # read-only +< +`merge` exits non-zero unless the result was saved with `:w`, so jj and +`git mergetool` (with trustExitCode) keep the conflict. + ============================================================================== COMMIT PANEL *diffbandit-commit-panel* @@ -558,6 +567,7 @@ LUA API *diffbandit-lua-api* diffbandit.git_compare_branches({}) diffbandit.git_checkout("feature") diffbandit.merge("path/to/conflicted-file") + diffbandit.merge_files({ base = "B", left = "L", right = "R", output = "O" }) diffbandit.commit_panel({}) diffbandit.toggle_stage_hunk() diff --git a/lua/diffbandit/folder/init.lua b/lua/diffbandit/folder/init.lua index f71e08d..99f4348 100644 --- a/lua/diffbandit/folder/init.lua +++ b/lua/diffbandit/folder/init.lua @@ -374,6 +374,9 @@ function Folder.start(left_path, right_path, config, opts) self.left_root = left_root self.right_root = right_root self.open_file_diff = opts.open_file_diff + -- Diff-editor mode: the right folder is edited in place (jj split etc.). + self.editable_right = opts.editable_right == true + self.hide_patterns = opts.hide or {} self.disposed = false self.expanded = {} self.filter = "all" @@ -463,6 +466,10 @@ function Folder:refresh() self.active_jobs = 0 self.pending_batches = {} local filters = folder_config(self).filters or {} + if #self.hide_patterns > 0 then + local exclude = vim.list_extend(vim.deepcopy(filters.exclude or {}), self.hide_patterns) + filters = vim.tbl_extend("force", {}, filters, { exclude = exclude }) + end self.left_entries = folder_model.scan_tree(self.left_root, filters) self.right_entries = folder_model.scan_tree(self.right_root, filters) self.rows, self.rows_by_rel = folder_model.build_rows(self.left_entries, self.right_entries) @@ -820,8 +827,14 @@ function Folder:restore_from_child(context) if context and context.selected_rel then self.selected_rel = context.selected_rel end - self:build_visible_rows() - self:render() + if self.editable_right then + -- The child diff may have written the right side: rescan so statuses + -- (and newly created files) are current. + self:refresh() + else + self:build_visible_rows() + self:render() + end if context and context.topline then local win = context.cursor_side == "right" and self.right_win or self.left_win if win and vim.api.nvim_win_is_valid(win) then diff --git a/lua/diffbandit/init.lua b/lua/diffbandit/init.lua index 52a8ac3..8efd0d5 100644 --- a/lua/diffbandit/init.lua +++ b/lua/diffbandit/init.lua @@ -446,6 +446,14 @@ function M.merge(path, opts) return Merge.start_for_path(path, opts or {}, config) end +-- File-based three-way merge for external tools (jj ui.merge-editor, git +-- mergetool). opts: { base, left, right, output, path?, labels? }. +function M.merge_files(opts) + local config = state.get_config() + ensure_highlights(config) + return Merge.start_files(opts or {}, config) +end + local function start_folder_file_diff(folder_session, row, context) if not row then return nil, "no folder row selected" @@ -463,7 +471,26 @@ local function start_folder_file_diff(folder_session, row, context) if not left_source then return nil, "unable to read left file" end - if row.right then + if folder_session.editable_right then + -- Diff-editor mode (jj split / squash -i): the right folder is the edit + -- target. A file missing on the right is created on :w. + local right_path = row.right and row.right.path or (folder_session.right_root .. "/" .. row.rel) + if row.right then + right_source = document.source_from_file_or_buffer(right_path, row.rel, { + editable = { target = "file" }, + }, function() + return source_mod.from_file(right_path, row.rel, config) + end) + else + vim.fn.mkdir(vim.fn.fnamemodify(right_path, ":h"), "p") + right_source = source_mod.from_text("", row.rel, row.rel .. " (missing right)", { + empty_reason = "missing from right folder", + }) + if right_source then + right_source.editable = { target = "file", path = document.normalize_path(right_path) or right_path } + end + end + elseif row.right then right_source = source_mod.from_file(row.right.path, row.rel, config) else right_source = source_mod.from_text("", row.rel, row.rel .. " (missing right)", { diff --git a/lua/diffbandit/launcher.lua b/lua/diffbandit/launcher.lua index 925630a..10c3e6a 100644 --- a/lua/diffbandit/launcher.lua +++ b/lua/diffbandit/launcher.lua @@ -4,6 +4,12 @@ -- -- diffbandit [--cached] [--no-untracked] [ [] | .. | ...] [--] [...] -- diffbandit show [--] [...] +-- +-- External-tool subcommands (no repository needed; used by jj's +-- merge-tools.* config and git mergetool/difftool): +-- diffbandit merge [--path P] [--label-left L] [--label-right R] +-- diffbandit edit (editable right side: jj diff-editor) +-- diffbandit diff (read-only) local git = require("diffbandit.git") local panel_mod = require("diffbandit.panel") @@ -93,6 +99,101 @@ function M.parse(args, is_rev) return { kind = "status", opts = opts } end +local tool_commands = { merge = true, edit = true, diff = true } + +-- Pure: parses the external-tool subcommands. Returns nil when args[1] is not +-- one of them. +function M.parse_tool(args) + args = args or {} + local command = args[1] + if not tool_commands[command] then + return nil + end + local positionals = {} + local plan = { kind = command, labels = {} } + local value_flags = { + ["--path"] = function(v) plan.path = v end, + ["--label-left"] = function(v) plan.labels.left = v end, + ["--label-right"] = function(v) plan.labels.right = v end, + ["--label-base"] = function(v) plan.labels.base = v end, + } + local i = 2 + while i <= #args do + local arg = args[i] + if value_flags[arg] then + if args[i + 1] == nil then + return nil, arg .. " needs a value" + end + value_flags[arg](args[i + 1]) + i = i + 1 + elseif arg == "--" then + for j = i + 1, #args do + positionals[#positionals + 1] = args[j] + end + break + elseif arg:sub(1, 2) == "--" then + return nil, "unknown option: " .. arg + else + positionals[#positionals + 1] = arg + end + i = i + 1 + end + if command == "merge" then + if #positionals ~= 4 then + return nil, "usage: diffbandit merge [--path P]" + end + plan.base, plan.left, plan.right, plan.output = positionals[1], positionals[2], positionals[3], positionals[4] + else + if #positionals ~= 2 then + return nil, string.format("usage: diffbandit %s ", command) + end + plan.left, plan.right = positionals[1], positionals[2] + end + return plan +end + +local function mark_resolved() + local statusfile = vim.env.DIFFBANDIT_STATUSFILE + if statusfile and statusfile ~= "" then + pcall(vim.fn.writefile, { "resolved" }, statusfile) + end +end + +local function launch_tool(plan, cwd) + local diffbandit = require("diffbandit") + local function abs(path) + if path:sub(1, 1) == "/" then + return path + end + return cwd .. "/" .. path + end + if plan.kind == "merge" then + return diffbandit.merge_files({ + base = abs(plan.base), + left = abs(plan.left), + right = abs(plan.right), + output = abs(plan.output), + path = plan.path, + labels = plan.labels, + on_resolve = mark_resolved, + }) + end + local left, right = abs(plan.left), abs(plan.right) + local left_dir = vim.fn.isdirectory(left) == 1 + local right_dir = vim.fn.isdirectory(right) == 1 + if left_dir ~= right_dir then + return nil, "compare two folders or two files, not one of each" + end + if left_dir then + return diffbandit.folder_diff(left, right, { + editable_right = plan.kind == "edit", + -- jj adds a synthetic instructions file to diff-editor folders. + hide = { "^JJ%-INSTRUCTIONS$" }, + }) + end + return diffbandit.files(left, right, { left_label = plan.labels.left, right_label = plan.labels.right }) +end + -- True while any DiffBandit view is on screen: a live session (diff, merge, -- folder) or a commit panel whose windows are still open. function M.has_live_view() @@ -134,6 +235,18 @@ function M.launch(args, opts) local diffbandit = require("diffbandit") local uv = vim.uv or vim.loop local cwd = opts.cwd or uv.cwd() + local tool_plan, tool_err = M.parse_tool(args) + if tool_err then + return nil, tool_err + end + if tool_plan then + local host, err = launch_tool(tool_plan, cwd) + if host and opts.quit_on_close then + state.quit_on_close = true + watch_for_quit() + end + return host, err + end local root, root_err = git.find_root(cwd) if not root then return nil, root_err or "not a git repository" diff --git a/lua/diffbandit/merge/init.lua b/lua/diffbandit/merge/init.lua index ed45c61..b75f799 100644 --- a/lua/diffbandit/merge/init.lua +++ b/lua/diffbandit/merge/init.lua @@ -40,14 +40,76 @@ local function can_use_real_result_buffer(path) return document.find_loaded_buffer(path) ~= nil or vim.fn.filereadable(path) == 1 end -function Merge.load(root, path, config) - local stages, err = git.conflict_stages(root, path) - if not stages then - return nil, err - end - local base_text = stages.base or "" - local local_text = stages.local_text or "" - local remote_text = stages.remote or "" +-- A merge host decides where the three sides came from and what resolving +-- means. The git host reads index stages, writes the worktree, and runs +-- `git add`; the file host (jj/git mergetool) writes an output file only. +function Merge.git_host() + return { + kind = "git", + context = function(root) + return git.merge_context(root) + end, + result_path = function(root, path) + return git.abs_path(root, path) + end, + resolve = function(session, value) + local ok, write_err = git.write_worktree(session.root, session.path, value, false) + if not ok then + return false, write_err + end + local args = value == nil and { "add", "-A", "--", session.path } or { "add", "--", session.path } + local _, add_err = git.git_output(session.root, args) + if add_err then + return false, add_err + end + return true + end, + after_resolve = function(session) + session:refresh_git_queue(session.path) + end, + } +end + +-- opts: { output, labels = { left, right, base, operation }, on_resolve(value) } +function Merge.file_host(opts) + opts = opts or {} + local labels = opts.labels or {} + return { + kind = "file", + context = function() + return { + current = labels.left or "left", + incoming = labels.right or "right", + operation = labels.operation or "merge", + } + end, + -- The output is a tool-owned temp file: always edit a scratch result. + result_path = function() + return nil + end, + resolve = function(session, value) + if value == nil then + return false, "this merge tool cannot delete the result; resolve to content instead" + end + local fd = io.open(opts.output, "wb") + if not fd then + return false, "unable to write " .. tostring(opts.output) + end + fd:write(value) + fd:close() + session.resolved = true + if type(opts.on_resolve) == "function" then + opts.on_resolve(value) + end + return true + end, + } +end + +function Merge.data_from_texts(root, path, texts, config) + local base_text = texts.base or "" + local local_text = texts.local_text or "" + local remote_text = texts.remote or "" local base_lines = text.split_lines(base_text) local local_lines = text.split_lines(local_text) local remote_lines = text.split_lines(remote_text) @@ -59,8 +121,8 @@ function Merge.load(root, path, config) base_text = base_text, local_text = local_text, remote_text = remote_text, - has_local = stages.has_local ~= false, - has_remote = stages.has_remote ~= false, + has_local = texts.has_local ~= false, + has_remote = texts.has_remote ~= false, base_lines = base_lines, local_lines = local_lines, remote_lines = remote_lines, @@ -77,6 +139,63 @@ function Merge.load(root, path, config) }, nil end +function Merge.load(root, path, config) + local stages, err = git.conflict_stages(root, path) + if not stages then + return nil, err + end + return Merge.data_from_texts(root, path, stages, config) +end + +local function read_file_text(path, what) + if not path or path == "" then + return nil, "missing " .. what .. " path" + end + if vim.fn.filereadable(path) ~= 1 then + -- jj and git pass an empty/missing file for an absent side. + return "", nil, false + end + local fd = io.open(path, "rb") + if not fd then + return nil, "unable to read " .. what .. ": " .. path + end + local value = fd:read("*a") or "" + fd:close() + return value, nil, true +end + +-- File-based three-way merge (jj ui.merge-editor, git mergetool). +-- opts: { base, left, right, output, path?, labels?, on_resolve? } +function Merge.start_files(opts, config, start_opts) + opts = opts or {} + config = config or state.get_config() + if not opts.output or opts.output == "" then + return nil, "missing output path" + end + local texts = {} + local err, present + texts.base, err = read_file_text(opts.base, "base") + if not texts.base then + return nil, err + end + texts.local_text, err, present = read_file_text(opts.left, "left") + if not texts.local_text then + return nil, err + end + texts.has_local = present + texts.remote, err, present = read_file_text(opts.right, "right") + if not texts.remote then + return nil, err + end + texts.has_remote = present + local display_path = opts.path or vim.fn.fnamemodify(opts.output, ":t") + local uv = vim.uv or vim.loop + local data = Merge.data_from_texts(uv.cwd(), display_path, texts, config) + return Merge.start(data, config, vim.tbl_extend("force", start_opts or {}, { + host = Merge.file_host(opts), + })) +end + function Merge.start(data, config, opts) opts = opts or {} local self = setmetatable({}, Merge) @@ -103,6 +222,7 @@ function Merge.start(data, config, opts) self.current_conflict = #self.conflicts > 0 and 1 or 0 self.selected_pair_hunk = nil self.line_ending_warning = data.line_ending_warning + self.host = opts.host or Merge.git_host() self.file_queue = opts.queue self.file_queue_index = opts.queue_index or (opts.queue and opts.queue.index) self.right_win = nil @@ -137,12 +257,12 @@ function Merge.start(data, config, opts) -- mirrored in self.panel.visible by panel_mod. self.hidden = { panel = false, local_pane = false, remote_pane = false } self.status_enabled = config_mod.section(self.config, "ui", "status").enabled ~= false - self.merge_context = git.merge_context(self.root) + self.merge_context = self.host.context(self.root) self.connector_width = kitty.min_core_width(self) local ft = source_mod.detect_filetype(self.path) - local result_path = git.abs_path(self.root, self.path) - if can_use_real_result_buffer(result_path) then + local result_path = self.host.result_path(self.root, self.path) + if result_path and can_use_real_result_buffer(result_path) then self.result_editable = { target = "merge-result", path = document.normalize_path(result_path) or result_path, @@ -1780,14 +1900,14 @@ function Merge:open_merge_file(index, opts) self.remote_hunks = data.remote_hunks or {} self.current_conflict = #self.conflicts > 0 and 1 or 0 self.line_ending_warning = data.line_ending_warning - self.merge_context = git.merge_context(self.root) + self.merge_context = self.host.context(self.root) local ft = source_mod.detect_filetype(self.path) local old_result_editable = self.result_editable local old_result_buf = self.result_buf self.result_editable = nil - local result_path = git.abs_path(self.root, self.path) - if can_use_real_result_buffer(result_path) then + local result_path = self.host.result_path(self.root, self.path) + if result_path and can_use_real_result_buffer(result_path) then self.result_editable = { target = "merge-result", path = document.normalize_path(result_path) or result_path, @@ -2053,35 +2173,19 @@ function Merge:apply_non_conflicting() end function Merge:resolve() - if self.delete_result then - local ok, write_err = git.write_worktree(self.root, self.path, nil, false) - if not ok then - nvim.notify_error(tostring(write_err)) - return false - end - local _, add_err = git.git_output(self.root, { "add", "-A", "--", self.path }) - if add_err then - nvim.notify_error(tostring(add_err)) - return false - end - pcall(vim.api.nvim_set_option_value, "modified", false, { buf = self.result_buf }) - self:refresh_git_queue(self.path) - nvim.notify_info("resolved " .. tostring(self.path)) - return true + local value + if not self.delete_result then + value = text.to_text(logical_buffer_lines(self.result_buf)) end - local value = text.to_text(logical_buffer_lines(self.result_buf)) - local ok, write_err = git.write_worktree(self.root, self.path, value, false) + local ok, err = self.host.resolve(self, value) if not ok then - nvim.notify_error(tostring(write_err)) - return false - end - local _, add_err = git.git_output(self.root, { "add", "--", self.path }) - if add_err then - nvim.notify_error(tostring(add_err)) + nvim.notify_error(tostring(err)) return false end pcall(vim.api.nvim_set_option_value, "modified", false, { buf = self.result_buf }) - self:refresh_git_queue(self.path) + if self.host.after_resolve then + self.host.after_resolve(self) + end nvim.notify_info("resolved " .. tostring(self.path)) return true end diff --git a/tests/specs/25_launcher.lua b/tests/specs/25_launcher.lua index b80a0e1..7060983 100644 --- a/tests/specs/25_launcher.lua +++ b/tests/specs/25_launcher.lua @@ -164,3 +164,98 @@ do "merge_base rev queue swaps the base for the merge base") assert_eq(queue.opts.merge_base, nil, "merge_base flag is consumed once resolved") end + +-- External-tool subcommands: jj merge-tools / git mergetool contract. +do + local launcher = require("diffbandit.launcher") + local plan = assert(launcher.parse_tool({ "merge", "b", "l", "r", "o", "--path", "src/x.lua" })) + assert_eq(plan.kind, "merge", "merge subcommand parses") + assert_eq(table.concat({ plan.base, plan.left, plan.right, plan.output }, ","), "b,l,r,o", + "merge takes base left right output in order") + assert_eq(plan.path, "src/x.lua", "merge --path is the display path") + assert_eq(launcher.parse_tool({ "status" }), nil, "Non-tool args fall through to the git parser") + local _, err = launcher.parse_tool({ "merge", "b", "l", "r" }) + assert_eq(err ~= nil, true, "merge needs four paths") + plan = assert(launcher.parse_tool({ "edit", "L", "R" })) + assert_eq(plan.kind .. plan.left .. plan.right, "editLR", "edit takes two paths") + _, err = launcher.parse_tool({ "diff", "L", "R", "--bogus" }) + assert_eq(err, "unknown option: --bogus", "Tool subcommands reject unknown options") + + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, "p") + vim.fn.writefile({ "one", "base", "three" }, dir .. "/base") + vim.fn.writefile({ "one", "left", "three" }, dir .. "/left") + vim.fn.writefile({ "one", "right", "three" }, dir .. "/right") + local resolved_value + local merge_mod = require("diffbandit.merge") + local session = assert((merge_mod.start_files({ + base = dir .. "/base", + left = dir .. "/left", + right = dir .. "/right", + output = dir .. "/output", + path = "src/x.txt", + labels = { left = "rwoumtly", right = "nqswmmuv" }, + on_resolve = function(value) + resolved_value = value + end, + }, config))) + assert_eq(#session.conflicts, 1, "File merge finds the overlapping conflict") + assert_eq(session.path, "src/x.txt", "File merge shows the --path display path") + assert_eq(session.result_editable, nil, "File merge edits a scratch result, not the output file") + assert_eq(session:build_status_lines().left:find("rwoumtly", 1, true) ~= nil, true, + "File merge header uses the provided left label") + vim.api.nvim_buf_set_lines(session.result_buf, 0, -1, false, { "one", "merged", "three" }) + assert_eq(session:resolve(), true, "File merge resolves") + assert_eq(table.concat(read_file(dir .. "/output"), "|"), "one|merged|three", "Resolve writes the output file") + assert_eq(resolved_value, "one\nmerged\nthree\n", "on_resolve receives the written text") + assert_eq(session.resolved, true, "Session records the resolution") + session.delete_result = true + assert_eq(session:resolve(), false, "File merge refuses to delete the result") + session:close() + + -- Missing side (add/add or delete): an absent file is an absent side. + session = assert((merge_mod.start_files({ + base = dir .. "/nope", + left = dir .. "/left", + right = dir .. "/right", + output = dir .. "/output2", + }, config))) + assert_eq(session.has_local, true, "Present left side is kept") + assert_eq(#session.base_lines, 0, "Missing base is empty") + session:close() + + -- Diff-editor mode: the right folder is edited in place. + local left_dir, right_dir = dir .. "/L", dir .. "/R" + vim.fn.mkdir(left_dir .. "/sub", "p") + vim.fn.mkdir(right_dir, "p") + vim.fn.writefile({ "a", "b" }, left_dir .. "/same.txt") + vim.fn.writefile({ "a", "B" }, right_dir .. "/same.txt") + vim.fn.writefile({ "gone" }, left_dir .. "/sub/deleted.txt") + vim.fn.writefile({ "ignore me" }, right_dir .. "/JJ-INSTRUCTIONS") + local diffbandit = require("diffbandit") + local folder = assert(diffbandit.folder_diff(left_dir, right_dir, { + editable_right = true, + hide = { "^JJ%-INSTRUCTIONS$" }, + })) + assert_eq(folder.rows_by_rel["JJ-INSTRUCTIONS"], nil, "Hidden patterns are not listed") + local child = assert((folder.open_file_diff(folder, folder.rows_by_rel["same.txt"], { tabpage = folder.tabpage }))) + assert_eq(child.right.editable ~= nil, true, "Editable folder diff makes the right file editable") + vim.api.nvim_set_current_win(child.right_win) + vim.api.nvim_buf_set_lines(child.right_buf, 0, -1, false, { "a", "b" }) + vim.cmd("write") + assert_eq(table.concat(read_file(right_dir .. "/same.txt"), "|"), "a|b", "Writing the child edits the right folder") + child:close() + child = assert((folder.open_file_diff(folder, folder.rows_by_rel["sub/deleted.txt"], { tabpage = folder.tabpage }))) + vim.api.nvim_set_current_win(child.right_win) + vim.api.nvim_buf_set_lines(child.right_buf, 0, -1, false, { "gone" }) + vim.cmd("write") + assert_eq(read_file(right_dir .. "/sub/deleted.txt")[1], "gone", "A file missing on the right is created on write") + child:close() + folder:close() + + local readonly = assert(diffbandit.folder_diff(left_dir, right_dir, {})) + child = assert((readonly.open_file_diff(readonly, readonly.rows_by_rel["same.txt"], { tabpage = readonly.tabpage }))) + assert_eq(child.right.editable, nil, "Plain folder diff stays read-only") + child:close() + readonly:close() +end From 9e4751587c802818516446273e9e5a8747544738 Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Tue, 3 Nov 2026 11:25:39 -0500 Subject: [PATCH 2/4] Route repository calls through a VCS backend proxy Consumers now require diffbandit.vcs instead of diffbandit.git. The proxy exposes the git module's function names and routes each call by its root/path/queue argument, so a second backend can plug in without touching the hosts, panel, or hunk actions. Only git is registered yet; vcs.prefer selects a backend once more exist. Also fixes git.current_branch (it called a local before its definition), drops a duplicate relpath, and stops commit_metadata from running a wasted `git log` when a revision is given. tests/run.lua accepts DIFFBANDIT_SPECS=20,50 to run a subset of specs. --- lua/diffbandit/config.lua | 5 + lua/diffbandit/git/init.lua | 24 +++-- lua/diffbandit/git/workflows.lua | 2 +- lua/diffbandit/init.lua | 2 +- lua/diffbandit/launcher.lua | 4 +- lua/diffbandit/merge/init.lua | 7 +- lua/diffbandit/panel/init.lua | 4 +- lua/diffbandit/session/actions.lua | 2 +- lua/diffbandit/util/amend_mode.lua | 2 +- lua/diffbandit/util/log_view.lua | 2 +- lua/diffbandit/vcs/init.lua | 154 +++++++++++++++++++++++++++++ tests/run.lua | 17 ++++ 12 files changed, 200 insertions(+), 25 deletions(-) create mode 100644 lua/diffbandit/vcs/init.lua diff --git a/lua/diffbandit/config.lua b/lua/diffbandit/config.lua index 11055d2..26aeb1e 100644 --- a/lua/diffbandit/config.lua +++ b/lua/diffbandit/config.lua @@ -108,6 +108,11 @@ local defaults = { prompt_message = "Reached final change in this diff. Open next file with changes?", on_request_next_file = nil, }, + vcs = { + -- "auto": jj when the repository has a .jj workspace (and jj is + -- installed), git otherwise. "git" / "jj" force a backend. + prefer = "auto", + }, git = { default_mode = "all", default_scope = "repo", diff --git a/lua/diffbandit/git/init.lua b/lua/diffbandit/git/init.lua index 1fe93d6..1ff6671 100644 --- a/lua/diffbandit/git/init.lua +++ b/lua/diffbandit/git/init.lua @@ -630,10 +630,6 @@ function M.queue(opts, config) return queue, nil end -function M.current_branch(root) - return current_branch_label(root) -end - function M.root(opts) opts = opts or {} local uv = vim.uv or vim.loop @@ -725,11 +721,13 @@ function M.log(root, opts) end function M.commit_metadata(root, rev) - local commits, err = M.log(root, { rev = "-1", max_count = 1 }) - if not commits then - return nil, err - end - if rev and rev ~= "" then + local commits, err + if not rev or rev == "" then + commits, err = M.log(root, { rev = "-1", max_count = 1 }) + if not commits then + return nil, err + end + else local args = { "log" } vim.list_extend(args, log_format_args()) args[#args + 1] = "-1" @@ -996,6 +994,10 @@ local function current_branch_label(root) return short and ("detached " .. short) or "detached HEAD" end +function M.current_branch(root) + return current_branch_label(root) +end + local function commit_name(root, rev) local name = git_output(root, { "name-rev", "--name-only", "--exclude=tags/*", rev }) name = name and vim.trim(name) or "" @@ -1083,10 +1085,6 @@ function M.abs_path(root, path) return abs_path(root, path) end -function M.relpath(root, path) - return relpath(root, path) -end - function M.git_output(root, args) return git_output(root, args) end diff --git a/lua/diffbandit/git/workflows.lua b/lua/diffbandit/git/workflows.lua index c69aba4..cb4308e 100644 --- a/lua/diffbandit/git/workflows.lua +++ b/lua/diffbandit/git/workflows.lua @@ -1,4 +1,4 @@ -local git = require("diffbandit.git") +local git = require("diffbandit.vcs") local nvim = require("diffbandit.util.nvim") local log_view = require("diffbandit.util.log_view") diff --git a/lua/diffbandit/init.lua b/lua/diffbandit/init.lua index 8efd0d5..7795382 100644 --- a/lua/diffbandit/init.lua +++ b/lua/diffbandit/init.lua @@ -3,7 +3,7 @@ local state = require("diffbandit.state") local diff_mod = require("diffbandit.diff") local Session = require("diffbandit.session") local highlights = require("diffbandit.highlights") -local git_mod = require("diffbandit.git") +local git_mod = require("diffbandit.vcs") local source_mod = require("diffbandit.util.source") local text = require("diffbandit.util.text") local document = require("diffbandit.util.document") diff --git a/lua/diffbandit/launcher.lua b/lua/diffbandit/launcher.lua index 10c3e6a..b969b3b 100644 --- a/lua/diffbandit/launcher.lua +++ b/lua/diffbandit/launcher.lua @@ -11,7 +11,7 @@ -- diffbandit edit (editable right side: jj diff-editor) -- diffbandit diff (read-only) -local git = require("diffbandit.git") +local git = require("diffbandit.vcs") local panel_mod = require("diffbandit.panel") local state = require("diffbandit.state") @@ -290,7 +290,7 @@ local function fail(err) return end pcall(vim.fn.writefile, { "diffbandit: " .. message }, errfile) - if message == "no git changes" then + if git.is_no_changes(message) then vim.cmd("qall!") else vim.cmd("cquit 1") diff --git a/lua/diffbandit/merge/init.lua b/lua/diffbandit/merge/init.lua index b75f799..7518e78 100644 --- a/lua/diffbandit/merge/init.lua +++ b/lua/diffbandit/merge/init.lua @@ -1,5 +1,6 @@ local diff_pair = require("diffbandit.diff.pair") local git = require("diffbandit.git") +local vcs = require("diffbandit.vcs") local merge_model = require("diffbandit.merge.model") local nvim = require("diffbandit.util.nvim") local keymaps = require("diffbandit.util.keymaps") @@ -140,7 +141,7 @@ function Merge.data_from_texts(root, path, texts, config) end function Merge.load(root, path, config) - local stages, err = git.conflict_stages(root, path) + local stages, err = vcs.conflict_stages(root, path) if not stages then return nil, err end @@ -430,11 +431,11 @@ function Merge.start_for_path(path, opts, config) opts = opts or {} config = config or state.get_config() local start = path or vim.api.nvim_buf_get_name(0) or vim.loop.cwd() - local root, root_err = git.find_root(start) + local root, root_err = vcs.find_root(start) if not root then return nil, root_err end - local rel = path and git.relpath(root, path) or git.relpath(root, vim.api.nvim_buf_get_name(0)) + local rel = path and vcs.relpath(root, path) or vcs.relpath(root, vim.api.nvim_buf_get_name(0)) if not rel or rel == "" then return nil, "no conflict path provided" end diff --git a/lua/diffbandit/panel/init.lua b/lua/diffbandit/panel/init.lua index 646a68c..7c098fb 100644 --- a/lua/diffbandit/panel/init.lua +++ b/lua/diffbandit/panel/init.lua @@ -1,4 +1,4 @@ -local git = require("diffbandit.git") +local git = require("diffbandit.vcs") local nvim = require("diffbandit.util.nvim") local ui = require("diffbandit.util.ui") local config_mod = require("diffbandit.config") @@ -336,7 +336,7 @@ function M.refresh_git_queue(session, opts) }) local next_queue, err = git.queue(queue_opts, git_config) if not next_queue then - if err ~= "no git changes" then + if not git.is_no_changes(err) then nvim.notify_info(tostring(err)) return false, err end diff --git a/lua/diffbandit/session/actions.lua b/lua/diffbandit/session/actions.lua index 5a25579..9d10857 100644 --- a/lua/diffbandit/session/actions.lua +++ b/lua/diffbandit/session/actions.lua @@ -1,5 +1,5 @@ local nvim = require("diffbandit.util.nvim") -local git_mod = require("diffbandit.git") +local git_mod = require("diffbandit.vcs") local diff_mod = require("diffbandit.diff") local source_mod = require("diffbandit.util.source") local text = require("diffbandit.util.text") diff --git a/lua/diffbandit/util/amend_mode.lua b/lua/diffbandit/util/amend_mode.lua index 6bb04f0..2dfd601 100644 --- a/lua/diffbandit/util/amend_mode.lua +++ b/lua/diffbandit/util/amend_mode.lua @@ -2,7 +2,7 @@ -- standalone commit panel): seeds the commit message from the last commit -- and reloads the file queue against the amend base. local nvim = require("diffbandit.util.nvim") -local git = require("diffbandit.git") +local git = require("diffbandit.vcs") local panel_mod = require("diffbandit.panel") local queue_host = require("diffbandit.host.queue") diff --git a/lua/diffbandit/util/log_view.lua b/lua/diffbandit/util/log_view.lua index b39db47..43b79c5 100644 --- a/lua/diffbandit/util/log_view.lua +++ b/lua/diffbandit/util/log_view.lua @@ -1,7 +1,7 @@ -- Git log browser: a bottom split listing commits with open/refresh -- keymaps. Extracted from git_workflows.lua, which keeps only workflow -- orchestration. -local git = require("diffbandit.git") +local git = require("diffbandit.vcs") local nvim = require("diffbandit.util.nvim") local ui = require("diffbandit.util.ui") diff --git a/lua/diffbandit/vcs/init.lua b/lua/diffbandit/vcs/init.lua new file mode 100644 index 0000000..60488ce --- /dev/null +++ b/lua/diffbandit/vcs/init.lua @@ -0,0 +1,154 @@ +-- Version-control backend routing. Consumers require this module in place of +-- `diffbandit.git`: it exposes the same function names and forwards each call +-- to the backend that owns the repository (git or jj), chosen from the call's +-- root/path/queue argument. +-- +-- Backends share one contract (see lua/diffbandit/git/init.lua for the +-- reference implementation). A queue from either backend keeps +-- `kind = "git"` — it means "repository-backed queue" to hosts, panel, and +-- status code — and carries `vcs = "git" | "jj"` to say which backend built it. + +local M = {} + +local backend_modules = { + git = "diffbandit.git", +} + +local uv = vim.uv or vim.loop + +local function prefer() + local ok, state = pcall(require, "diffbandit.state") + local config = ok and state.get_config and state.get_config() or nil + local section = config and config.vcs or {} + return section.prefer or "auto" +end + +local function start_dir(path) + if not path or path == "" then + return uv.cwd() + end + local expanded = vim.fn.fnamemodify(path, ":p") + if vim.fn.isdirectory(expanded) == 1 then + return (expanded:gsub("/$", "")) + end + return vim.fn.fnamemodify(expanded, ":h") +end + +-- Returns "jj" plus the workspace root when the nearest repository marker is +-- a jj workspace (colocated repos have both .jj and .git; jj wins unless +-- vcs.prefer = "git"), otherwise "git" (root resolved later by git itself). +function M.detect(path) + local mode = prefer() + if mode == "git" then + return "git", nil + end + local dir = start_dir(path) + while dir and dir ~= "" do + if vim.fn.isdirectory(dir .. "/.jj") == 1 then + if mode == "jj" or vim.fn.executable("jj") == 1 then + return "jj", dir + end + return "git", nil + end + if mode ~= "jj" and uv.fs_stat(dir .. "/.git") then + return "git", nil + end + local parent = vim.fn.fnamemodify(dir, ":h") + if parent == dir then + break + end + dir = parent + end + if mode == "jj" then + return "jj", nil + end + return "git", nil +end + +function M.backend(kind) + return require(backend_modules[kind] or backend_modules.git) +end + +function M.kind_for_path(path) + return (M.detect(path)) +end + +function M.for_path(path) + return M.backend(M.kind_for_path(path)) +end + +function M.for_root(root) + return M.for_path(root) +end + +function M.for_queue(queue) + return M.backend(queue and queue.vcs or "git") +end + +-- The "nothing to review" sentinel each backend's queue() returns. +function M.is_no_changes(err) + return err == "no git changes" or err == "no jj changes" +end + +-- True for queues built by any backend (git or jj). +function M.is_vcs_queue(queue) + return queue ~= nil and queue.kind == "git" +end + +local function current_buffer_path() + local name = vim.api.nvim_buf_get_name(0) + if name ~= "" then + return name + end + return nil +end + +-- Where an opts-table call (queue/root) will look for its repository. +local function opts_start(opts) + opts = opts or {} + return opts.root + or opts.path + or (opts.scope == "current" and opts.pathspecs and opts.pathspecs[1]) + or current_buffer_path() + or uv.cwd() +end + +local opts_first = { queue = true, root = true } +local queue_first = { sources_for_entry = true } + +local function route(name, first) + if queue_first[name] then + return M.for_queue(first) + end + if opts_first[name] then + return M.for_path(opts_start(first)) + end + -- Everything else takes a root (or, for find_root, a start path) first. + if type(first) == "string" then + return M.for_path(first) + end + return M.backend("git") +end + +local proxy_cache = {} + +setmetatable(M, { + __index = function(_, name) + local fn = proxy_cache[name] + if fn then + return fn + end + fn = function(first, ...) + local backend = route(name, first) + local impl = backend[name] + if type(impl) ~= "function" then + return nil, string.format("%s is not supported in this repository", name) + end + return impl(first, ...) + end + proxy_cache[name] = fn + return fn + end, +}) + +return M diff --git a/tests/run.lua b/tests/run.lua index b3b6c2c..e32d3c4 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -46,6 +46,23 @@ local specs = { "45_kitty.lua", } +-- DIFFBANDIT_SPECS=20,50 runs only the matching specs (00_harness always +-- runs: it defines the shared helpers). +local only = os.getenv("DIFFBANDIT_SPECS") +if only and only ~= "" then + local wanted = {} + for prefix in only:gmatch("[^,%s]+") do + wanted[prefix] = true + end + local filtered = {} + for _, name in ipairs(specs) do + if name:sub(1, 2) == "00" or wanted[name:sub(1, 2)] then + filtered[#filtered + 1] = name + end + end + specs = filtered +end + local map_entries = {} local bodies = {} for _, name in ipairs(specs) do From 5c780c228b6705cbad6c682d066cacf22c7f8af7 Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Tue, 3 Nov 2026 11:26:16 -0500 Subject: [PATCH 3/4] Add a native Jujutsu (jj) backend In a jj workspace (colocated or not) the vcs proxy routes to the new jj backend, so reviews, hunk and file staging, the commit panel, log, compare, checkout, and conflict resolution all work there. Staging follows jj's squash workflow: an undescribed, mutable @- is the staging change, and staging moves hunks from @ into it with `jj diffedit --restore-descendants` (driven by a generated non-interactive tool script), so @'s content never changes. A described @- is treated as a commit: the first stage from the unstaged view starts a fresh staging change instead of rewriting it, and only amend mode stages into or re-describes it. Commit describes the staging change and opens a new one; without one it runs `jj commit`. The backend maps its views onto git-shaped options (unstaged @- -> @, staged @-- -> @-, all @-- -> @, rev for any revsets) so git/sources.lua builds diff sources unchanged; ref_labels and index_tracks_additions are the only jj hooks there. Conflicts in @ are read from git-style markers and resolved by writing the working copy through a jj merge host. CI installs a pinned jj; tests/specs/50_jj.lua runs against real jj repos (including `jj resolve` and `jj split` through bin/diffbandit) and is skipped when jj is missing. --- .github/workflows/ci.yml | 10 + lua/diffbandit/config.lua | 5 + lua/diffbandit/git/init.lua | 14 + lua/diffbandit/git/sources.lua | 37 +- lua/diffbandit/jj/init.lua | 1334 ++++++++++++++++++++++++++++ lua/diffbandit/merge/init.lua | 19 +- lua/diffbandit/session/actions.lua | 10 + lua/diffbandit/util/process.lua | 14 +- lua/diffbandit/util/status.lua | 3 +- lua/diffbandit/vcs/init.lua | 1 + tests/run.lua | 1 + tests/specs/50_jj.lua | 485 ++++++++++ 12 files changed, 1912 insertions(+), 21 deletions(-) create mode 100644 lua/diffbandit/jj/init.lua create mode 100644 tests/specs/50_jj.lua diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c078527..26f078c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,16 @@ jobs: sudo apt-get update sudo apt-get install -y git lua5.1 tmux + - name: Install jj + env: + JJ_VERSION: v0.46.0 + run: | + mkdir -p "$HOME/.local/bin" + curl -fsSL "https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/jj-${JJ_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + | tar -xz -C "$HOME/.local/bin" ./jj + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/jj" --version + - name: Install Neovim uses: rhysd/action-setup-vim@v1 with: diff --git a/lua/diffbandit/config.lua b/lua/diffbandit/config.lua index 26aeb1e..f411eba 100644 --- a/lua/diffbandit/config.lua +++ b/lua/diffbandit/config.lua @@ -113,6 +113,11 @@ local defaults = { -- installed), git otherwise. "git" / "jj" force a backend. prefer = "auto", }, + jj = { + -- Initial review mode in jj repos: "unstaged" (@- -> @, like `jj diff`) + -- or "all" (@-- -> @, with stage indicators for hunks already in @-). + default_mode = "unstaged", + }, git = { default_mode = "all", default_scope = "repo", diff --git a/lua/diffbandit/git/init.lua b/lua/diffbandit/git/init.lua index 1ff6671..95a8871 100644 --- a/lua/diffbandit/git/init.lua +++ b/lua/diffbandit/git/init.lua @@ -1582,4 +1582,18 @@ M._private = { split_char = split_char, } +-- Worktree/buffer readers the jj backend reuses (the working copy is plain +-- files in both systems). +M._shared = { + abs_path = abs_path, + normalize_path = normalize_path, + read_worktree = read_worktree, + read_worktree_raw = read_worktree_raw, + read_worktree_symlink = read_worktree_symlink, + find_loaded_buffer = find_loaded_buffer, + has_modified_loaded_buffer = has_modified_loaded_buffer, + entry_uses_modified_buffer = entry_uses_modified_buffer, + worktree_mode = worktree_mode, +} + return M diff --git a/lua/diffbandit/git/sources.lua b/lua/diffbandit/git/sources.lua index 53889be..3c2e275 100644 --- a/lua/diffbandit/git/sources.lua +++ b/lua/diffbandit/git/sources.lua @@ -34,6 +34,13 @@ local function source_from_hex(text, path, label, metadata, opts) return source end +-- Display names for the refs in labels; backends without an index (jj) +-- override them through queue.opts.ref_labels. +local function ref_label(opts, key, default) + local labels = opts and opts.ref_labels + return labels and labels[key] or default +end + local function source_from_binary_notice(path, label, metadata) local source = source_from_text("[DiffBandit: binary file hidden]\n", path, label, metadata) source.git_binary_hidden = true @@ -255,7 +262,11 @@ function M.new(readers) end if side == "left" then - if entry.untracked or (entry.status == "A" and (mode == "staged" or mode == "all" or mode == "rev")) then + -- Without an index (jj), an added file is absent on the left in + -- every mode, including unstaged. + local added_absent = entry.status == "A" + and (mode == "staged" or mode == "all" or mode == "rev" or opts.index_tracks_additions == false) + if entry.untracked or added_absent then local label if entry.untracked then label = string.format("%s (not tracked)", label_path) @@ -263,8 +274,10 @@ function M.new(readers) label = string.format("%s (%s: absent)", label_path, opts.base) elseif opts.unborn_head then label = string.format("%s (empty tree: absent)", label_path) + elseif mode == "unstaged" then + label = string.format("%s (%s: absent)", label_path, ref_label(opts, "index", "index")) else - label = string.format("%s (%s: absent)", label_path, opts.base or "HEAD") + label = string.format("%s (%s: absent)", label_path, ref_label(opts, "base", opts.base or "HEAD")) end local absent_ref = entry.untracked and "not tracked" or (opts.unborn_head and "empty tree" or (mode == "rev" and opts.base or opts.base or "HEAD")) @@ -278,16 +291,16 @@ function M.new(readers) }) end if mode == "unstaged" then - return git_source(queue, path, string.format("%s (index)", label_path), function() + return git_source(queue, path, string.format("%s (%s)", label_path, ref_label(opts, "index", "index")), function() return read_blob(root, ":" .. path) - end, { git_side = side, git_target = "index", git_ref = "index", git_relpath = path, git_entry_kind = entry.kind }) + end, { git_side = side, git_target = "index", git_ref = ref_label(opts, "index", "index"), git_relpath = path, git_entry_kind = entry.kind }) elseif mode == "staged" then - return git_source(queue, path, string.format("%s (HEAD)", label_path), function() + return git_source(queue, path, string.format("%s (%s)", label_path, ref_label(opts, "base", opts.base or "HEAD")), function() return read_blob(root, (opts.base or "HEAD") .. ":" .. path) end, { git_side = side, git_target = "head", git_ref = opts.base or "HEAD", git_relpath = path, git_entry_kind = entry.kind }) elseif mode == "all" then local ref = opts.unborn_head and "empty tree" or (opts.base or "HEAD") - return git_source(queue, path, string.format("%s (%s)", label_path, opts.base or "HEAD"), function() + return git_source(queue, path, string.format("%s (%s)", label_path, ref_label(opts, "base", opts.base or "HEAD")), function() return read_blob(root, (opts.base or "HEAD") .. ":" .. path) end, { git_side = side, git_target = "head", git_ref = ref, git_relpath = path, git_entry_kind = entry.kind }) elseif mode == "rev" then @@ -304,7 +317,7 @@ function M.new(readers) elseif mode == "rev" then label = string.format("%s (%s: absent)", label_path, opts.target) else - label = string.format("%s (working tree: deleted)", label_path) + label = string.format("%s (%s: deleted)", label_path, ref_label(opts, "worktree", "working tree")) end return empty_source(abs_path(root, path), label, { git_state = "deleted", @@ -316,9 +329,9 @@ function M.new(readers) }) end if mode == "staged" then - return git_source(queue, path, string.format("%s (index)", label_path), function() + return git_source(queue, path, string.format("%s (%s)", label_path, ref_label(opts, "index", "index")), function() return read_blob(root, ":" .. path) - end, { git_side = side, git_target = "index", git_ref = "index", git_relpath = path, git_entry_kind = entry.kind }) + end, { git_side = side, git_target = "index", git_ref = ref_label(opts, "index", "index"), git_relpath = path, git_entry_kind = entry.kind }) elseif mode == "rev" then return git_source(queue, path, string.format("%s (%s)", label_path, opts.target), function() return read_blob(root, opts.target .. ":" .. path) @@ -327,19 +340,19 @@ function M.new(readers) local worktree_path = abs_path(root, path) local worktree_bufnr = opts.use_buffer ~= false and find_loaded_buffer(worktree_path) or nil - return git_source(queue, path, string.format("%s (working tree)", label_path), function() + return git_source(queue, path, string.format("%s (%s)", label_path, ref_label(opts, "worktree", "working tree")), function() if opts.use_buffer ~= false and has_modified_loaded_buffer(root, path) then return read_worktree(root, path, opts.use_buffer) end local raw, raw_err = read_worktree_raw(root, path) if raw then - return raw, nil, "working tree" + return raw, nil, ref_label(opts, "worktree", "working tree") end return nil, raw_err end, { git_side = side, git_target = "worktree", - git_ref = "working tree", + git_ref = ref_label(opts, "worktree", "working tree"), git_relpath = path, git_entry_kind = entry.kind, editable = { diff --git a/lua/diffbandit/jj/init.lua b/lua/diffbandit/jj/init.lua new file mode 100644 index 0000000..2312267 --- /dev/null +++ b/lua/diffbandit/jj/init.lua @@ -0,0 +1,1334 @@ +-- Jujutsu (jj) backend. Implements the same contract as diffbandit.git so +-- hosts, the panel, and hunk actions work unchanged (routed by +-- diffbandit.vcs). +-- +-- Model (the jj "squash workflow"): +-- * the working copy is the commit @ (files on disk); +-- * the stage target is its parent @- — staging a hunk moves it from @ +-- into @-, unstaging moves it back, and @'s content never changes; +-- * modes map onto revisions: +-- unstaged @- -> @ (plain `jj diff`; the default) +-- staged @-- -> @- +-- all @-- -> @ +-- rev base -> target (any revsets) +-- +-- Git-shaped blob specs are kept so git/sources.lua can build diff sources +-- unchanged: ":path" reads the stage target (@-), "REV:path" reads REV. +-- Revsets containing ":" are resolved to commit ids before they enter a spec. + +local process = require("diffbandit.util.process") +local text = require("diffbandit.util.text") +local git = require("diffbandit.git") +local git_sources = require("diffbandit.git.sources") + +local shared = git._shared + +local M = {} + +M.STAGE_REV = "@-" +M.STAGE_BASE = "@--" + +local function jj_cmd(root, args, opts) + opts = opts or {} + local cmd = { "jj", "--no-pager", "--color=never" } + if root and root ~= "" then + cmd[#cmd + 1] = "-R" + cmd[#cmd + 1] = root + end + if opts.ignore_working_copy then + cmd[#cmd + 1] = "--ignore-working-copy" + end + for _, arg in ipairs(args) do + cmd[#cmd + 1] = arg + end + return cmd +end + +-- jj prints paths relative to the process cwd, so every command runs from +-- the workspace root (making paths root-relative, like git's). +local function jj_output(root, args, opts) + opts = opts or {} + return process.run(jj_cmd(root, args, opts), { env = opts.env, cwd = root }) +end + +-- Reads never need a fresh snapshot: the queue build (`jj diff`) snapshots +-- once, and worktree content is read from disk directly. +local function jj_read(root, args) + return jj_output(root, args, { ignore_working_copy = true }) +end + +local function fileset(path) + return 'root-file:"' .. path:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' +end + +local function trim(value) + return value and vim.trim(value) or "" +end + +-- jj aliases for git's "HEAD": the stage base (@--) is what git's HEAD is to +-- the index. +local function rev_of(ref) + if ref == nil or ref == "" or ref == "HEAD" then + return M.STAGE_BASE + end + return ref +end + +-- A user-supplied revision (launcher ranges, compare, rev mode): git's +-- "HEAD" — "what I have checked out" — is the working-copy commit @. +local function user_rev(ref) + if ref == nil or ref == "" or ref == "HEAD" then + return "@" + end + return ref +end + +-- --------------------------------------------------------------------------- +-- Paths and roots + +local abs_path = shared.abs_path +M.abs_path = function(root, path) + return abs_path(root, path) +end +M.relpath = function(root, path) + -- One value only: git's relpath can leak a gsub count. + return (git.relpath(root, path)) +end +M.find_loaded_buffer = function(path) + return shared.find_loaded_buffer(path) +end + +function M.find_root(start) + local dir = start + if not dir or dir == "" then + dir = (vim.uv or vim.loop).cwd() + end + dir = vim.fn.fnamemodify(dir, ":p") + if vim.fn.isdirectory(dir) ~= 1 then + dir = vim.fn.fnamemodify(dir, ":h") + end + -- `jj root` has no -C; run it from the start directory instead. + local result = vim.system({ "jj", "--no-pager", "--ignore-working-copy", "root" }, { cwd = dir, text = true }):wait() + if result.code ~= 0 then + return nil, trim(result.stderr) ~= "" and trim(result.stderr) or "not inside a jj repository" + end + local output = result.stdout + local root = trim(output) + if root == "" then + return nil, "not inside a jj repository" + end + return (vim.fn.fnamemodify(root, ":p"):gsub("/$", "")), nil +end + +function M.root(opts) + opts = opts or {} + local name = vim.api.nvim_buf_get_name(0) + local start = opts.root or opts.path or (name ~= "" and name or nil) or (vim.uv or vim.loop).cwd() + return M.find_root(start) +end + +-- --------------------------------------------------------------------------- +-- Revisions + +-- Commit ids for every commit in revset (in jj's order). +local function commit_ids(root, revset) + local output, err = jj_read(root, { "log", "--no-graph", "-r", revset, "-T", 'commit_id ++ "\\n"' }) + if not output then + return nil, err + end + return text.split_lines(output), nil +end + +-- One commit id for rev, or nil + reason when it resolves to zero or many. +function M.resolve_single(root, rev) + local ids, err = commit_ids(root, rev) + if not ids then + return nil, err + end + if #ids ~= 1 then + return nil, string.format("revision %s resolves to %d commits", rev, #ids) + end + return ids[1], nil +end + +function M.is_revision(root, rev) + if not rev or rev == "" or rev:sub(1, 1) == "-" then + return false + end + return M.resolve_single(root, rev) ~= nil +end + +-- A spec-safe name for rev: simple revsets stay readable (@-, main, +-- trunk()), anything with ":" (which would split a blob spec) becomes its +-- commit id. +local function spec_rev(root, rev) + rev = rev_of(rev) + if not rev:find(":", 1, true) then + return rev, nil + end + return M.resolve_single(root, rev) +end + +-- The parent revision diffs are taken against: a single parent, or root() +-- for a root commit. Merge commits have no single tree to stage against. +local function single_parent(root, rev) + local ids, err = commit_ids(root, "parents(" .. rev .. ")") + if not ids then + return nil, err + end + if #ids == 0 then + return "root()", nil + end + if #ids > 1 then + return nil, string.format("%s is a merge commit; diff it with an explicit base", rev) + end + -- Keep the readable revset when it names exactly that parent. + if rev == M.STAGE_REV then + return M.STAGE_BASE, nil + end + return ids[1], nil +end + +-- --------------------------------------------------------------------------- +-- Blob reads + +local function read_rev_file(root, rev, path) + return jj_read(root, { "file", "show", "-r", rev, "--", fileset(path) }) +end + +-- Git-shaped spec: ":path" (stage target), "REV:path". +local function read_blob(root, spec) + if spec:sub(1, 1) == ":" then + return read_rev_file(root, M.STAGE_REV, spec:sub(2)) + end + local rev, path = spec:match("^([^:]*):(.*)$") + if not rev then + return nil, "invalid blob spec: " .. spec + end + return read_rev_file(root, rev_of(rev), path) +end + +function M.read_index(root, path) + return read_blob(root, ":" .. path) +end + +function M.read_head(root, path, base) + return read_rev_file(root, rev_of(base), path) +end + +function M.read_worktree(root, path, use_buffer) + return shared.read_worktree(root, path, use_buffer) +end + +function M.write_worktree(root, path, value, use_buffer) + return git.write_worktree(root, path, value, use_buffer) +end + +-- --------------------------------------------------------------------------- +-- Change listing + +local status_letters = { + modified = "M", + added = "A", + removed = "D", + copied = "C", + renamed = "R", +} + +local file_type_modes = { + file = "100644", + executable = "100755", + symlink = "120000", + ["git-submodule"] = "160000", + tree = "040000", +} + +local diff_template = table.concat({ + "status", + "source.path()", + "target.path()", + "source.file_type()", + "target.file_type()", +}, ' ++ "\\x1f" ++ ') .. ' ++ "\\x1e"' + +local function range_args(opts) + return { "--from", opts.jj_from, "--to", opts.jj_to } +end + +local function append_filesets(args, root, pathspecs) + if pathspecs and #pathspecs > 0 then + args[#args + 1] = "--" + for _, spec in ipairs(pathspecs) do + if spec == "." or spec == "" then + args[#args + 1] = "all()" + else + -- Pathspecs are root-relative here (launcher/queue normalize them); + -- a directory pathspec matches everything below it. + local quoted = spec:gsub("\\", "\\\\"):gsub('"', '\\"') + args[#args + 1] = 'root:"' .. quoted .. '"' + end + end + end + return args +end + +-- Parses `jj diff -T diff_template` output into git-style entries. +local function parse_diff_entries(output) + local entries = {} + for _, record in ipairs(git._private.split_char(output or "", "\30")) do + record = record:gsub("^%s+", "") + if record ~= "" then + local fields = git._private.split_char(record, "\31") + local status = status_letters[fields[1]] or "M" + local source_type, target_type = fields[4], fields[5] + if status == "M" and source_type and target_type and source_type ~= target_type then + local function is_file(t) + return t == "file" or t == "executable" + end + if not (is_file(source_type) and is_file(target_type)) then + status = "T" + end + end + local entry = { + status = status, + raw_status = fields[1], + path = fields[3] ~= "" and fields[3] or fields[2], + modes = { + left = status ~= "A" and file_type_modes[source_type] or nil, + right = status ~= "D" and file_type_modes[target_type] or nil, + }, + } + if (status == "R" or status == "C") and fields[2] ~= fields[3] then + entry.old_path = fields[2] + end + if status == "M" and source_type ~= target_type then + entry.mode_change = true + end + entries[#entries + 1] = entry + end + end + table.sort(entries, function(a, b) + return a.path < b.path + end) + return entries +end + +local function list_changes(root, from, to, pathspecs, read_only) + local args = { "diff", "--from", from, "--to", to, "-T", diff_template } + append_filesets(args, root, pathspecs) + -- The first command of a refresh snapshots the working copy. + local output, err + if read_only then + output, err = jj_read(root, args) + else + output, err = jj_output(root, args) + end + if not output then + return nil, err + end + return parse_diff_entries(output), nil +end + +-- Conflicted paths in @ (`jj resolve --list`), as unmerged entries. +function M.unmerged_entries(root, pathspecs) + local args = { "resolve", "--list", "-r", "@" } + append_filesets(args, root, pathspecs) + -- Snapshots: a just-written resolution must be seen as resolved. + local code, stdout = process.run_exit_code(jj_cmd(root, args), { cwd = root }) + if code ~= 0 then + -- "No conflicts found" exits non-zero. + return {}, nil + end + local entries = {} + for _, line in ipairs(text.split_lines(stdout)) do + local path, sides = line:match("^(.-)%s%s+(.*)$") + path = path or vim.trim(line) + if path ~= "" then + entries[#entries + 1] = { + status = "U", + raw_status = "conflict", + path = path, + conflict_description = sides, + } + end + end + return entries, nil +end + +function M.has_unmerged(root) + local entries = M.unmerged_entries(root, {}) + return #entries > 0, nil +end + +-- --------------------------------------------------------------------------- +-- Classification readers for git/sources.lua + +local function entry_modes(queue, entry) + if entry.modes then + return entry.modes + end + entry.modes = {} + return entry.modes +end + +-- Non-nil only for mode-only changes (exec bit): sources then renders a +-- metadata entry instead of an empty text diff. +local function summary_reader(root, opts, path) + local entry = opts and opts.jj_mode_changes and opts.jj_mode_changes[path] + if entry then + return string.format("mode change %s => %s %s", entry.modes.left or "?", entry.modes.right or "?", path) + end + return nil +end + +local function numstat_reader(root, opts, path) + local args = { "diff", "--stat" } + vim.list_extend(args, range_args(opts)) + append_filesets(args, root, { path }) + local output = jj_read(root, args) + if not output then + return nil + end + -- "0 files changed, 0 insertions(+), 0 deletions(-)" for mode-only changes. + local ins = tonumber(output:match("(%d+) insertions?")) or 0 + local del = tonumber(output:match("(%d+) deletions?")) or 0 + return string.format("%d\t%d\t%s", ins, del, path) +end + +local sources = git_sources.new({ + git_summary = summary_reader, + numstat = numstat_reader, + entry_modes = entry_modes, + abs_path = abs_path, + read_blob = read_blob, + read_worktree = shared.read_worktree, + read_worktree_raw = shared.read_worktree_raw, + read_worktree_symlink = shared.read_worktree_symlink, + find_loaded_buffer = shared.find_loaded_buffer, + has_modified_loaded_buffer = shared.has_modified_loaded_buffer, +}) + +function M.sources_for_entry(queue, index) + -- Same post-processing as the git backend. + local entry = queue.entries[index] + if not entry then + return nil, "no changed file at queue index " .. tostring(index) + end + local left, left_err = sources.source_from_kind(queue, entry, "left") + if not left then + return nil, left_err + end + local right, right_err = sources.source_from_kind(queue, entry, "right") + if not right then + return nil, right_err + end + if left.git_binary_hex or right.git_binary_hex or left.git_binary_hidden or right.git_binary_hidden then + entry.content_kind = "binary" + entry.actions_enabled = false + entry.actions_disabled_reason = (left.git_binary_hidden or right.git_binary_hidden) + and "binary file is not rendered as text" + or "binary hex view is read-only" + left.git_readonly_reason = entry.actions_disabled_reason + right.git_readonly_reason = entry.actions_disabled_reason + left.editable = nil + right.editable = nil + end + return { left = left, right = right, entry = entry, index = index }, nil +end + +-- --------------------------------------------------------------------------- +-- Queues + +local function normalize_opts(opts, config) + opts = vim.tbl_extend("force", {}, config or {}, opts or {}) + local ok, state = pcall(require, "diffbandit.state") + local jj_config = opts.jj or (ok and state.get_config and (state.get_config() or {}).jj) or {} + -- git.default_mode ("all") assumes an index; jj defaults to `jj diff`. + opts.mode = opts.mode or jj_config.default_mode or "unstaged" + opts.scope = opts.scope or opts.default_scope or "repo" + if opts.mode == "cached" then + opts.mode = "staged" + end + if opts.mode == "rev" and (not opts.base or not opts.target) then + return nil, "revision mode requires both base and target revisions" + end + opts.pathspecs = opts.pathspecs or {} + opts.include_untracked = false + -- No index: files added in @ are absent from @- in every mode. + opts.index_tracks_additions = false + return opts, nil +end + +-- Resolves the jj range for opts.mode and rewrites base/target into +-- spec-safe names for git/sources.lua. +local function resolve_range(root, opts) + local mode = opts.mode + local from, to, err + if mode == "unstaged" then + from, to = M.STAGE_REV, "@" + elseif mode == "staged" then + from, err = single_parent(root, M.STAGE_REV) + to = M.STAGE_REV + elseif mode == "all" then + if opts.base and opts.base ~= "" and opts.base ~= "HEAD" then + from = user_rev(opts.base) + else + from, err = single_parent(root, M.STAGE_REV) + end + to = "@" + elseif mode == "rev" then + from, to = user_rev(opts.base), user_rev(opts.target) + else + return nil, "unsupported jj mode: " .. tostring(mode) + end + if not from then + return nil, err + end + opts.jj_from, opts.jj_to = from, to + opts.ref_labels = { + index = M.STAGE_REV, + worktree = "@ working copy", + base = mode == "rev" and opts.base or from, + } + -- sources.lua reads ":" for the left side of staged/all/rev + -- and ":" for the right side of rev. + if mode ~= "unstaged" then + local base_name, base_err = spec_rev(root, from) + if not base_name then + return nil, base_err + end + opts.base = base_name + end + if mode == "rev" then + local target_name, target_err = spec_rev(root, to) + if not target_name then + return nil, target_err + end + opts.target = target_name + end + return opts, nil +end + +function M.queue(opts, config) + local normalized, normalize_err = normalize_opts(opts, config) + if not normalized then + return nil, normalize_err + end + local uv = vim.uv or vim.loop + local name = vim.api.nvim_buf_get_name(0) + local start = normalized.root + or normalized.path + or (normalized.scope == "current" and normalized.pathspecs and normalized.pathspecs[1]) + or (name ~= "" and name or nil) + or uv.cwd() + local root, root_err = M.find_root(start) + if not root then + return nil, root_err + end + normalized.root = root + if normalized.scope == "current" then + local target = normalized.path or (normalized.pathspecs and normalized.pathspecs[1]) or (name ~= "" and name or nil) + if not target then + return nil, "no current file for jj diff" + end + normalized.pathspecs = { M.relpath(root, target) } + end + + local entries, entries_err + local working_copy_merge = false + -- The refresh's first command snapshots the working copy (reviews of @ + -- must see current files too). + local parents_output = jj_output(root, { "log", "--no-graph", "-r", "parents(@)", "-T", 'commit_id ++ "\n"' }) + if normalized.mode ~= "rev" then + working_copy_merge = #text.split_lines(parents_output or "") > 1 + end + if working_copy_merge then + -- @ is a merge (e.g. `jj new A B`): there is no single parent tree to + -- diff or stage against, so the review lists only its conflicts. + normalized.read_only = true + normalized.jj_working_copy_merge = true + entries = {} + else + local _, range_err = resolve_range(root, normalized) + if range_err then + return nil, range_err + end + entries, entries_err = list_changes(root, normalized.jj_from, normalized.jj_to, + normalized.pathspecs, normalized.read_only == true) + if not entries then + return nil, entries_err + end + end + if normalized.mode ~= "rev" then + -- Conflicted files in @ replace their diff rows. + local unmerged = M.unmerged_entries(root, normalized.pathspecs) + local by_path = {} + for index, entry in ipairs(entries) do + by_path[entry.path] = index + end + for _, conflict in ipairs(unmerged) do + if by_path[conflict.path] then + entries[by_path[conflict.path]] = conflict + else + entries[#entries + 1] = conflict + end + end + table.sort(entries, function(a, b) + return a.path < b.path + end) + end + normalized.jj_mode_changes = {} + for _, entry in ipairs(entries) do + if entry.mode_change then + normalized.jj_mode_changes[entry.path] = entry + end + end + + if #entries == 0 and normalized.scope == "current" and normalized.use_buffer ~= false then + local current_path = normalized.pathspecs and normalized.pathspecs[1] + if current_path and shared.has_modified_loaded_buffer(root, current_path) then + entries[#entries + 1] = { + status = "M", + raw_status = "buffer", + path = current_path, + buffer_only = true, + modes = {}, + } + end + end + if #entries == 0 then + if working_copy_merge then + return nil, "@ is a merge commit with no conflicts; review it with `diffbandit show @`" + end + return nil, "no jj changes" + end + + local queue = { + kind = "git", + vcs = "jj", + root = root, + opts = normalized, + entries = entries, + index = 1, + source_cache = {}, + } + + function queue.load(index) + local entry = queue.entries[index] + local cacheable = normalized.use_buffer == false or not shared.entry_uses_modified_buffer(root, entry) + if cacheable and queue.source_cache[index] then + return queue.source_cache[index], nil + end + local loaded, load_err = M.sources_for_entry(queue, index) + if loaded and cacheable then + queue.source_cache[index] = loaded + end + return loaded, load_err + end + + return queue, nil +end + +-- --------------------------------------------------------------------------- +-- History + +local log_template = table.concat({ + "commit_id", + "commit_id.short()", + 'parents.map(|p| p.commit_id()).join(" ")', + "author.name()", + 'author.timestamp().format("%Y-%m-%d")', + "description.first_line()", + "change_id.short()", + 'bookmarks.join(" ")', + 'if(conflict, "conflict", "")', + 'if(empty, "empty", "")', + 'if(immutable, "immutable", "")', + 'if(current_working_copy, "@", "")', + "description", +}, ' ++ "\\x1f" ++ ') .. ' ++ "\\x1e"' + +local function parse_log(output) + local commits = {} + for _, record in ipairs(git._private.split_char(output or "", "\30")) do + record = record:gsub("^\n", "") + if record ~= "" then + local f = git._private.split_char(record, "\31") + commits[#commits + 1] = { + hash = f[1], + short_hash = f[2], + parents = vim.split(f[3] or "", " ", { plain = true, trimempty = true }), + author = f[4], + date = f[5], + subject = (f[6] ~= "" and f[6]) or "(no description set)", + change_id = f[7], + bookmarks = vim.split(f[8] or "", " ", { plain = true, trimempty = true }), + conflict = f[9] == "conflict", + empty = f[10] == "empty", + immutable = f[11] == "immutable", + working_copy = f[12] == "@", + description = f[13] or "", + } + end + end + return commits +end + +-- opts: { rev (revset), all, max_count, pathspecs }. Without a revset jj's +-- own `revsets.log` default applies. +function M.log(root, opts) + opts = opts or {} + local args = { "log", "--no-graph", "-T", log_template } + local revset = opts.rev + if opts.all then + revset = "all()" + end + if revset and revset ~= "" then + args[#args + 1] = "-r" + args[#args + 1] = revset + end + if opts.max_count then + args[#args + 1] = "--limit" + args[#args + 1] = tostring(opts.max_count) + end + append_filesets(args, root, opts.pathspecs or {}) + local output, err = jj_output(root, args) + if not output then + return nil, err + end + return parse_log(output), nil +end + +function M.commit_metadata(root, rev) + local commits, err = M.log(root, { rev = user_rev(rev), max_count = 2 }) + if not commits then + return nil, err + end + if #commits ~= 1 then + return nil, string.format("revision %s resolves to %d commits", tostring(rev), #commits) + end + return commits[1], nil +end + +local function review_queue_opts(opts, review) + local next_opts = vim.tbl_extend("force", {}, opts or {}) + next_opts.mode = "rev" + next_opts.read_only = true + next_opts.include_untracked = false + next_opts.review = review + return next_opts +end + +function M.commit_queue(root, rev, opts, config) + if not rev or rev == "" then + return nil, "commit revision is required" + end + local meta, meta_err = M.commit_metadata(root, rev) + if not meta then + return nil, meta_err + end + local base = #meta.parents == 1 and meta.parents[1] or nil + if #meta.parents == 0 then + base = "root()" + end + if not base then + return nil, "merge commits are not supported in commit review yet" + end + local queue_opts = review_queue_opts(vim.tbl_extend("force", opts or {}, { + root = root, + base = base, + target = meta.hash, + }), { + kind = "commit", + title = (meta.change_id or meta.short_hash or rev) .. " " .. (meta.subject or ""), + commit = meta, + base = base, + target = meta.hash, + }) + return M.queue(queue_opts, config) +end + +function M.merge_base(root, base, target) + return M.resolve_single(root, string.format("heads(::(%s) & ::(%s))", base, target)) +end + +function M.compare_queue(root, base, target, opts, config) + opts = opts or {} + base = user_rev(base) + target = user_rev(target) + local compare_base = base + if opts.direct ~= true then + local merge_base, err = M.merge_base(root, base, target) + if not merge_base then + return nil, err + end + compare_base = merge_base + end + local queue_opts = review_queue_opts(vim.tbl_extend("force", opts, { + root = root, + base = compare_base, + target = target, + }), { + kind = "compare", + title = string.format("%s..%s", base, target), + base = compare_base, + requested_base = base, + target = target, + direct = opts.direct == true, + }) + return M.queue(queue_opts, config) +end + +-- Bookmarks, shaped like git branches (name, current = on @ or @-). +function M.list_branches(root) + local output, err = jj_read(root, { + "bookmark", "list", "--all-remotes", "-T", + 'if(remote, name ++ "@" ++ remote, name) ++ "\\x1f" ++ if(remote, "remote", "") ++ "\\x1f" ++ ' + .. 'if(normal_target, normal_target.commit_id().short(), "") ++ "\\n"', + }) + if not output then + return nil, err + end + local near = {} + for _, id in ipairs(commit_ids(root, "@ | @-") or {}) do + near[id:sub(1, 12)] = true + end + local branches = {} + local seen = {} + for _, line in ipairs(text.split_lines(output)) do + local name, remote, oid = line:match("^([^\31]*)\31([^\31]*)\31(.*)$") + if name and name ~= "" and not name:match("@git$") and not seen[name] then + seen[name] = true + branches[#branches + 1] = { + refname = name, + name = name, + oid = oid ~= "" and oid or nil, + current = oid ~= "" and near[oid:sub(1, 12)] == true, + remote = remote == "remote", + } + end + end + table.sort(branches, function(a, b) + if a.current ~= b.current then + return a.current == true + end + return a.name < b.name + end) + return branches, nil +end + +-- "Checkout" in jj: start a new change on top of rev (the working copy is +-- always committed, so nothing can be lost). +function M.checkout_branch(root, rev, opts) + opts = opts or {} + if not rev or rev == "" then + return false, "revision is required" + end + local args = opts.edit and { "edit", user_rev(rev) } or { "new", user_rev(rev) } + local _, err = jj_output(root, args) + return err == nil, err +end + +function M.is_worktree_dirty() + return false, nil +end + +-- --------------------------------------------------------------------------- +-- Staging: the write primitive + +local write_tool_path + +-- A tiny diff-editor "tool" that jj runs non-interactively: it copies the +-- desired contents into $right/ (or removes the file). +local function ensure_write_tool() + if write_tool_path and vim.fn.executable(write_tool_path) == 1 then + return write_tool_path + end + local dir = vim.fn.stdpath("cache") .. "/diffbandit" + vim.fn.mkdir(dir, "p") + local path = dir .. "/jj-write-tool.sh" + vim.fn.writefile({ + "#!/bin/sh", + "# Written by diffbandit.nvim: non-interactive jj diff editor.", + 'target="$1/$DIFFBANDIT_JJ_PATH"', + 'if [ -n "$DIFFBANDIT_JJ_SRC" ]; then', + ' mkdir -p "$(dirname "$target")" && cp "$DIFFBANDIT_JJ_SRC" "$target"', + "else", + ' rm -f "$target"', + "fi", + }, path) + vim.fn.setfperm(path, "rwxr-xr-x") + write_tool_path = path + return path +end + +local function toml_string(value) + return '"' .. value:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' +end + +-- Sets path's content in revision `to` to value (nil deletes it), keeping +-- every descendant's content unchanged. `from` must differ from `to` at +-- path (jj only materializes changed paths for the editor); the other side +-- of the diff being edited always does. +function M.write_rev_file(root, to, path, value, from) + local tool = ensure_write_tool() + local src = "" + if value ~= nil then + src = vim.fn.tempname() + local fd = io.open(src, "wb") + if not fd then + return false, "unable to write temp file" + end + fd:write(value) + fd:close() + end + local args = { + "diffedit", "--from", from, "--to", to, "--restore-descendants", + "--tool", "diffbandit-write", + "--config", "merge-tools.diffbandit-write.program=" .. toml_string(tool), + "--config", 'merge-tools.diffbandit-write.edit-args=["$right"]', + "--config", "ui.diff-instructions=false", + "--", fileset(path), + } + local _, err = jj_output(root, args, { + env = { DIFFBANDIT_JJ_SRC = src, DIFFBANDIT_JJ_PATH = path }, + }) + if src ~= "" then + pcall(os.remove, src) + end + return err == nil, err +end + +-- What @- is to the staging model: +-- "staging" mutable and undescribed: the change being built (stage here) +-- "commit" a described, mutable commit: only amend mode changes it +-- "immutable" never written +-- nil @ has no single parent +local function stage_target(root) + local commits = M.log(root, { rev = M.STAGE_REV, max_count = 2 }) or {} + if #commits ~= 1 then + return nil, nil + end + local commit = commits[1] + if commit.immutable then + return "immutable", commit + end + if vim.trim(commit.description or "") == "" then + return "staging", commit + end + return "commit", commit +end + +local NO_PARENT = "the working copy has no single parent to stage into" +local NEEDS_STAGING = "@- is a described commit: turn on amend to change it, " + .. "or stage from the unstaged view to start a staging change" + +-- Gate for stage/unstage (session hunk actions and panel file actions). +-- Staging from the unstaged view creates the staging change on demand: +-- it has @-'s tree, so the open diff stays valid. +function M.prepare_stage(root, opts, action) + opts = opts or {} + local role = stage_target(root) + if role == "staging" or (role == "commit" and opts.amend_mode) then + return true, nil + end + if role == nil then + return false, NO_PARENT + end + if action == "unstage" or (opts.mode or "unstaged") ~= "unstaged" then + if role == "immutable" then + return false, "@- is immutable; create a staging change first (jj new --insert-before @ --no-edit)" + end + return false, NEEDS_STAGING + end + local _, err = jj_output(root, { "new", "--insert-before", "@", "--no-edit" }) + if err then + return false, err + end + vim.schedule(function() + vim.notify("DiffBandit: started an empty staging change under @ (jj new --insert-before @)", vim.log.levels.INFO) + end) + return true, nil +end + +local function stage_target_error(root) + local role = stage_target(root) + if role == nil then + return NO_PARENT + end + if role == "immutable" then + return "@- is immutable; create a staging change first (jj new --insert-before @ --no-edit)" + end + return nil +end + +-- The index slot: hunk staging writes @-; unstaging writes @- back toward +-- @--. Either way @ keeps its content. +function M.write_index(root, path, value) + local err = stage_target_error(root) + if err then + return false, err + end + -- @ differs from @- at path whenever staging (moving @'s hunk in); + -- @-- differs from @- whenever unstaging. Pick the side that differs. + local from = "@" + local current = read_rev_file(root, M.STAGE_REV, path) + local worktree = shared.read_worktree_raw(root, path) + if current ~= nil and worktree ~= nil and current == worktree then + from = M.STAGE_BASE + elseif current == nil and worktree == nil then + from = M.STAGE_BASE + end + return M.write_rev_file(root, M.STAGE_REV, path, value, from) +end + +function M.has_staged_changes(root, path, base) + local args = { "diff", "--from", rev_of(base), "--to", M.STAGE_REV, "--name-only" } + if path then + append_filesets(args, root, { path }) + end + local output, err = jj_read(root, args) + if not output then + return false, err + end + return vim.trim(output) ~= "", nil +end + +function M.has_any_staged_changes(root) + return (M.has_staged_changes(root, nil, nil)) +end + +local function name_set(root, from, to) + local output = jj_read(root, { "diff", "--from", from, "--to", to, "--name-only" }) + local set = {} + for _, line in ipairs(text.split_lines(output or "")) do + set[line] = true + end + return set +end + +function M.file_stage_states(root, entries, opts) + -- A described @- is the previous commit, not staged work (unless amending). + local role = stage_target(root) + local staged = {} + if role == "staging" or (role == "commit" and opts and opts.amend_mode) then + staged = name_set(root, M.STAGE_BASE, M.STAGE_REV) + end + local unstaged = name_set(root, M.STAGE_REV, "@") + local states = {} + for index, entry in ipairs(entries or {}) do + local s = staged[entry.path] or (entry.old_path and staged[entry.old_path]) + local u = unstaged[entry.path] or (entry.old_path and unstaged[entry.old_path]) + if s and u then + states[index] = "partial" + elseif s then + states[index] = "staged" + else + states[index] = "unstaged" + end + end + return states +end + +function M.file_stage_state(root, entry, opts) + return M.file_stage_states(root, { entry }, opts)[1] or "unstaged" +end + +local function entry_filesets(root, entry) + local args = { "--" } + if entry.old_path and entry.old_path ~= entry.path then + args[#args + 1] = fileset(entry.old_path) + end + args[#args + 1] = fileset(entry.path) + return args +end + +function M.stage_file(root, entry, opts) + local ready, err = M.prepare_stage(root, opts, "stage") + if not ready then + return false, err + end + local args = { "squash", "--from", "@", "--into", M.STAGE_REV, "--keep-emptied", "--use-destination-message" } + vim.list_extend(args, entry_filesets(root, entry)) + local _, run_err = jj_output(root, args) + return run_err == nil, run_err +end + +function M.unstage_file(root, entry, opts) + local ready, prep_err = M.prepare_stage(root, opts, "unstage") + if not ready then + return false, prep_err + end + local args = { "restore", "--from", M.STAGE_BASE, "--into", M.STAGE_REV, "--restore-descendants" } + vim.list_extend(args, entry_filesets(root, entry)) + local _, err = jj_output(root, args) + return err == nil, err +end + +function M.toggle_file_stage(root, entry, opts) + local state = M.file_stage_state(root, entry, opts) + if state == "staged" or state == "partial" then + return M.unstage_file(root, entry, opts) + end + return M.stage_file(root, entry, opts) +end + +function M.toggle_file_stage_async(root, entry, opts, state, callback) + vim.schedule(function() + if state == "staged" or state == "partial" then + callback(M.unstage_file(root, entry, opts)) + elseif state then + callback(M.stage_file(root, entry, opts)) + else + callback(M.toggle_file_stage(root, entry, opts)) + end + end) +end + +-- Discard = restore @'s file from @- (the working copy follows). +function M.discard_worktree_file(root, entry) + if not entry or not entry.path then + return false, "no file entry to discard" + end + local args = { "restore", "--from", M.STAGE_REV, "--into", "@" } + vim.list_extend(args, entry_filesets(root, entry)) + local _, err = jj_output(root, args) + if err == nil then + -- Reload buffers showing the restored file. + vim.schedule(function() + pcall(vim.cmd, "checktime") + end) + end + return err == nil, err +end + +function M.delete_untracked_file() + return false, "jj tracks new files automatically; discard the change instead" +end + +function M.append_gitignore(root, pattern) + return git.append_gitignore(root, pattern) +end + +-- --------------------------------------------------------------------------- +-- Commit + +-- Amend's message: @-'s description. +function M.last_commit_message(root) + local _, commit = stage_target(root) + local description = commit and commit.description or "" + if vim.trim(description) == "" then + return nil + end + return (description:gsub("\n+$", "")) +end + +-- Amend edits a described @- in place (diffing it against @--). Called +-- when amend mode turns on: an empty staging change left by the last commit +-- is abandoned first, so the commit just made becomes @- again. +function M.amend_base(root) + local role, commit = stage_target(root) + if role == "staging" and commit.empty then + local parents = M.log(root, { rev = "parents(" .. M.STAGE_REV .. ")", max_count = 2 }) or {} + local parent = parents[1] + if #parents == 1 and not parent.immutable and vim.trim(parent.description) ~= "" then + local _, err = jj_output(root, { "abandon", M.STAGE_REV }) + if err then + return nil, err + end + role = "commit" + end + end + if role == "commit" then + return M.STAGE_BASE, nil + elseif role == "staging" then + return nil, "@- holds staged changes: commit them before amending" + elseif role == "immutable" then + return nil, "@- is immutable and cannot be amended" + end + return nil, NO_PARENT +end + +local function has_working_copy_changes(root) + local output = jj_read(root, { "diff", "--from", M.STAGE_REV, "--to", "@", "--name-only" }) + return output ~= nil and vim.trim(output) ~= "" +end + +-- Commit, by @-'s role: +-- amend describe @- (its content was edited by staging) +-- staging change describe it and open a fresh staging change above it; +-- with nothing staged, all of @ goes in first +-- anything else `jj commit` (describe @, start a new @) +function M.commit(root, message, opts) + opts = opts or {} + message = message or "" + if vim.trim(message) == "" then + return false, "commit message cannot be empty" + end + if M.has_unmerged(root) then + return false, "merge conflicts must be resolved before committing" + end + local role = stage_target(root) + if opts.amend then + if role ~= "commit" then + return false, select(2, M.amend_base(root)) + end + local _, err = jj_output(root, { "describe", "-r", M.STAGE_REV, "-m", message }) + return err == nil, err + end + if role == "staging" then + if not M.has_any_staged_changes(root) then + if not has_working_copy_changes(root) then + return false, "nothing to commit" + end + local _, squash_err = jj_output(root, { + "squash", "--from", "@", "--into", M.STAGE_REV, "--keep-emptied", "--use-destination-message", + }) + if squash_err then + return false, squash_err + end + end + local _, describe_err = jj_output(root, { "describe", "-r", M.STAGE_REV, "-m", message }) + if describe_err then + return false, describe_err + end + local _, new_err = jj_output(root, { "new", "--insert-after", M.STAGE_REV, "--no-edit" }) + return new_err == nil, new_err + end + if not has_working_copy_changes(root) then + return false, "nothing to commit" + end + local _, err = jj_output(root, { "commit", "-m", message }) + return err == nil, err +end + +-- --------------------------------------------------------------------------- +-- Conflicts + +-- Splits a file materialized with ui.conflict-marker-style=git into its +-- base/left/right texts. Only two-sided conflicts are representable that +-- way; anything else returns nil + reason. +function M.parse_git_style_conflict(content) + local sides = { base = {}, left = {}, right = {} } + local labels = {} + local section + local marker_len + local saw_conflict = false + local lines = vim.split(content or "", "\n", { plain = true }) + local trailing_newline = lines[#lines] == "" + if trailing_newline then + lines[#lines] = nil + end + local function push(targets, line) + for _, name in ipairs(targets) do + local list = sides[name] + list[#list + 1] = line + end + end + for _, line in ipairs(lines) do + local open = line:match("^(<<<<<<<+)") + if not section and open then + section = "left" + marker_len = #open + saw_conflict = true + labels.left = labels.left or vim.trim(line:sub(marker_len + 1)) + elseif section and line:match("^" .. string.rep("|", marker_len) .. "%f[^|]") then + section = "base" + elseif section and line:match("^" .. string.rep("=", marker_len) .. "$") then + section = "right" + elseif section and line:match("^" .. string.rep(">", marker_len) .. "%f[^>]") then + if section ~= "right" then + return nil, "conflict markers are malformed" + end + labels.right = labels.right or vim.trim(line:sub(marker_len + 1)) + section = nil + elseif section then + if section == "left" and line:match("^" .. string.rep("<", marker_len)) then + return nil, "nested conflict markers" + end + push({ section }, line) + else + push({ "base", "left", "right" }, line) + end + end + if section then + return nil, "unterminated conflict" + end + if not saw_conflict then + return nil, "no conflict markers found" + end + local function join(list) + if #list == 0 then + return "" + end + return table.concat(list, "\n") .. "\n" + end + return { + base = join(sides.base), + local_text = join(sides.left), + remote = join(sides.right), + has_local = true, + has_remote = true, + labels = labels, + }, nil +end + +function M.conflict_stages(root, path) + local listed = M.unmerged_entries(root, { path }) + local description = listed[1] and listed[1].conflict_description or "" + if #listed == 0 then + return nil, "no jj conflict at " .. tostring(path) + end + local sides_count = tonumber(description:match("(%d+)%-sided")) + if sides_count and sides_count > 2 then + return nil, string.format("%s has a %d-sided conflict; resolve it with `jj resolve`", path, sides_count) + end + if description:find("deletion", 1, true) or description:find("non-file", 1, true) then + -- e.g. "2-sided conflict including 1 deletion": no three texts to merge. + return nil, path .. ": " .. description .. "; resolve it with `jj resolve`" + end + local output, err = jj_read(root, { + "file", "show", "-r", "@", + "--config", "ui.conflict-marker-style=git", + "--", fileset(path), + }) + if not output then + return nil, err + end + return M.parse_git_style_conflict(output) +end + +-- Header labels: jj's markers name each side (" "desc""). +function M.merge_context(_, data) + local labels = data and data.labels or {} + return { + operation = "jj conflict", + current = labels.left or "side #1", + incoming = labels.right or "side #2", + } +end + +-- The merge host for jj: resolving writes the working-copy file; jj's next +-- snapshot records the resolution (no `git add`). +function M.merge_host() + return { + kind = "jj", + context = function(root, data) + return M.merge_context(root, data) + end, + result_path = function(root, path) + return abs_path(root, path) + end, + resolve = function(session, value) + if value == nil then + return false, "deleting a conflicted file: use `jj restore` or `jj resolve` instead" + end + return git.write_worktree(session.root, session.path, value, false) + end, + after_resolve = function(session) + session:refresh_git_queue(session.path) + end, + } +end + +return M diff --git a/lua/diffbandit/merge/init.lua b/lua/diffbandit/merge/init.lua index 7518e78..9b8ee4e 100644 --- a/lua/diffbandit/merge/init.lua +++ b/lua/diffbandit/merge/init.lua @@ -145,7 +145,18 @@ function Merge.load(root, path, config) if not stages then return nil, err end - return Merge.data_from_texts(root, path, stages, config) + local data = Merge.data_from_texts(root, path, stages, config) + data.labels = stages.labels + return data +end + +-- The backend's own merge host when it has one (jj), else git's. +function Merge.host_for_root(root) + local backend = root and vcs.for_root(root) + if backend and type(backend.merge_host) == "function" then + return backend.merge_host() + end + return Merge.git_host() end local function read_file_text(path, what) @@ -223,7 +234,7 @@ function Merge.start(data, config, opts) self.current_conflict = #self.conflicts > 0 and 1 or 0 self.selected_pair_hunk = nil self.line_ending_warning = data.line_ending_warning - self.host = opts.host or Merge.git_host() + self.host = opts.host or Merge.host_for_root(data.root) self.file_queue = opts.queue self.file_queue_index = opts.queue_index or (opts.queue and opts.queue.index) self.right_win = nil @@ -258,7 +269,7 @@ function Merge.start(data, config, opts) -- mirrored in self.panel.visible by panel_mod. self.hidden = { panel = false, local_pane = false, remote_pane = false } self.status_enabled = config_mod.section(self.config, "ui", "status").enabled ~= false - self.merge_context = self.host.context(self.root) + self.merge_context = self.host.context(self.root, data) self.connector_width = kitty.min_core_width(self) local ft = source_mod.detect_filetype(self.path) @@ -1901,7 +1912,7 @@ function Merge:open_merge_file(index, opts) self.remote_hunks = data.remote_hunks or {} self.current_conflict = #self.conflicts > 0 and 1 or 0 self.line_ending_warning = data.line_ending_warning - self.merge_context = self.host.context(self.root) + self.merge_context = self.host.context(self.root, data) local ft = source_mod.detect_filetype(self.path) local old_result_editable = self.result_editable diff --git a/lua/diffbandit/session/actions.lua b/lua/diffbandit/session/actions.lua index 9d10857..27c5e96 100644 --- a/lua/diffbandit/session/actions.lua +++ b/lua/diffbandit/session/actions.lua @@ -293,6 +293,12 @@ function M.stage(session) if mode ~= "unstaged" and mode ~= "all" then return false, "stage hunk is only available in unstaged or all Git diffs" end + -- Backends without an index (jj) may need to set up a stage target; + -- git has no prepare_stage (the proxy returns nil). + local ready, prep_err = git_mod.prepare_stage(ctx.root, ctx.opts, "stage") + if ready == false then + return false, prep_err + end return apply_sides(session, "right", "left", { action = "stage" }) end @@ -309,6 +315,10 @@ function M.unstage(session) if mode ~= "staged" and mode ~= "all" then return false, "unstage hunk is only available in staged or all Git diffs" end + local ready, prep_err = git_mod.prepare_stage(ctx.root, ctx.opts, "unstage") + if ready == false then + return false, prep_err + end if mode == "all" then local visible_hunk = active_hunk(session) local staged_hunk = visible_hunk and staged_hunk_for_active_hunk(session, ctx, visible_hunk) diff --git a/lua/diffbandit/util/process.lua b/lua/diffbandit/util/process.lua index 914c302..93b3b53 100644 --- a/lua/diffbandit/util/process.lua +++ b/lua/diffbandit/util/process.lua @@ -1,8 +1,14 @@ local M = {} -function M.run(cmd) +-- opts.env: extra environment variables; opts.cwd: working directory +-- (vim.system only). +function M.run(cmd, opts) if vim.system then - local result = vim.system(cmd, { text = false }):wait() + local result = vim.system(cmd, { + text = false, + env = opts and opts.env or nil, + cwd = opts and opts.cwd or nil, + }):wait() if result.code ~= 0 then return nil, vim.trim(result.stderr or result.stdout or "") end @@ -33,9 +39,9 @@ function M.run_async(cmd, callback) return false end -function M.run_exit_code(cmd) +function M.run_exit_code(cmd, opts) if vim.system then - local result = vim.system(cmd, { text = true }):wait() + local result = vim.system(cmd, { text = true, cwd = opts and opts.cwd or nil }):wait() return result.code, result.stdout or "", result.stderr or "" end diff --git a/lua/diffbandit/util/status.lua b/lua/diffbandit/util/status.lua index 24229ab..2f03cc1 100644 --- a/lua/diffbandit/util/status.lua +++ b/lua/diffbandit/util/status.lua @@ -171,7 +171,8 @@ function M.build(session) local staged = tostring(count_staged_chunks(session)) .. "/" .. tostring(#(session.view and session.view.chunks or {})) - center_parts[#center_parts + 1] = icons.git .. ":" .. mode + local vcs_name = (session.file_queue or {}).vcs + center_parts[#center_parts + 1] = icons.git .. ":" .. (vcs_name and vcs_name ~= "git" and (vcs_name .. " ") or "") .. mode center_parts[#center_parts + 1] = icons.file .. " " .. file_pos center_parts[#center_parts + 1] = icons.hunk .. " " .. hunk_pos if status and status ~= "" then diff --git a/lua/diffbandit/vcs/init.lua b/lua/diffbandit/vcs/init.lua index 60488ce..fc061ad 100644 --- a/lua/diffbandit/vcs/init.lua +++ b/lua/diffbandit/vcs/init.lua @@ -12,6 +12,7 @@ local M = {} local backend_modules = { git = "diffbandit.git", + jj = "diffbandit.jj", } local uv = vim.uv or vim.loop diff --git a/tests/run.lua b/tests/run.lua index e32d3c4..86cdf57 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -44,6 +44,7 @@ local specs = { "35_render_recovery.lua", "40_oracle.lua", "45_kitty.lua", + "50_jj.lua", } -- DIFFBANDIT_SPECS=20,50 runs only the matching specs (00_harness always diff --git a/tests/specs/50_jj.lua b/tests/specs/50_jj.lua new file mode 100644 index 0000000..a0908b5 --- /dev/null +++ b/tests/specs/50_jj.lua @@ -0,0 +1,485 @@ +-- Suite: jj (Jujutsu) backend against real jj repositories. Skipped when jj +-- is not installed. + +if vim.fn.executable("jj") ~= 1 then + print("SKIP jj specs: jj is not installed") +else + local vcs = require("diffbandit.vcs") + local jj_mod = require("diffbandit.jj") + + local saved_jj_config = vim.env.JJ_CONFIG + local jj_config_path = vim.fn.tempname() .. ".toml" + vim.fn.writefile({ + "[user]", + 'name = "DiffBandit Test"', + 'email = "diffbandit@example.com"', + }, jj_config_path) + vim.env.JJ_CONFIG = jj_config_path + + local function jj_run(args, cwd) + local cmd = { "jj", "--no-pager", "--color=never" } + vim.list_extend(cmd, args) + local result = vim.system(cmd, { cwd = cwd, text = true }):wait() + assert_eq(result.code, 0, "jj " .. table.concat(args, " ") .. " should succeed: " .. tostring(result.stderr)) + return result.stdout or "" + end + + local function jj_status(args, cwd) + local cmd = { "jj", "--no-pager", "--color=never" } + vim.list_extend(cmd, args) + return vim.system(cmd, { cwd = cwd, text = true, timeout = 30000 }):wait() + end + + local function make_jj_repo(opts) + local repo = vim.fn.tempname() + vim.fn.mkdir(repo, "p") + local args = { "git", "init" } + if opts and opts.colocate then + args[#args + 1] = "--colocate" + end + args[#args + 1] = "." + jj_run(args, repo) + return repo + end + + local function write(repo, path, lines) + vim.fn.mkdir(vim.fn.fnamemodify(repo .. "/" .. path, ":h"), "p") + vim.fn.writefile(lines, repo .. "/" .. path) + end + + local function show(repo, rev, path) + return jj_run({ "file", "show", "-r", rev, "--", 'root-file:"' .. path .. '"' }, repo) + end + + local function make_action_session(queue) + local loaded = select(1, queue.load(queue.index or 1)) + local hunks, err = diff.compute_hunks(loaded.left.text, loaded.right.text, config.diff) + assert_eq(err, nil, "jj action session diff should compute") + local v = view.build(loaded.left.lines, loaded.right.lines, hunks, config) + local fake = setmetatable({ + config = config, + file_queue = queue, + file_queue_index = queue.index or 1, + left = loaded.left, + right = loaded.right, + hunks = hunks, + view = v, + current_chunk = v.chunks[1] and 1 or 0, + }, Session) + function fake:replace_sources(sources, opts) + local next_hunks = diff.compute_hunks(sources.left.text, sources.right.text, config.diff) + self.left = sources.left + self.right = sources.right + self.hunks = next_hunks + self.view = view.build(sources.left.lines, sources.right.lines, next_hunks, config) + self.current_chunk = self.view.chunks[1] and math.min((opts and opts.preferred_chunk) or 1, #self.view.chunks) or 0 + return true, nil + end + return fake + end + + -- Detection: jj workspaces (including colocated ones) route to jj. + do + local repo = make_jj_repo() + assert_eq((vcs.detect(repo)), "jj", "A jj workspace routes to the jj backend") + vim.fn.mkdir(repo .. "/sub/dir", "p") + assert_eq((vcs.detect(repo .. "/sub/dir")), "jj", "Subdirectories of a jj workspace route to jj") + assert_eq(vcs.find_root(repo .. "/sub/dir"), (vim.uv or vim.loop).fs_realpath(repo), "jj root resolves from a subdirectory") + local colocated = make_jj_repo({ colocate = true }) + assert_eq((vcs.detect(colocated)), "jj", "Colocated repos prefer jj") + local saved = state_mod.get_config() + state_mod.set_config(vim.tbl_deep_extend("force", saved, { vcs = { prefer = "git" } })) + assert_eq((vcs.detect(colocated)), "git", "vcs.prefer = git forces the git backend") + state_mod.set_config(saved) + local plain = make_git_repo() + assert_eq((vcs.detect(plain)), "git", "Plain git repos route to git") + end + + -- Queues: unstaged (@- -> @) lists modify/rename/add/delete with jj labels. + local repo = make_jj_repo() + write(repo, "f.txt", { "one", "two", "three", "four", "five" }) + write(repo, "keep.txt", { "keep" }) + write(repo, "gone.txt", { "gone" }) + jj_run({ "commit", "-m", "base" }, repo) + -- Squash workflow: an empty staging change between the base and @. + jj_run({ "new", "--insert-before", "@", "--no-edit" }, repo) + write(repo, "f.txt", { "one", "TWO", "three", "four", "FIVE" }) + os.rename(repo .. "/keep.txt", repo .. "/kept.txt") + os.remove(repo .. "/gone.txt") + write(repo, "new.txt", { "new" }) + + do + local queue = assert((vcs.queue({ root = repo }, config.git))) + assert_eq(queue.vcs, "jj", "jj queues say which backend built them") + assert_eq(queue.kind, "git", "jj queues keep the repository-queue kind") + assert_eq(queue.opts.mode, "unstaged", "jj defaults to the unstaged (@- -> @) view") + local by_path = {} + for index, entry in ipairs(queue.entries) do + by_path[entry.path] = { entry = entry, index = index } + end + assert_eq(by_path["f.txt"].entry.status, "M", "Modified file") + assert_eq(by_path["kept.txt"].entry.status, "R", "Renamed file") + assert_eq(by_path["kept.txt"].entry.old_path, "keep.txt", "Rename keeps its source path") + assert_eq(by_path["gone.txt"].entry.status, "D", "Deleted file") + assert_eq(by_path["new.txt"].entry.status, "A", "Added file") + + local loaded = assert((queue.load(by_path["f.txt"].index))) + assert_eq(loaded.left.text, "one\ntwo\nthree\nfour\nfive\n", "Unstaged left side reads @-") + assert_eq(loaded.left.label, "f.txt (@-)", "Left label names @-") + assert_eq(loaded.right.git_target, "worktree", "Right side is the working copy") + assert_eq(loaded.right.editable ~= nil, true, "The working copy side is editable") + loaded = assert((queue.load(by_path["new.txt"].index))) + assert_eq(loaded.left.git_target, "absent", "Added files are absent on the left without an index") + loaded = assert((queue.load(by_path["gone.txt"].index))) + assert_eq(loaded.right.git_target, "absent", "Deleted files are absent on the right") + end + + -- Hunk staging moves the hunk into @- and leaves @ untouched. + do + local queue = assert((vcs.queue({ root = repo, mode = "unstaged", pathspecs = { "f.txt" } }, config.git))) + assert_eq(#queue.entries, 1, "Pathspec narrows the jj queue") + local session = make_action_session(queue) + assert_eq(#session.view.chunks, 2, "Two separated hunks") + local ok, err = actions_mod.stage(session) + assert_eq(err, nil, "jj stage hunk should not error") + assert_eq(ok, true, "jj stage hunk should succeed") + assert_eq(show(repo, "@-", "f.txt"), "one\nTWO\nthree\nfour\nfive\n", "Staging writes only the hunk into @-") + assert_eq(show(repo, "@", "f.txt"), "one\nTWO\nthree\nfour\nFIVE\n", "Staging keeps @'s content") + assert_eq(table.concat(read_file(repo .. "/f.txt"), "|"), "one|TWO|three|four|FIVE", "Staging does not touch the working copy") + assert_eq(vcs.file_stage_state(repo, { path = "f.txt" }), "partial", "Half-staged file is partial") + + ok, err = actions_mod.undo(session) + assert_eq(err, nil, "jj undo stage should not error") + assert_eq(show(repo, "@-", "f.txt"), "one\ntwo\nthree\nfour\nfive\n", "Undo restores @-") + assert_eq(show(repo, "@", "f.txt"), "one\nTWO\nthree\nfour\nFIVE\n", "Undo keeps @") + end + + -- all mode (@-- -> @) unstages a hunk that is in @-. + do + assert(vcs.write_index(repo, "f.txt", "one\nTWO\nthree\nfour\nfive\n")) + local queue = assert((vcs.queue({ root = repo, mode = "all", pathspecs = { "f.txt" } }, config.git))) + assert_eq(queue.opts.base, "@--", "all mode diffs from @--") + local session = make_action_session(queue) + local states = actions_mod.staged_chunk_states(session) + assert_eq(states[1], true, "The staged hunk is marked staged in all mode") + assert_eq(states[2] == true, false, "The unstaged hunk is not") + local ok, err = actions_mod.unstage(session) + assert_eq(err, nil, "jj unstage hunk should not error") + assert_eq(ok, true, "jj unstage hunk should succeed") + assert_eq(show(repo, "@-", "f.txt"), "one\ntwo\nthree\nfour\nfive\n", "Unstaging moves the hunk back out of @-") + assert_eq(show(repo, "@", "f.txt"), "one\nTWO\nthree\nfour\nFIVE\n", "Unstaging keeps @") + end + + -- File-level stage/unstage and discard. + do + local ok, err = vcs.stage_file(repo, { path = "new.txt" }) + assert_eq(err, nil, "Stage file should not error") + assert_eq(show(repo, "@-", "new.txt"), "new\n", "Stage file squashes it into @-") + assert_eq(vcs.file_stage_state(repo, { path = "new.txt" }), "staged", "Fully staged file") + ok, err = vcs.unstage_file(repo, { path = "new.txt" }) + assert_eq(err, nil, "Unstage file should not error") + assert_eq(jj_status({ "file", "show", "-r", "@-", "--", 'root-file:"new.txt"' }, repo).code ~= 0, true, + "Unstage file removes it from @-") + assert_eq(show(repo, "@", "new.txt"), "new\n", "Unstage file keeps it in @") + ok, err = vcs.discard_worktree_file(repo, { path = "gone.txt" }) + assert_eq(ok, true, "Discard restores a deleted file: " .. tostring(err)) + assert_eq(read_file(repo .. "/gone.txt")[1], "gone", "Discarded deletion is back on disk") + end + + -- Commit with a staging change: describe it and open a fresh one; with + -- nothing staged, all of @ is committed through it. + do + assert(vcs.write_index(repo, "f.txt", "one\nTWO\nthree\nfour\nfive\n")) + assert_eq(vcs.last_commit_message(repo), nil, "An undescribed staging change has no amend message") + local _, amend_err = vcs.amend_base(repo) + assert_eq(amend_err, "@- holds staged changes: commit them before amending", "Amend refuses over staged work") + local ok, err = vcs.commit(repo, "add TWO") + assert_eq(err, nil, "jj commit should not error") + assert_eq(ok, true, "jj commit should succeed") + local log = assert(vcs.log(repo, { rev = "::@", max_count = 4 })) + assert_eq(log[1].working_copy, true, "@ is still the working copy") + assert_eq(log[2].subject, "(no description set)", "A fresh empty staging change sits under @") + assert_eq(log[2].empty, true, "The staging change is empty") + assert_eq(log[3].subject, "add TWO", "The staged commit got the message") + assert_eq(log[4].subject, "base", "The base commit is untouched") + assert_eq(show(repo, "@-", "f.txt"), "one\nTWO\nthree\nfour\nfive\n", "Only the staged hunk was committed") + assert_eq(show(repo, "@", "f.txt"), "one\nTWO\nthree\nfour\nFIVE\n", "Commit keeps @") + assert_eq(vcs.has_any_staged_changes(repo), false, "Nothing is staged after the commit") + + ok, err = vcs.commit(repo, "everything else") + assert_eq(err, nil, "Committing with nothing staged commits all of @") + log = assert(vcs.log(repo, { rev = "::@", max_count = 3 })) + assert_eq(log[2].empty, true, "A fresh staging change again") + assert_eq(log[3].subject, "everything else", "The rest of @ went into the commit") + assert_eq(show(repo, "@--", "f.txt"), "one\nTWO\nthree\nfour\nFIVE\n", "It holds @'s content") + _, err = vcs.commit(repo, "empty") + assert_eq(err, "nothing to commit", "An empty working copy has nothing to commit") + end + + -- A described @- is the previous commit: it is never staged into by + -- accident. Staging from the unstaged view starts a staging change; other + -- views refuse; amend opts in. + do + local drepo = make_jj_repo() + write(drepo, "d.txt", { "1", "2", "3", "4", "5" }) + jj_run({ "commit", "-m", "first" }, drepo) + write(drepo, "d.txt", { "1", "TWO", "3", "4", "FIVE" }) + local entries = assert((vcs.queue({ root = drepo }, config.git))).entries + assert_eq(vcs.file_stage_states(drepo, entries, {})[1], "unstaged", + "A described @- does not show its files as staged") + + local queue = assert((vcs.queue({ root = drepo, mode = "all" }, config.git))) + local session = make_action_session(queue) + local ok, err = actions_mod.stage(session) + assert_eq(ok, false, "The all view refuses to stage into a described @-") + assert_eq(err:find("turn on amend", 1, true) ~= nil, true, "The refusal explains amend") + + queue = assert((vcs.queue({ root = drepo, mode = "unstaged" }, config.git))) + session = make_action_session(queue) + ok, err = actions_mod.stage(session) + assert_eq(err, nil, "Staging from the unstaged view starts a staging change") + local log = assert(vcs.log(drepo, { rev = "::@", max_count = 3 })) + assert_eq(log[2].subject, "(no description set)", "The new staging change sits under @") + assert_eq(log[3].subject, "first", "The described commit is now @--") + assert_eq(show(drepo, "@--", "d.txt"), "1\n2\n3\n4\n5\n", "The described commit was not modified") + assert_eq(show(drepo, "@-", "d.txt"), "1\nTWO\n3\n4\n5\n", "The hunk went into the staging change") + ok, err = vcs.commit(drepo, "second") + assert_eq(err, nil, "Commit the staging change") + + -- After the commit @- is an empty staging change over "second"; turning + -- amend on abandons it so "second" is @- again. @ still holds FIVE. + assert_eq(vcs.amend_base(drepo), "@--", "Amend diffs @- against @--") + log = assert(vcs.log(drepo, { rev = "@-", max_count = 1 })) + assert_eq(log[1].subject, "second", "The empty staging change was abandoned for amend") + assert_eq(vcs.last_commit_message(drepo), "second", "Amend starts from @-'s description") + ok, err = vcs.stage_file(drepo, { path = "d.txt" }, { mode = "all", amend_mode = true }) + assert_eq(err, nil, "Amend mode stages straight into the described @-") + assert_eq(show(drepo, "@-", "d.txt"), "1\nTWO\n3\n4\nFIVE\n", "Amend staging rewrote @-") + ok, err = vcs.commit(drepo, "second, amended", { amend = true }) + assert_eq(err, nil, "Amend commit") + log = assert(vcs.log(drepo, { rev = "@-", max_count = 1 })) + assert_eq(log[1].subject, "second, amended", "Amend commit rewrote @-'s description") + + write(drepo, "d.txt", { "six" }) + ok, err = vcs.commit(drepo, "third") + assert_eq(err, nil, "Without a staging change, commit is `jj commit`") + log = assert(vcs.log(drepo, { rev = "@-", max_count = 1 })) + assert_eq(log[1].subject, "third", "jj commit described the working copy") + assert_eq(show(drepo, "@-", "d.txt"), "six\n", "It holds the working copy") + end + + -- Reviews: commit review, compare, revision checks, log. + do + assert_eq(vcs.is_revision(repo, "@-"), true, "@- is a revision") + assert_eq(vcs.is_revision(repo, "f.txt"), false, "A path is not a revision") + assert_eq(vcs.is_revision(repo, "all()"), false, "A multi-commit revset is not a single revision") + -- @- is the empty staging change left by the last commit. + local queue = assert((vcs.commit_queue(repo, "@--", {}, config.git))) + assert_eq(queue.opts.read_only, true, "Commit review is read-only") + assert_eq(queue.opts.review.kind, "commit", "Commit review kind") + local paths = vim.tbl_map(function(entry) + return entry.path + end, queue.entries) + assert_eq(vim.tbl_contains(paths, "f.txt"), true, "Commit review lists the commit's files") + -- Reviews of @ snapshot first: a file written just now is current. + write(repo, "fresh.txt", { "fresh" }) + local wc, wc_err = vcs.commit_queue(repo, "@", {}, config.git) + assert_eq(wc_err, nil, "Review of @") + local fresh + for index, entry in ipairs(wc.entries) do + if entry.path == "fresh.txt" then + fresh = assert((wc.load(index))) + end + end + assert_eq(fresh and fresh.right.text, "fresh\n", "A review of @ sees unsnapshotted files") + os.remove(repo .. "/fresh.txt") + local compare = assert((vcs.compare_queue(repo, "root()", "@--", { direct = true }, config.git))) + assert_eq(compare.opts.review.kind, "compare", "Compare review kind") + local loaded = assert((compare.load(1))) + assert_eq(loaded.left.git_target, "absent", "root() has no files") + end + + -- Launcher in a jj workspace. + do + local launcher = require("diffbandit.launcher") + write(repo, "f.txt", { "launch" }) + local session = assert((launcher.launch({}, { cwd = repo }))) + assert_eq(session.file_queue.vcs, "jj", "The launcher opens the jj backend in a jj workspace") + session:close() + for _, panel in pairs(state_mod.panels) do + if panel and not panel.disposed and panel.close then + pcall(panel.close, panel) + end + end + session = assert((launcher.launch({ "show", "@--" }, { cwd = repo }))) + assert_eq(session.file_queue.opts.review.kind, "commit", "show opens a jj commit review") + session:close() + end + + -- Conflicts in @ are listed as unmerged entries. + do + local crepo = make_jj_repo() + write(crepo, "c.txt", { "1", "2", "3" }) + jj_run({ "commit", "-m", "base" }, crepo) + local base = vim.trim(jj_run({ "log", "--no-graph", "-r", "@-", "-T", "change_id" }, crepo)) + write(crepo, "c.txt", { "1", "L", "3" }) + jj_run({ "commit", "-m", "left" }, crepo) + local left = vim.trim(jj_run({ "log", "--no-graph", "-r", "@-", "-T", "change_id" }, crepo)) + jj_run({ "new", base }, crepo) + write(crepo, "c.txt", { "1", "R", "3" }) + jj_run({ "commit", "-m", "right" }, crepo) + local right = vim.trim(jj_run({ "log", "--no-graph", "-r", "@-", "-T", "change_id" }, crepo)) + jj_run({ "new", left, right }, crepo) + local entries = jj_mod.unmerged_entries(crepo, {}) + assert_eq(#entries, 1, "One conflicted file") + assert_eq(entries[1].path, "c.txt", "Conflicted path") + assert_eq(entries[1].status, "U", "Conflicts are unmerged entries") + assert_eq((jj_mod.has_unmerged(crepo)), true, "has_unmerged sees jj conflicts") + local ok, err = vcs.commit(crepo, "nope") + assert_eq(ok, false, "Commit refuses while conflicts remain") + assert_eq(err, "merge conflicts must be resolved before committing", "Conflict refusal message") + + local queue = assert((vcs.queue({ root = crepo }, config.git))) + assert_eq(queue.entries[1].status, "U", "The conflicted file shows as unmerged in the review queue") + + local stages = assert((vcs.conflict_stages(crepo, "c.txt"))) + assert_eq(stages.base, "1\n2\n3\n", "Conflict base side") + assert_eq(stages.local_text, "1\nL\n3\n", "Conflict first side") + assert_eq(stages.remote, "1\nR\n3\n", "Conflict second side") + assert_eq(stages.labels.left:find("left", 1, true) ~= nil, true, "Side labels come from jj's markers") + + local session = assert((merge_mod.start_for_path(crepo .. "/c.txt", {}, config))) + assert_eq(session.host.kind, "jj", "jj workspaces get the jj merge host") + assert_eq(#session.conflicts, 1, "The merge host sees the conflict") + assert_eq(session:build_status_lines().remote:find("right", 1, true) ~= nil, true, "Header names the second side") + vim.api.nvim_buf_set_lines(session.result_buf, 0, -1, false, { "1", "LR", "3" }) + assert_eq(session:resolve(), true, "Resolving writes the working copy") + session:close() + assert_eq(#jj_mod.unmerged_entries(crepo, {}), 0, "jj sees the conflict as resolved after the write") + assert_eq(vim.trim(jj_run({ "file", "show", "-r", "@", "--", 'root-file:"c.txt"' }, crepo)), "1\nLR\n3", + "The resolution is recorded in @") + end + + -- Marker parsing edge cases. + do + local parsed = assert((jj_mod.parse_git_style_conflict(table.concat({ + "top", + "<<<<<<<<<< a", + "<<<<<<< not a marker at this length", + "|||||||||| base", + "b", + "==========", + "c", + ">>>>>>>>>> z", + "", + }, "\n")))) + assert_eq(parsed.local_text, "top\n<<<<<<< not a marker at this length\n", "Long markers keep shorter lookalikes as text") + assert_eq(parsed.base, "top\nb\n", "Long-marker base") + assert_eq(parsed.remote, "top\nc\n", "Long-marker right") + local _, perr = jj_mod.parse_git_style_conflict("plain\n") + assert_eq(perr, "no conflict markers found", "A clean file is not a conflict") + end + + -- bin/diffbandit as jj's merge-editor and diff-editor, end to end. The + -- "nvim" is a headless wrapper that acts like a user after startup. + do + local tool_dir = vim.fn.tempname() + vim.fn.mkdir(tool_dir, "p") + local wrapper = tool_dir .. "/nvim" + -- Polls until the launcher's view is up, then plays the user. + local action = [[lua local tries = 0 +local function act() + local state = require("diffbandit.state") + if next(state.sessions) == nil then + tries = tries + 1 + if tries > 100 then + vim.cmd("cquit 3") + end + vim.defer_fn(act, 100) + return + end + for _, s in pairs(state.sessions) do + if s.result_buf and vim.env.DB_ACTION == "resolve" then + vim.api.nvim_buf_set_lines(s.result_buf, 0, -1, false, { "1", "BOTH", "3" }) + s:resolve() + s:close() + elseif s.result_buf then + s:close() + elseif s.right_root then + vim.fn.writefile({ "a", "B", "c" }, s.right_root .. "/f.txt") + vim.fn.delete(s.right_root .. "/n.txt") + s:close() + end + end +end +vim.defer_fn(act, 100)]] + vim.fn.writefile({ + "#!/bin/sh", + string.format("exec %s --headless --clean --cmd %s --cmd %s -c %s \"$@\"", + vim.fn.shellescape(vim.v.progpath), + vim.fn.shellescape("set rtp^=" .. root), + vim.fn.shellescape("runtime plugin/diffbandit.lua"), + -- writefile() turns embedded newlines into NULs; Lua needs none. + vim.fn.shellescape((action:gsub("\n", " ")))), + }, wrapper) + vim.fn.setfperm(wrapper, "rwxr-xr-x") + local tool_config = tool_dir .. "/jj.toml" + vim.fn.writefile({ + "[user]", + 'name = "DiffBandit Test"', + 'email = "diffbandit@example.com"', + "[merge-tools.diffbandit]", + string.format('program = "%s/bin/diffbandit"', root), + 'merge-args = ["merge", "$base", "$left", "$right", "$output", "--path", "$path"]', + 'edit-args = ["edit", "$left", "$right"]', + "merge-tool-edits-conflict-markers = false", + "[ui]", + 'merge-editor = "diffbandit"', + 'diff-editor = "diffbandit"', + }, tool_config) + local saved_nvim = vim.env.DIFFBANDIT_NVIM + vim.env.JJ_CONFIG = tool_config + vim.env.DIFFBANDIT_NVIM = wrapper + + local crepo = make_jj_repo() + write(crepo, "c.txt", { "1", "2", "3" }) + jj_run({ "commit", "-m", "base" }, crepo) + local base = vim.trim(jj_run({ "log", "--no-graph", "-r", "@-", "-T", "change_id" }, crepo)) + write(crepo, "c.txt", { "1", "L", "3" }) + jj_run({ "commit", "-m", "left" }, crepo) + local left = vim.trim(jj_run({ "log", "--no-graph", "-r", "@-", "-T", "change_id" }, crepo)) + jj_run({ "new", base }, crepo) + write(crepo, "c.txt", { "1", "R", "3" }) + jj_run({ "commit", "-m", "right" }, crepo) + local right = vim.trim(jj_run({ "log", "--no-graph", "-r", "@-", "-T", "change_id" }, crepo)) + jj_run({ "new", left, right }, crepo) + + vim.env.DB_ACTION = "quit" + local quit = jj_status({ "resolve" }, crepo) + assert_eq(quit.code ~= 0, true, "Quitting the merge without :w fails jj resolve") + assert_eq(#jj_mod.unmerged_entries(crepo, {}), 1, "An abandoned merge keeps the conflict") + vim.env.DB_ACTION = "resolve" + local resolved = jj_status({ "resolve" }, crepo) + assert_eq(resolved.code, 0, "jj resolve accepts a resolved merge: " .. tostring(resolved.stderr)) + assert_eq(#jj_mod.unmerged_entries(crepo, {}), 0, "jj resolve recorded the resolution") + assert_eq(read_file(crepo .. "/c.txt")[2], "BOTH", "The merged text reached the working copy") + + local srepo = make_jj_repo() + write(srepo, "f.txt", { "a", "b", "c" }) + jj_run({ "commit", "-m", "base" }, srepo) + write(srepo, "f.txt", { "a", "B", "c", "d" }) + write(srepo, "n.txt", { "new" }) + vim.env.DB_ACTION = "edit" + local split = jj_status({ "split", "-m", "first" }, srepo) + assert_eq(split.code, 0, "jj split runs diffbandit as the diff editor: " .. tostring(split.stderr)) + assert_eq(show(srepo, "@-", "f.txt"), "a\nB\nc\n", "The split's first commit holds the kept hunk") + assert_eq(jj_status({ "file", "show", "-r", "@-", "--", 'root-file:"n.txt"' }, srepo).code ~= 0, true, + "Files removed from $right stay out of the first commit") + assert_eq(show(srepo, "@", "n.txt"), "new\n", "The rest stays in @") + + vim.env.DB_ACTION = nil + vim.env.DIFFBANDIT_NVIM = saved_nvim + end + + vim.env.JJ_CONFIG = saved_jj_config +end From 9ec2236867da3056c1dddc6fab45480dc3fc313e Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Tue, 3 Nov 2026 11:26:22 -0500 Subject: [PATCH 4/4] Document jj support README and :help diffbandit-jj describe how the views, staging, commit, amend, and conflicts map onto jj, the jj merge-tools configuration for using bin/diffbandit as jj's merge and diff editor, and current limits. --- README.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++ doc/diffbandit.txt | 52 ++++++++++++++++++++++++++++++++++++++++- doc/tags | 1 + 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6dbf234..18845d6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ documents without distorting either one. - Merge conflict group in the commit panel with a local/result/remote resolver, editable result pane with editor integrations, gutter-guided accept actions, and resolve-on-write staging. +- Native [Jujutsu (jj)](https://github.com/jj-vcs/jj) support: the same + review, hunk staging (squash workflow into `@-`), commit panel, log, compare, + and conflict resolution, plus `bin/diffbandit` as jj's merge and diff editor. - Recursive folder comparison with efficient external digest checks and child file diffs that return to the folder view. - One-column overview gutters that show changed regions proportionally across @@ -287,6 +290,55 @@ git config --global mergetool.diffbandit.trustExitCode true git config --global difftool.diffbandit.cmd 'diffbandit diff "$LOCAL" "$REMOTE"' ``` +## Jujutsu (jj) + +In a jj workspace (colocated or not), every Git command, the launcher, and the +panel switch to the jj backend automatically. Set `vcs.prefer = "git"` to +force Git in a colocated repo. Revisions accept any jj revset. + +| DiffBandit | jj | +|---|---| +| unstaged view (default) | `@-` → `@` (what `jj diff` shows) | +| staged view (`--cached`) | `@--` → `@-` | +| all view | `@--` → `@`, with stage indicators | +| stage hunk / file | move it from `@` into the staging change `@-` (`@`'s content is unchanged) | +| unstage hunk / file | move it from `@-` back into `@` | +| discard | restore the file in `@` from `@-` | +| commit (`:w` in the panel) | describe the staging change and open a fresh one under `@`. With nothing staged, all of `@` is committed. Without a staging change, `jj commit` | +| amend | stage into and re-describe the commit `@-` | +| conflicts | `jj resolve --list` entries open in the merge resolver; `:w` writes the working copy | +| checkout | `jj new ` | + +This is the jj "squash workflow". The staging change is an undescribed, +mutable `@-`, and `@` is your scratch space. If `@-` is a described commit (for +example right after `jj commit`), your first stage from the unstaged view +creates an empty staging change under `@` (`jj new --insert-before @`), so an +existing commit is never changed by accident. To change that commit, turn on +amend: it abandons an empty staging change left by the last commit, stages +straight into `@-`, and `:w` rewrites its description. `jj.default_mode = "all"` +opens the all view instead. + +To use diffbandit as jj's merge and diff editor (`jj resolve`, `jj split`, +`jj squash -i`, `jj commit -i`), add this to `~/.config/jj/config.toml`: + +```toml +[merge-tools.diffbandit] +program = "diffbandit" # bin/diffbandit on PATH +merge-args = ["merge", "$base", "$left", "$right", "$output", "--path", "$path"] +edit-args = ["edit", "$left", "$right"] +diff-args = ["diff", "$left", "$right"] +merge-tool-edits-conflict-markers = false + +[ui] +merge-editor = "diffbandit" +diff-editor = "diffbandit" +``` + +Current limits: +- Conflicts with more than two sides, or that involve a deletion, are left to + `jj resolve`. +- When `@` is a merge commit, the review lists only its conflicts. + ## Default Keys Inside a diff view: @@ -460,6 +512,12 @@ require("diffbandit").setup({ }, snap_key = "]s", }, + vcs = { + prefer = "auto", -- "auto" (jj in jj workspaces), "git", or "jj" + }, + jj = { + default_mode = "unstaged", -- or "all" + }, git = { default_mode = "all", include_untracked = true, diff --git a/doc/diffbandit.txt b/doc/diffbandit.txt index b9c9283..a4727c5 100644 --- a/doc/diffbandit.txt +++ b/doc/diffbandit.txt @@ -16,6 +16,7 @@ CONTENTS *diffbandit* 11. Configuration |diffbandit-configuration| 12. Lua API |diffbandit-lua-api| 13. Testing |diffbandit-testing| + 14. Jujutsu (jj) |diffbandit-jj| ============================================================================== OVERVIEW *diffbandit-overview* @@ -316,7 +317,8 @@ External tool mode works outside any repository: diffbandit diff LEFT RIGHT # read-only < `merge` exits non-zero unless the result was saved with `:w`, so jj and -`git mergetool` (with trustExitCode) keep the conflict. +`git mergetool` (with trustExitCode) keep the conflict. See |diffbandit-jj| +for the jj configuration. ============================================================================== COMMIT PANEL *diffbandit-commit-panel* @@ -597,4 +599,52 @@ they can verify connector geometry, backgrounds, underline spans, Git actions, commit panel behavior, binary diffs, and scrolling behavior. ============================================================================== + +============================================================================== +JUJUTSU (JJ) *diffbandit-jj* + +In a jj workspace (colocated or not), the Git commands, the launcher, and the +commit panel use the jj backend automatically. `vcs.prefer = "git"` forces Git +in a colocated repository. Revisions accept jj revsets. + +The jj "squash workflow" maps onto staging: + + unstaged view (default) @- -> @ (what `jj diff` shows) + staged view (--cached) @-- -> @- + all view @-- -> @ (with stage indicators) + stage hunk/file moves it from @ into the staging change @- + (@ keeps its content) + unstage hunk/file moves it from @- back into @ + discard restores the file in @ from @- + commit (:w) describes the staging change and opens a fresh + one under @; with nothing staged, all of @ is + committed; without a staging change, `jj commit` + amend stages into and re-describes the commit @- + conflicts `jj resolve --list` entries open the merge + resolver; :w writes the working copy + checkout `jj new ` + +The staging change is an undescribed, mutable @-. When @- is a described +commit, the first stage from the unstaged view creates an empty staging +change under @ (`jj new --insert-before @`), so an existing commit is never +changed by accident. Amend changes that commit instead: it abandons an empty +staging change left by the last commit, stages straight into @-, and :w +rewrites its description. `jj.default_mode = "all"` opens the all view. + +Merge and diff editor for `jj resolve`, `jj split`, `jj squash -i`: +>toml + [merge-tools.diffbandit] + program = "diffbandit" + merge-args = ["merge", "$base", "$left", "$right", "$output", "--path", "$path"] + edit-args = ["edit", "$left", "$right"] + diff-args = ["diff", "$left", "$right"] + merge-tool-edits-conflict-markers = false + + [ui] + merge-editor = "diffbandit" + diff-editor = "diffbandit" +< +Conflicts with more than two sides, or that involve a deletion, are left to +`jj resolve`. When @ is a merge commit the review lists only its conflicts. + vim:tw=78:ts=8:ft=help:norl: diff --git a/doc/tags b/doc/tags index bcd5089..7a1b99e 100644 --- a/doc/tags +++ b/doc/tags @@ -37,6 +37,7 @@ diffbandit-folder-diff diffbandit.txt /*diffbandit-folder-diff* diffbandit-git diffbandit.txt /*diffbandit-git* diffbandit-hunk-actions diffbandit.txt /*diffbandit-hunk-actions* diffbandit-installation diffbandit.txt /*diffbandit-installation* +diffbandit-jj diffbandit.txt /*diffbandit-jj* diffbandit-launcher diffbandit.txt /*diffbandit-launcher* diffbandit-lua-api diffbandit.txt /*diffbandit-lua-api* diffbandit-mappings diffbandit.txt /*diffbandit-mappings*