The diff cookbook

“Diffing” refers to the process of comparing one thing against another, viewing the “differences” between the two.

Because comparing text is a common occurrence when writing code, there exists an entire category of tools dedicated to helping developers compare text snippets. In this post, I go over some of the tools I rely on for this task.

Who is this post for?

  1. You use neovim (or considering it).
  2. You have yet to go down the diff rabbit hole yourself.
  3. You’re (at least mildly) interested in improving your diff workflow.

On the other hand, if you’ve already been down this path and have found better methods, do consider sharing them.

Table of Contents

Neovim: diffing while editing code

The following examples require diffview.nvim and gitsigns.nvim to be installed.

Deep diffing: comparing against one or more commits

Repo

Trace the history of the entire git repository.

After the command is invoked, use tab to cycle to the next change in chronological order. shift+tab will take you in the opposite direction.
vim.keymap.set(
	'n',
	',hh',
	'<cmd>DiffviewFileHistory<cr>',
	{ desc = 'Repo history' }
)

File

This can be used to step through the changes of a single file (the current file). The --follow flag tells git to trace a file’s history through renames.

It opens in the same view as the previous example, except that the commits listed are limited to those affecting the current file.

vim.keymap.set(
	'n',
	',hf',
	'<cmd>DiffviewFileHistory --follow %<cr>',
	{ desc = 'File history' }
)

Visual selection

Trace the history of the current visual selection. Use this command if you’re only interested in tracing changes affecting a specific section of a file.

vim.keymap.set(
	'v',
	',hl',
	"<Esc><Cmd>'<,'>DiffviewFileHistory --follow<CR>",
	{ desc = 'Range history' }
)

Line

Finally, the most granular form is tracing the history of a single line.

vim.keymap.set(
	'n',
	',hl',
	'<Cmd>.DiffviewFileHistory --follow<CR>',
	{ desc = 'Line history' }
)

Shallow diffing: comparing against HEAD

Repo

Diff the working directory against HEAD (and staging area). This will open a new tab.

screenshot of diffview.nvim
Image source: https://github.com/sindrets/diffview.nvim
vim.keymap.set('n', ',d', '<cmd>DiffviewOpen<cr>', { desc = 'Repo diff' })

We can slightly alter the previous command to serve another common use case: diffing the working directory against the master branch. This is useful before merging or when first continuing work on an existing feature branch.

local function get_default_branch_name()
	local res = vim
		.system({ 'git', 'rev-parse', '--verify', 'main' }, { capture_output = true })
		:wait()
	return res.code == 0 and 'main' or 'master'
end

-- Diff against local master branch
vim.keymap.set(
	'n',
	',hm',
	function() vim.cmd('DiffviewOpen ' .. get_default_branch_name()) end,
	{ desc = 'Diff against master' }
)

-- Diff against remote master branch
vim.keymap.set(
	'n',
	',hM',
	function() vim.cmd('DiffviewOpen HEAD..origin/' .. get_default_branch_name()) end,
	{ desc = 'Diff against origin/master' }
)

File

The following commands use highlighting and virtual text to visualize changes without leaving the current buffer.

-- Highlight changed words.
vim.keymap.set(
	'n',
	',vw',
	require('gitsigns').toggle_word_diff,
	{ desc = 'Toggle word diff' }
)

-- Highlight added lines.
vim.keymap.set(
	'n',
	',vL',
	require('gitsigns').toggle_linehl,
	{ desc = 'Toggle line highlight' }
)

-- Highlight removed lines.
vim.keymap.set(
	'n',
	',vv',
	require('gitsigns').toggle_deleted,
	{ desc = 'Toggle deleted (all)' }
)

Hunk

Diff the current hunk in a floating window (against the staging area).

vim.keymap.set(
	'n',
	',vh',
	require('gitsigns').preview_hunk,
	{ desc = 'Preview hunk' }
)

Clipboard

Clipboard vs. current file

Diff the current buffer against the clipboard contents.

My clipboard contains "yodafied" text. Here, I compare that "yodafied" text to the current buffer by invoking :CompareClipboard. Finally, I close the generated tab to return back to my original tab by invoking :tabclose.
-- Create a new scratch buffer
vim.api.nvim_create_user_command(
	'Ns',
	function()
		vim.cmd([[
		execute 'vsplit | enew'
		setlocal buftype=nofile
		setlocal bufhidden=hide
		setlocal noswapfile
	]])
	end,
	{ nargs = 0 }
)

-- Compare clipboard to current buffer
vim.api.nvim_create_user_command('CompareClipboard', function()
	local ftype = vim.api.nvim_eval('&filetype') -- original filetype
	vim.cmd([[
		tabnew %
		Ns
		normal! P
		windo diffthis
	]])
	vim.cmd('set filetype=' .. ftype)
end, { nargs = 0 })

-- Assign it to a keymap
vim.keymap.set(
	'n',
	',vc',
	'<cmd>CompareClipboard<cr>',
	{ desc = 'Compare Clipboard', silent = true }
)

Clipboard vs. visual selection

Diff the current visual selection against the clipboard contents.

My clipboard contains "yodafied" text. Here, I highlight two lines, then compare the "yodafied" text to the two highlighted lines by invoking :CompareClipboardSelection.
-- Create a new scratch buffer (see previous example)
-- ...

-- Compare clipboard to visual selection
vim.api.nvim_create_user_command(
	'CompareClipboardSelection',
	function()
		vim.cmd([[
		" yank visual selection to z register
		normal! gv"zy
		" open new tab, set options to prevent save prompt when closing
		execute 'tabnew | setlocal buftype=nofile bufhidden=hide noswapfile'
		" paste z register into new buffer
		normal! V"zp
		Ns
		normal! Vp
		windo diffthis
	]])
	end,
	{
		nargs = 0,
		range = true,
	}
)

-- Assign it to a keymap
vim.keymap.set(
	'v',
	',vc',
	'<esc><cmd>CompareClipboardSelection<cr>',
	{ desc = 'Compare Clipboard Selection' }
)

Shell: diffing the output of two shell commands

You can use process substitution to diff the output of any two shell commands. The following works for bash and zsh.

nvim -d <(ls -l) <(ls -la)

# you can also use another difftool
delta <(ls -l) <(ls -la)

Filesize: recursively diffing file sizes in a directory

This bash function uses dust to diff the sizes of two directories. I don’t use this much, but it comes in handy when I’m experimenting with different bundler options (such as in a tsconfig.json or vite.config.js) and want to know more about how the changes I’m making affect the size of the generated output.

screenshot of diffing directory sizes
The total size (bottom line) is reduced by 1.6K when we instruct the typescript compiler to not output declaration files (*.d.ts).
# diff directory size
# Usage:   diff-dirs DIR1 DIR2 [dust options]
# Example: diff-dirs dir1 dir2 --depth=1 --no-progress --apparent-size
diff-dirs() {
  local dir1="$1"
  local dir2="$2"
  if [[ ! -d "$dir1" ]] || [[ ! -d "$dir2" ]]; then
    echo "Error: both directories must exist"
    return 1
  fi
  shift 2
  delta --side-by-side <(dust --screen-reader --no-colors --full-paths --only-file --reverse "$@" "$dir1" | sed 's/\r//g' | sed 's|^[^/]*/||') <(dust --screen-reader --no-colors --full-paths --only-file --reverse "$@" "$dir2" | sed 's/\r//g' | sed 's|^[^/]*/||')
}

Depending on the output, it can be tricky to accurately detect and properly highlight moved vs changed lines. Delta has some options that you can tweak such as --word-diff-regex.

Misc: quality of life tips for working with diffs

Syntax highlighting for code snippets

- my original line
+ my updated line
my unchanged line

You can enable this form of syntax highlighting for a markdown code block by specifying diff as the language. Then, prefix any line with either a - or a + to highlight it.

This works automatically on github.com. In neovim, you might need to explicitly add diff to the list of languages in your treesitter config (in the ensure_installed table).

Delta

Delta makes diffs easier to read.

screenshot of delta diff
screenshot of delta
Image source: https://github.com/dandavison/delta

Using delta on the command line

# delta can be used to diff any two files
delta file1 file2

# or directories.
delta dir1 dir2

Using delta as the default git pager

Delta can be set as git’s default pager. See delta’s documentation for how to do so in your .gitconfig. Additionally, below are some extra delta options I’ve added to my .gitconfig:

# .gitconfig
[core]
	pager = delta
[delta]
	features = decorations
	light = false
	tabs = 2
	line-numbers = true
	navigate = true
	hyperlinks = true
	# side-by-side = true

Hint: For even greater readability, set gruvmax-fang as your custom theme.

You can find out more about these options by running delta --help or visiting delta’s docs.

Delta can also be integrated into other tools such as lazygit.

meld

If you’d rather work with a mouse, or simply prefer a GUI, meld can compare files or entire directories recursively.

screenshot of meld
Image source: https://meldmerge.org/

comm

comm can print common or unique lines between two files. A helpful cheat sheet can be found here.