Skip to content

Commit 1cf1998

Browse files
committed
feat(neovim): Implement "The diff cookbook"
https://www.naseraleisa.com/posts/diff feat(neovim): Smart parent branch detection for diff operations - Add get_parent_branch() that prioritizes dev > develop > main > master - Update SPC ghm to diff against parent branch (not just main) - Improve PR review to use parent branch detection - Support both git and jj repositories with appropriate fallbacks fix(neovim): Handle jj bookmarks correctly in diff operations - Convert jj bookmarks to commit IDs before passing to DiffviewOpen - Only show bookmark options in menu when they actually exist - Add proper fallbacks for non-existent bookmarks fix(neovim): Simplify branch detection to always use origin branches - Always prefer origin/dev, origin/develop, origin/main, origin/master - Remove complex jj bookmark handling since most repos are git-colocated - Simplify all diff operations to use git branches directly fix(neovim): Auto-fetch remote dev/develop branches if needed - Try local remote refs first to avoid unnecessary fetches - If dev/develop not found locally, fetch them specifically - This ensures we always find origin/dev even in fresh clones fix(neovim): Use merge-base for branch diffs to show only your changes - diff_against_default now uses merge-base to show only your commits - Interactive menu shows 'Your changes' vs 'All changes from branch tip' - Prevents showing 100+ files when origin/dev has moved forward
1 parent 68ff405 commit 1cf1998

4 files changed

Lines changed: 221 additions & 73 deletions

File tree

config/jj/config.toml

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,43 @@
22
name = "Edmund Miller"
33
email = "git@edmundmiller.dev"
44

5-
# Override for Seqera projects
6-
[[conditional-includes]]
7-
condition = 'working_copy.path ~ "/Users/edmundmiller/src/seqera"'
8-
file = "~/.config/jj/seqera-config.toml"
9-
10-
[[conditional-includes]]
11-
condition = "glob_matches(current_working_directory(), \"/Users/edmundmiller/src/nextflow\")"
12-
file = "~/.config/jj/seqera-config.toml"
13-
14-
# Override for nf-core projects
15-
[[conditional-includes]]
16-
condition = 'working_copy.path ~ "/Users/edmundmiller/src/nf-core"'
17-
file = "~/.config/jj/nfcore-config.toml"
18-
19-
# Override for UTD academic projects
20-
[[conditional-includes]]
21-
condition = 'working_copy.path ~ "/Users/edmundmiller/src/fg"'
22-
file = "~/.config/jj/fg-config.toml"
5+
# CONDITIONAL INCLUDES - Currently not working as expected in jj 0.33.0
6+
#
7+
# Issue: Despite trying multiple patterns, conditional includes are not being applied:
8+
# - working_copy.path ~ "/path" (substring matching)
9+
# - working_copy.path.contains("/path/") (string contains)
10+
# - working_copy.path.starts_with("/path") (string starts_with)
11+
# - glob_matches(working_copy.path, "/path/*") (glob pattern)
12+
# - glob_matches(current_working_directory(), "/path") (cwd glob)
13+
#
14+
# All patterns are being parsed correctly (show up in `jj config list`) but
15+
# conditions evaluate to false for unknown reasons. May be related to:
16+
# - Path resolution differences (symlinks, canonical paths)
17+
# - Version compatibility issues with jj 0.33.0
18+
# - Working copy path format differences
19+
#
20+
# WORKAROUND: Using per-repository email configuration with:
21+
# `jj config set --repo user.email "email@domain.com"`
22+
# This approach is more reliable and provides immediate results.
23+
24+
# Override for Seqera projects (disabled - see above)
25+
# [[conditional-includes]]
26+
# condition = "glob_matches(working_copy.path, \"/Users/edmundmiller/src/seqera/*\")"
27+
# file = "~/.config/jj/seqera-config.toml"
28+
29+
# [[conditional-includes]]
30+
# condition = "glob_matches(current_working_directory(), \"/Users/edmundmiller/src/nextflow\")"
31+
# file = "~/.config/jj/seqera-config.toml"
32+
33+
# Override for nf-core projects (disabled - see above)
34+
# [[conditional-includes]]
35+
# condition = 'working_copy.path ~ "/Users/edmundmiller/src/nf-core"'
36+
# file = "~/.config/jj/nfcore-config.toml"
37+
38+
# Override for UTD academic projects (disabled - see above)
39+
# [[conditional-includes]]
40+
# condition = "glob_matches(working_copy.path, \"/Users/edmundmiller/src/fg/*\")"
41+
# file = "~/.config/jj/fg-config.toml"
2342

2443
[signing]
2544
behavior = "own"

config/nvim/lua/config/keymaps.lua

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,25 @@ map("n", "]c", function()
145145
end
146146
end, { desc = "Next hunk/change" })
147147

148+
-- Visual mode hunk operations
149+
map("v", "<leader>ghs", function()
150+
local ok, gitsigns = pcall(require, "gitsigns")
151+
if ok then
152+
gitsigns.stage_hunk({ vim.fn.line("."), vim.fn.line("v") })
153+
else
154+
vim.notify("Gitsigns not available", vim.log.levels.WARN)
155+
end
156+
end, { desc = "Stage selected hunk" })
157+
158+
map("v", "<leader>ghr", function()
159+
local ok, gitsigns = pcall(require, "gitsigns")
160+
if ok then
161+
gitsigns.reset_hunk({ vim.fn.line("."), vim.fn.line("v") })
162+
else
163+
vim.notify("Gitsigns not available", vim.log.levels.WARN)
164+
end
165+
end, { desc = "Reset selected hunk" })
166+
148167
-- GitHub operations via Octo (LazyVim extra provides these automatically):
149168
-- <leader>gi - List Issues
150169
-- <leader>gI - Search Issues

config/nvim/lua/plugins/git-neogit.lua

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,21 @@ return {
135135
"sindrets/diffview.nvim",
136136
cmd = { "DiffviewOpen", "DiffviewClose", "DiffviewFileHistory" },
137137
keys = {
138+
-- Interactive diff operations
138139
{ "<leader>go", function() require("util.git").open_diff_interactive() end, desc = "Open diff (interactive)" },
139140
{ "<leader>gf", function() require("util.git").file_history_interactive() end, desc = "File history (interactive)" },
140141
{ "<leader>gv", "<cmd>DiffviewOpen<cr>", desc = "View working changes" },
141142
{ "<leader>gV", "<cmd>DiffviewOpen HEAD<cr>", desc = "View last commit" },
142143
{ "<leader>gq", "<cmd>DiffviewClose<cr>", desc = "Quit diffview" },
143144
{ "<leader>gP", function() require("util.git").review_pr_changes() end, desc = "Review PR changes" },
145+
146+
-- History operations (from blog post)
147+
{ "<leader>ghh", function() require("util.git").repo_history() end, desc = "Repo history" },
148+
{ "<leader>ghm", function() require("util.git").diff_against_default() end, desc = "Diff against parent branch (dev/main)" },
149+
{ "<leader>ghv", function() require("util.git").visual_selection_history() end, mode = "v", desc = "Visual selection history" },
150+
{ "<leader>ghl", function() require("util.git").line_history() end, desc = "Line history" },
151+
{ "<leader>ghc", function() require("util.git").compare_with_clipboard() end, desc = "Compare with clipboard" },
152+
{ "<leader>ghC", function() require("util.git").compare_with_clipboard() end, mode = "v", desc = "Compare selection with clipboard" },
144153
},
145154
config = function()
146155
require("diffview").setup({

config/nvim/lua/util/git.lua

Lines changed: 156 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,75 @@
11
-- Git utility functions for interactive operations
22
local M = {}
33

4+
-- Get the parent branch (dev > develop > main > master) - always prefer origin
5+
function M.get_parent_branch()
6+
-- Try branches in priority order - ALWAYS prefer origin/remote branches
7+
local branches = { "dev", "develop", "main", "master" }
8+
9+
-- First pass: check if remote branches exist locally
10+
for _, b in ipairs(branches) do
11+
local exists = vim.fn.system("git rev-parse --verify origin/" .. b .. " 2>/dev/null")
12+
if vim.v.shell_error == 0 then
13+
return "origin/" .. b
14+
end
15+
end
16+
17+
-- If we didn't find dev/develop remotely, try fetching them specifically
18+
-- This is faster than fetching everything
19+
vim.fn.system("git fetch origin dev:refs/remotes/origin/dev 2>/dev/null")
20+
if vim.v.shell_error == 0 then
21+
return "origin/dev"
22+
end
23+
24+
vim.fn.system("git fetch origin develop:refs/remotes/origin/develop 2>/dev/null")
25+
if vim.v.shell_error == 0 then
26+
return "origin/develop"
27+
end
28+
29+
-- Fallback: try to detect default branch from origin/HEAD
30+
local default_branch = vim.fn.system("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/@@'"):gsub("%s+", "")
31+
if default_branch ~= "" then
32+
return default_branch
33+
end
34+
35+
-- Last resort: check local branches
36+
for _, b in ipairs(branches) do
37+
local exists = vim.fn.system("git rev-parse --verify " .. b .. " 2>/dev/null")
38+
if vim.v.shell_error == 0 then
39+
return b
40+
end
41+
end
42+
43+
return "origin/main" -- Default fallback
44+
end
45+
46+
-- Get the default branch name (backward compatibility)
47+
function M.get_default_branch_name()
48+
return M.get_parent_branch()
49+
end
50+
451
-- Interactive diff with menu selection
552
function M.open_diff_interactive()
6-
-- Check if in jj repo to provide appropriate prompt
7-
local jj_root = vim.fn.system("jj root 2>/dev/null"):gsub("%s+", "")
8-
local is_jj = vim.v.shell_error == 0 and jj_root ~= ""
9-
10-
local options = is_jj and {
11-
{ "@-..@", "Changes from parent to current" },
12-
{ "@--..@", "Changes from grandparent to current" },
13-
{ "main@origin..@", "All changes from remote main" },
14-
{ "@-", "Show parent change" },
15-
{ "@", "Show current change" },
16-
{ "custom", "Enter custom revision..." },
17-
} or {
53+
local default_branch = M.get_parent_branch()
54+
55+
-- Find merge base for more accurate feature branch diff
56+
local merge_base = vim.fn.system("git merge-base HEAD " .. default_branch):gsub("%s+", "")
57+
local feature_branch_pattern = merge_base ~= "" and (merge_base .. "..HEAD") or (default_branch .. "..HEAD")
58+
local feature_branch_desc = merge_base ~= "" and "Your changes (from merge base)" or ("Feature branch changes from " .. default_branch)
59+
60+
-- Use git-compatible options for both git and jj repos
61+
local options = {
62+
{ "", "Working directory changes" },
1863
{ "HEAD", "Last commit" },
1964
{ "HEAD~3", "Last 3 commits" },
20-
{ "main..HEAD", "Feature branch changes" },
21-
{ "origin/main..HEAD", "Changes from remote main" },
65+
{ feature_branch_pattern, feature_branch_desc },
66+
{ default_branch .. "..HEAD", "All changes from " .. default_branch .. " tip" },
2267
{ "HEAD^!", "Only last commit (no context)" },
2368
{ "@{u}..HEAD", "Unpushed changes" },
2469
{ "@{1}", "Previous HEAD position" },
2570
{ "stash", "View stashed changes" },
2671
{ "--staged", "Only staged changes" },
72+
{ "--cached", "Staged changes (cached)" },
2773
{ "custom", "Enter custom revision..." },
2874
}
2975

@@ -44,14 +90,13 @@ function M.open_diff_interactive()
4490

4591
if pattern == "custom" then
4692
-- Fall back to manual input
47-
local prompt = is_jj
48-
and "Revision to diff (@, @-, @-..@, main..@): "
49-
or "Revision to diff (HEAD, HEAD~3, main..HEAD, stash): "
50-
51-
local input = vim.fn.input(prompt)
93+
local input = vim.fn.input("Revision to diff (HEAD, HEAD~3, main..HEAD, stash): ")
5294
if input ~= "" then
5395
vim.cmd("DiffviewOpen " .. input)
5496
end
97+
elseif pattern == "" then
98+
-- Working directory changes
99+
vim.cmd("DiffviewOpen")
55100
else
56101
vim.cmd("DiffviewOpen " .. pattern)
57102
end
@@ -64,8 +109,12 @@ function M.file_history_interactive()
64109
{ "%", "Current file" },
65110
{ ".", "Entire repository" },
66111
{ "% --follow", "Current file (follow renames)" },
112+
{ "% --range=" .. vim.fn.line(".") .. ",+1", "Current line history" },
113+
{ vim.fn.expand("%:h"), "Current directory" },
67114
{ "src/", "src/ directory" },
68115
{ "*.lua", "All Lua files" },
116+
{ "*.py", "All Python files" },
117+
{ "*.js *.ts *.jsx *.tsx", "All JavaScript/TypeScript files" },
69118
{ "custom", "Enter custom path..." },
70119
}
71120

@@ -98,44 +147,14 @@ end
98147

99148
-- Review PR changes
100149
function M.review_pr_changes()
101-
-- Check if in jj repo
102-
local jj_root = vim.fn.system("jj root 2>/dev/null"):gsub("%s+", "")
103-
local is_jj = vim.v.shell_error == 0 and jj_root ~= ""
104-
105-
if is_jj then
106-
-- For jj, show diff from main branch to current change
107-
local main_commit = vim.fn.system("jj log --no-graph -r 'main' -T 'commit_id' --limit 1 2>/dev/null"):gsub("%s+", "")
150+
local parent_branch = M.get_parent_branch()
108151

109-
if vim.v.shell_error == 0 and main_commit ~= "" then
110-
vim.cmd("DiffviewOpen " .. main_commit .. "..HEAD")
111-
else
112-
-- Fallback: try main@origin
113-
main_commit = vim.fn.system("jj log --no-graph -r 'main@origin' -T 'commit_id' --limit 1 2>/dev/null"):gsub("%s+", "")
114-
if vim.v.shell_error == 0 and main_commit ~= "" then
115-
vim.cmd("DiffviewOpen " .. main_commit .. "..HEAD")
116-
else
117-
-- Final fallback: show diff from parent to current
118-
vim.cmd("DiffviewOpen HEAD~..HEAD")
119-
end
120-
end
152+
-- Find the merge base with the parent branch
153+
local merge_base = vim.fn.system("git merge-base HEAD " .. parent_branch):gsub("%s+", "")
154+
if vim.v.shell_error == 0 and merge_base ~= "" then
155+
vim.cmd("DiffviewOpen " .. merge_base .. "..HEAD")
121156
else
122-
-- For git, find the merge base with main/master
123-
local main_branch = vim.fn.system("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@'"):gsub("%s+", "")
124-
if main_branch == "" then
125-
main_branch = "main"
126-
-- Try master if main doesn't exist
127-
local branch_exists = vim.fn.system("git rev-parse --verify " .. main_branch .. " 2>/dev/null")
128-
if vim.v.shell_error ~= 0 then
129-
main_branch = "master"
130-
end
131-
end
132-
133-
local merge_base = vim.fn.system("git merge-base HEAD " .. main_branch):gsub("%s+", "")
134-
if vim.v.shell_error == 0 and merge_base ~= "" then
135-
vim.cmd("DiffviewOpen " .. merge_base .. "..HEAD")
136-
else
137-
vim.notify("Could not find merge base with " .. main_branch, vim.log.levels.ERROR)
138-
end
157+
vim.notify("Could not find merge base with " .. parent_branch, vim.log.levels.ERROR)
139158
end
140159
end
141160

@@ -165,4 +184,86 @@ function M.delete_worktree()
165184
})
166185
end
167186

187+
-- Visual selection history
188+
function M.visual_selection_history()
189+
-- Get visual selection range
190+
local start_line = vim.fn.line("'<")
191+
local end_line = vim.fn.line("'>")
192+
193+
-- Use line range for file history
194+
vim.cmd(string.format("DiffviewFileHistory %%:%d,%d", start_line, end_line))
195+
end
196+
197+
-- Single line history using Gitsigns
198+
function M.line_history()
199+
local ok, gitsigns = pcall(require, "gitsigns")
200+
if ok then
201+
gitsigns.blame_line({ full = true })
202+
else
203+
-- Fallback to DiffviewFileHistory for current line
204+
local line = vim.fn.line(".")
205+
vim.cmd(string.format("DiffviewFileHistory %%:%d,%d", line, line))
206+
end
207+
end
208+
209+
-- Compare buffer/selection with clipboard
210+
function M.compare_with_clipboard()
211+
local mode = vim.fn.mode()
212+
local clipboard = vim.fn.getreg("+")
213+
214+
if clipboard == "" then
215+
vim.notify("Clipboard is empty", vim.log.levels.WARN)
216+
return
217+
end
218+
219+
-- Create a temporary file with clipboard content
220+
local tmp_file = vim.fn.tempname()
221+
vim.fn.writefile(vim.split(clipboard, "\n"), tmp_file)
222+
223+
if mode == "v" or mode == "V" then
224+
-- Visual mode: compare selection with clipboard
225+
-- Get visual selection
226+
local start_line = vim.fn.line("'<")
227+
local end_line = vim.fn.line("'>")
228+
local lines = vim.fn.getline(start_line, end_line)
229+
230+
-- Create temp file with selection
231+
local selection_file = vim.fn.tempname()
232+
vim.fn.writefile(lines, selection_file)
233+
234+
-- Open diff view
235+
vim.cmd("tabnew")
236+
vim.cmd("edit " .. selection_file)
237+
vim.cmd("diffthis")
238+
vim.cmd("vsplit " .. tmp_file)
239+
vim.cmd("diffthis")
240+
else
241+
-- Normal mode: compare entire buffer with clipboard
242+
vim.cmd("tabnew %")
243+
vim.cmd("diffthis")
244+
vim.cmd("vsplit " .. tmp_file)
245+
vim.cmd("diffthis")
246+
end
247+
end
248+
249+
-- Diff against parent branch (dev > develop > main > master)
250+
function M.diff_against_default()
251+
local parent_branch = M.get_parent_branch()
252+
253+
-- Find the merge base to show only our changes
254+
local merge_base = vim.fn.system("git merge-base HEAD " .. parent_branch):gsub("%s+", "")
255+
if vim.v.shell_error == 0 and merge_base ~= "" then
256+
-- Show changes from merge base to HEAD (just our changes)
257+
vim.cmd("DiffviewOpen " .. merge_base .. "..HEAD")
258+
else
259+
-- Fallback to showing diff against branch tip
260+
vim.cmd("DiffviewOpen " .. parent_branch)
261+
end
262+
end
263+
264+
-- Show all repository history
265+
function M.repo_history()
266+
vim.cmd("DiffviewFileHistory")
267+
end
268+
168269
return M

0 commit comments

Comments
 (0)