Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <rev>` |

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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 17 additions & 2 deletions bin/diffbandit
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ usage: diffbandit [--cached] [--no-untracked] [<rev> [<rev>] | <a>..<b> | <a>...
<a>...<b> read-only review of <b> against its merge base with <a>
show <rev> read-only review of a single commit

External tool mode (jj merge-tools, git mergetool/difftool):
diffbandit merge <base> <left> <right> <output> [--path P]
three-way merge; exits non-zero unless resolved with :w
diffbandit edit <left> <right>
diff editor: edit the right folder/file (jj split, squash -i)
diffbandit diff <left> <right>
read-only folder or file diff

Environment:
DIFFBANDIT_NVIM nvim executable (default: nvim)
DIFFBANDIT_NVIM_APPNAME NVIM_APPNAME for a dedicated nvim config
Expand All @@ -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().
Expand All @@ -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
60 changes: 60 additions & 0 deletions doc/diffbandit.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down Expand Up @@ -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*

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 <rev>`

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:
1 change: 1 addition & 0 deletions doc/tags
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
10 changes: 10 additions & 0 deletions lua/diffbandit/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 15 additions & 2 deletions lua/diffbandit/folder/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
38 changes: 25 additions & 13 deletions lua/diffbandit/git/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 ""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading
Loading