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/README.md b/README.md index 71ee3ed..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 @@ -262,6 +265,80 @@ 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"' +``` + +## 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: @@ -435,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, @@ -649,6 +732,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..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* @@ -309,6 +310,16 @@ 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. See |diffbandit-jj| +for the jj configuration. + ============================================================================== COMMIT PANEL *diffbandit-commit-panel* @@ -558,6 +569,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() @@ -587,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* diff --git a/lua/diffbandit/config.lua b/lua/diffbandit/config.lua index 11055d2..f411eba 100644 --- a/lua/diffbandit/config.lua +++ b/lua/diffbandit/config.lua @@ -108,6 +108,16 @@ 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", + }, + 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/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/git/init.lua b/lua/diffbandit/git/init.lua index 1fe93d6..95a8871 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 @@ -1584,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/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 52a8ac3..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") @@ -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/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/launcher.lua b/lua/diffbandit/launcher.lua index 925630a..b969b3b 100644 --- a/lua/diffbandit/launcher.lua +++ b/lua/diffbandit/launcher.lua @@ -4,8 +4,14 @@ -- -- 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 git = require("diffbandit.vcs") local panel_mod = require("diffbandit.panel") local state = require("diffbandit.state") @@ -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" @@ -177,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 ed45c61..9b8ee4e 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") @@ -40,14 +41,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 +122,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 +140,74 @@ function Merge.load(root, path, config) }, nil end +function Merge.load(root, path, config) + local stages, err = vcs.conflict_stages(root, path) + if not stages then + return nil, err + end + 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) + 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 +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.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 @@ -137,12 +269,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, data) 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, @@ -310,11 +442,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 @@ -1780,14 +1912,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, data) 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 +2185,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/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..27c5e96 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") @@ -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/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/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 new file mode 100644 index 0000000..fc061ad --- /dev/null +++ b/lua/diffbandit/vcs/init.lua @@ -0,0 +1,155 @@ +-- 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", + jj = "diffbandit.jj", +} + +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..86cdf57 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -44,8 +44,26 @@ 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 +-- 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 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 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