notes from /dev/null

by Charles Choi 최민수


Using Ediff in 2023

10 Jul 2023  Charles Choi

I confess, it took me a long time to really be comfortable working with diffs, especially when rendered on top of each other. Back in the late 90's TkDiff changed that for me because it would show them side-by-side. At the time it seemed like an impressive feat. Ediff could do it too and it first came out in 1995. And yet…

For many years I’ve known about Ediff but whenever I got around to using it, my end emotional state would be: “wow that’s awkward.” Ultimately what I wanted to do with Ediff was to see the diffs for a modified repo file side-to-side with its checked in counterpart just like in TkDiff. And I wanted to do it right from the current buffer. Seems straightforward, yes? Not quite. Let’s go through an example.

On GUI Emacs 28.2 with Ediff defaults, invoking M-x ediff-revision on a modified repo file will:

  1. Prompt you for the first file (default is the file in the buffer)
  2. Prompt you for a branch containing a variant of the first file (default is the current branch)
    • No completion support is provided here.
  3. Open a new frame (GUI window) holding the controls (next, previous, copy left, copy right, etc.) which must be in focus to receive your keyboard event.
  4. Create two Emacs windows1, one on top of the other
    • At this point you are free to navigate the diffs between the two files.
  5. Quitting in the control frame will:
    1. Delete the control frame.
    2. Keep the two buffers in place (because the variable ediff-keep-variants default value is t).

Worse yet, if you set ediff-keep-variants to nil to dispose of them, you would be prompted each time before killing the aforementioned two buffers, regardless if there is no change to them.

This is what I mean by awkward. Contemporary IDEs (examples: anything from JetBrains, Xcode, VSCode, Eclipse) won’t make you go through anything near that to diff a modified repo file.

Why I think Ediff goes through such hoops is because it was designed to be profligate in creating state and timid in destroying it. I wouldn’t in the least be surprised if its original design intent was to only diff and merge two arbitrary files and that support for revision control was tacked on later in life (recall, Ediff was released in 1995!).

So why work with Ediff? My personal answer is that I’m too invested in Emacs. I want a similar solution provided by contemporary IDEs and Ediff is the only package I know of that provides side-by-side and merge support. If I want to stay in Emacs and I don’t want to write a new package, Ediff is the only horse to ride on.

Thus began my journey delving into the internals of Ediff to make a contemporary diff workflow for a modified repo file.

I got it to work. Here's a demo video (click on image) calling Ediff from the minibuffer with the function cc/ediff-revision. A similar demo using the mouse and context-menu-mode is shown here.

Screenshot of modified Emacs Ediff tuned to support a modified repo file.

Feels smoother, no? But to get this, I had to make a Devil’s bargain. Still here? Read on.

Ediff Variable Setup

Setup the following Ediff variables as shown in the table below:

Variable Setting Notes
ediff-keep-variants nil Kill variants upon quitting an Ediff session
ediff-split-window-function split-window-horizontally Show diffs side-by-side
ediff-window-setup-function ediff-setup-windows-plain Puts the control panel int same frame as the diff windows1

A Nasty Surprise

In my earlier post “Surprise and Emacs Defaults”, I described how to restore the window1 configuration in ediff-quit-hook that was taken from the Stack Exchange post helm - Restoring windows and layout after an Ediff session. There is a problem though: the suggested ediff-quit-hook addition will break the logic Ediff uses to kill ancillary buffers (for example ✷ediff-errors✷, ✷ediff-diff✷, ✷ediff-find-diff✷, and the diff control panel) upon quitting, particularly when ediff-keep-variants is set to nil. Again I’d observe that Ediff is quite profligate about creating state. If you are using the functions my-store-pre-ediff-winconfig and my-restore-pre-ediff-winconfig referenced in my earlier post, I recommend you replace that code with what I describe below.

Ediff Makeover: Support Diffing a Modified Repo File

To help clarify/sanity-check what I wanted to achieve with Ediff, I wrote a mini-PRD below.

User Story/Workflow

User has opened in the current buffer a file that is checked-in to a repository. User has modified that buffer and now wants to see the difference between said modifications and its checked-in counterpart in the current branch.

Requirements

  1. The user shall invoke above workflow using either a keyboard (M-x) or a mouse on a current buffer holding the version controlled file.
  2. If the buffer has not been saved to disk, the user must be prompted to save it before the diff operation proceeds.
  3. The user must not be prompted to select the version controlled file nor its current branch.
  4. If the file is not version controlled, no diff operation shall be made. A warning message shall be provided.
  5. If the buffer has no file associated with it, no diff operation shall be made. A warning message shall be provided.
  6. The diffs shall be displayed in accordance with ediff-split-window-function.
  7. The window1 configuration must be checkpointed such that it can be restored after the diff session is completed.
  8. Every workflow interaction must be in the same frame1.
  9. Code changes to Ediff functions are to not have adverse side-effects on Ediff behavior that is unrelated to the above workflow.

Implementation

The following shows all the code annotated with comments to support the above requirements. Note that the original Ediff function ediff-janitor is overridden to keep around the buffer holding the modified version controlled file.

Since a window1 configuration can be stored in an Emacs register, I use that to checkpoint and restore it, using a register identifier (🧊) that is unlikely to be typed in from a keyboard during interactive use (one less global variable!).

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
;;; cc-ediff-mode.el --- Ediff configuration for Charles Choi
;; ediff-mode

;;; Commentary:
;;

(require 'ediff)
;;; Code:
;; these defvars are here to let cc-ediff-mode.el compile clean
(defvar ediff-buffer-A)
(defvar ediff-buffer-B)
(defvar ediff-buffer-C)
(defvar ediff-merge-job)
(defvar ediff-ancestor-buffer)

;; CC: I set my Ediff variables in `custom-set-variables'
;; Use your own preference.
;; '(ediff-keep-variants nil)
;; '(ediff-split-window-function 'split-window-horizontally)
;; '(ediff-window-setup-function 'ediff-setup-windows-plain)

(defvar cc/ediff-revision-session-p nil
  "If t then `cc/ediff-revision-actual' has been called.
This state variable is used to insert added behavior to the overridden
function `ediff-janitor'.")

(defun cc/ediff-revision-from-menu (e)
  "Invoke `ediff-revision' on E with variable `buffer-file-name'."
  (interactive "e")
  (cc/ediff-revision))

(defun cc/ediff-revision ()
  "Run Ediff on the current `buffer-file-name' provided that it is `vc-registered'.
This function handles the interactive concerns found in `ediff-revision'.
This function will also test if a diff should apply to the current buffer."
  (interactive)
  (when (and (bound-and-true-p buffer-file-name)
             (vc-registered (buffer-file-name)))
    (if (and (buffer-modified-p)
             (y-or-n-p (format "Buffer %s is modified.  Save buffer? "
                               (buffer-name))))
      (save-buffer (current-buffer)))
    (message buffer-file-name)
    (cc/ediff-revision-actual))

  (cond ((not (bound-and-true-p buffer-file-name))
         (message (concat (buffer-name) " is not a file that can be diffed.")))
        ((not (vc-registered buffer-file-name))
         (message (concat buffer-file-name " is not under version control.")))))

(defun cc/ediff-revision-actual ()
  "Invoke Ediff logic to diff the modified repo file to its counterpart in the
current branch.
This function handles the actual diff behavior called by `ediff-revision'."
  (let ((rev1 "")
        (rev2 ""))
    (setq cc/ediff-revision-session-p t)
    (ediff-load-version-control)
    (funcall
     (intern (format "ediff-%S-internal" ediff-version-control-package))
     rev1 rev2 nil)))

(defun ediff-janitor (ask keep-variants)
  "Kill buffers A, B, and, possibly, C, if these buffers aren't modified.
In merge jobs, buffer C is not deleted here, but rather according to
`ediff-quit-merge-hook'.
ASK non-nil means ask the user whether to keep each unmodified buffer, unless
KEEP-VARIANTS is non-nil, in which case buffers are never killed.
A side effect of cleaning up may be that you should be careful when comparing
the same buffer in two separate Ediff sessions: quitting one of them might
delete this buffer in another session as well.

CC MODIFICATION: This method overrides the original Ediff function."
  (let ((ask (if (and (boundp 'cc/ediff-revision-session-p)
                      cc/ediff-revision-session-p)
                 nil
               ask)))
    (ediff-dispose-of-variant-according-to-user
     ediff-buffer-A 'A ask keep-variants)
    ;; !!!: CC Note: Test global state variable `cc/ediff-revision-session-p' to
    ;; determine if the modified repo file should be kept.
    ;; Guarding in place to hopefully avoid side-effects when `ediff-janitor' is
    ;; called from other Ediff functions. Informal testing has not revealed any
    ;; side-effects but YOLO.
    (if (and (boundp 'cc/ediff-revision-session-p)
             cc/ediff-revision-session-p)
        (ediff-dispose-of-variant-according-to-user
         ;; CC Note: keep-variants argument is hard-coded to t to keep
         ;; buffer holding modified repo file around.
         ediff-buffer-B 'B t t)
      (ediff-dispose-of-variant-according-to-user
       ediff-buffer-B 'B ask keep-variants))
    (if ediff-merge-job  ; don't del buf C if merging--del ancestor buf instead
        (ediff-dispose-of-variant-according-to-user
         ediff-ancestor-buffer 'Ancestor ask keep-variants)
      (ediff-dispose-of-variant-according-to-user
       ediff-buffer-C 'C ask keep-variants))
    ;; CC Note: Reset global state variable `cc/ediff-revision-session-p'.
    (if (and (boundp 'cc/ediff-revision-session-p)
             cc/ediff-revision-session-p)
        (setq cc/ediff-revision-session-p nil))))

(defun cc/stash-window-configuration-for-ediff ()
  "Store window configuration to register 🧊.
Use of emoji is to avoid potential use of keyboard character to reference
the register."
  (window-configuration-to-register ?🧊))

(defun cc/restore-window-configuration-for-ediff ()
  "Restore window configuration from register 🧊.
Use of emoji is to avoid potential use of keyboard character to reference
the register."
  (jump-to-register ?🧊))

(add-hook 'ediff-before-setup-hook #'cc/stash-window-configuration-for-ediff)
;; !!!: CC Note: Why this is not `ediff-quit-hook' I do not know. But this works
;; for cleaning up ancillary buffers on quitting an Ediff session.
(add-hook 'ediff-after-quit-hook-internal #'cc/restore-window-configuration-for-ediff)

(provide 'cc-ediff-mode)

;;; cc-ediff-mode.el ends here

With the above code loaded in your Emacs configuration, you can invoke cc/ediff-revision to your hearts content.

Summary

A common workflow in contemporary IDEs is to show the differences between a modified repo file and its checked-in counterpart. This post shows how you can do this in Emacs with Ediff, but at the cost of overriding ediff-janitor, being exposed to risk if its original behavior is ever changed. Try it out and let me know on Mastodon (Charles Choi 최 민수 (@kickingvegas@sfba.social) - SFBA.social) how well it works for you. Thanks for reading!

Acknowledgements

  • Thanks to q01 on #emacs IRC for identifying ediff-janitor as the starting point to achieve what I wanted from Ediff.
  • Thanks to all who participated in my informal poll of Ediff usage on Mastodon. Unsurprisingly, not many folk actually use Ediff to address the use-case described in this post, arguably because:
    • Magit can invoke Ediff and clean up after it, so folks go through Magit.
    • ediff-revision UX too abysmal.

References

Footnotes

1 Emacs UI definition

emacs

 

AboutMastodonInstagramGitHub

Feeds & TagsGet Captee for macOS

Powered by Pelican