difftastic is a structural diff tool
that compares files based on their syntax. So for example, if you
conditionalize some statement, the diff would only show the addition of one
if
with its condition instead of showing one line added (the if (condition)
) and the line with the statement being removed and re-added
(because of indentation changes). In many cases, such structural diffs
transport the meaning of a change much better than the typical line-based
diffs.
Of course, that comes with a price: difftastic has to understand the language's syntax (it currently supports these languages) and computing a structural diff is a quite expensive operation. Also, there are certain kinds of changes where a line-based diff with changed-word highlighting gives better results, namely when the changed syntactic unit is a large blob with no sub-structure, e.g., a docstring or a comment.
Git allows to use an external diff tool in many
commands by setting the environment variable GIT_EXTERNAL_DIFF=<tool>
and in
the following I make use of that for being able to use difftastic for git show
and git diff
when operating on a git repository from inside Emacs using
Magit. Because the aforementioned downsides, I want that
as opt-in behavior, i.e., separate commands. Also, Magit has some assumptions
on how a git diff looks like which are not met by difftastic, e.g., difftastic
prints line numbers and generates side-by-side diffs for changes which are not
plain additions or deletions.
So here we go. Let's first define a helper function which sets
GIT_EXTERNAL_DIFF
to difft
(the difftastic binary), runs the given git
command in a new process asynchronously, and pops up a buffer with the result
once the process finished.
(defun th/magit--with-difftastic (buffer command)
"Run COMMAND with GIT_EXTERNAL_DIFF=difft then show result in BUFFER."
(let ((process-environment
(cons (concat "GIT_EXTERNAL_DIFF=difft --width="
(number-to-string (frame-width)))
process-environment)))
;; Clear the result buffer (we might regenerate a diff, e.g., for
;; the current changes in our working directory).
(with-current-buffer buffer
(setq buffer-read-only nil)
(erase-buffer))
;; Now spawn a process calling the git COMMAND.
(make-process
:name (buffer-name buffer)
:buffer buffer
:command command
;; Don't query for running processes when emacs is quit.
:noquery t
;; Show the result buffer once the process has finished.
:sentinel (lambda (proc event)
(when (eq (process-status proc) 'exit)
(with-current-buffer (process-buffer proc)
(goto-char (point-min))
(ansi-color-apply-on-region (point-min) (point-max))
(setq buffer-read-only t)
(view-mode)
(end-of-line)
;; difftastic diffs are usually 2-column side-by-side,
;; so ensure our window is wide enough.
(let ((width (current-column)))
(while (zerop (forward-line 1))
(end-of-line)
(setq width (max (current-column) width)))
;; Add column size of fringes
(setq width (+ width
(fringe-columns 'left)
(fringe-columns 'right)))
(goto-char (point-min))
(pop-to-buffer
(current-buffer)
`(;; If the buffer is that wide that splitting the frame in
;; two side-by-side windows would result in less than
;; 80 columns left, ensure it's shown at the bottom.
,(when (> 80 (- (frame-width) width))
#'display-buffer-at-bottom)
(window-width
. ,(min width (frame-width))))))))))))
The crucial parts of that helper function are that we "wash" the result using
ansi-color-apply-on-region
so that the difftastic highlighting using shell
escape codes is transformed to emacs faces. Also, the needed width of the
possibly wide side-by-side difftastic diff is computed and tried to be
accommodated for.
Next, let's define our first command basically doing a git show
for some
revision which defaults to the commit or branch at point or queries the user if
there's none.
(defun th/magit-show-with-difftastic (rev)
"Show the result of \"git show REV\" with GIT_EXTERNAL_DIFF=difft."
(interactive
(list (or
;; If REV is given, just use it.
(when (boundp 'rev) rev)
;; If not invoked with prefix arg, try to guess the REV from
;; point's position.
(and (not current-prefix-arg)
(or (magit-thing-at-point 'git-revision t)
(magit-branch-or-commit-at-point)))
;; Otherwise, query the user.
(magit-read-branch-or-commit "Revision"))))
(if (not rev)
(error "No revision specified")
(th/magit--with-difftastic
(get-buffer-create (concat "*git show difftastic " rev "*"))
(list "git" "--no-pager" "show" "--ext-diff" rev))))
And here the second command which basically does a git diff
. It tries to
guess what one wants to diff, e.g., when point is on the Staged changes
section in a magit buffer, it will run git diff --cached
to show a diff of
all staged changes. If no context can be guessed, it'll query the user for a
range or commit for diffing.
(defun th/magit-diff-with-difftastic (arg)
"Show the result of \"git diff ARG\" with GIT_EXTERNAL_DIFF=difft."
(interactive
(list (or
;; If RANGE is given, just use it.
(when (boundp 'range) range)
;; If prefix arg is given, query the user.
(and current-prefix-arg
(magit-diff-read-range-or-commit "Range"))
;; Otherwise, auto-guess based on position of point, e.g., based on
;; if we are in the Staged or Unstaged section.
(pcase (magit-diff--dwim)
('unmerged (error "unmerged is not yet implemented"))
('unstaged nil)
('staged "--cached")
(`(stash . ,value) (error "stash is not yet implemented"))
(`(commit . ,value) (format "%s^..%s" value value))
((and range (pred stringp)) range)
(_ (magit-diff-read-range-or-commit "Range/Commit"))))))
(let ((name (concat "*git diff difftastic"
(if arg (concat " " arg) "")
"*")))
(th/magit--with-difftastic
(get-buffer-create name)
`("git" "--no-pager" "diff" "--ext-diff" ,@(when arg (list arg))))))
What's left is integrating these two new commands in Magit. For that purpose, I've created a new transient prefix for my personal commands.
(transient-define-prefix th/magit-aux-commands ()
"My personal auxiliary magit commands."
["Auxiliary commands"
("d" "Difftastic Diff (dwim)" th/magit-diff-with-difftastic)
("s" "Difftastic Show" th/magit-show-with-difftastic)])
I want my personal commands transient to be bound to #
and be shown in the
Magit dispatch transient (which is bound to ?
in Magit status buffers and
C-x M-g
in any Magit enabled buffer) after the Run (!
) transient.
(transient-append-suffix 'magit-dispatch "!"
'("#" "My Magit Cmds" th/magit-aux-commands))
(define-key magit-status-mode-map (kbd "#") #'th/magit-aux-commands)
And that's it!
Finally, here's a screenshot showing how it looks like:
Back to the blog by tags or to the overview of all posts.
Also, you can subscribe to my rss feed.