Emacs Folder Actions

Normally, we edit code on our workstation, and then copy it to production-like server for final verification…which seldom works, right? We fix the discovered problems directly on that server (quick turn-around). Don’t forget to copy back the updates…as soon, we aren’t sure which system has the latest version.

My work flow is only slightly different.

I edit locally in Emacs, but write the file directly to the server with Tramp (C-x C-w). I then make the modifications in Emacs, and all saves from that point write directly to the server.

Once it works on the server, I write the file back to my local workstation for committing. The only advantage of my work flow is that the latest version is in an Emacs buffer. However, I’m seldom sure if I have written the latest to the local file system.

On Macs, the Finder can trigger a script due to any change of a file in a directory (Apple calls these Folder Actions). I was tempted to try and learn enough of this so I could rsync all local file changes to my system in the lab.

Seemed easier for me to just modify Emacs.

Simple Start

My Emacs function would asynchronously copy with each file write. I tried to think generally, since this approach might be useful elsewhere, so the function looks in the same directory as the saved file, and if it found a script, it would execute it.

The .on-save script could contain an scp to copy the file, or (since it is rsync), copy the whole directory, for instance:

rsync -avz * 10.52.52.232:projecta

Here is my first version of a function to look in the current directory, and run a .on-save script asynchronously:

(defun ha/folder-action-save-hook ()
  "A file save hook that will look for a script in the same
directory, called .on-save.  It will then execute that script
asynchronously."
  (let* ((filename (buffer-file-name))
         (dir      (file-name-directory filename))
         (script   (concat dir ".on-save"))
         (cmd      (concat script " " filename)))
    (write-file filename nil)
    (when (file-exists-p script)
      (async-shell-command cmd))))

To get this running, I attach the function hook to the local-write-file-hooks:

(add-hook 'local-write-file-hooks 'ha/folder-action-save-hook)

While the code is easy to understand, it wasn’t quite general enough for a blog posting…

Advanced Version

I’d just as soon have the .on-save script be at the top-level of my source tree, and saving any file in any directory would find the script, and execute it, so our new version calls the function, locate-dominating-file, which walks up the tree looking for our script:

(defun ha/folder-action-save-hook ()
  "A file save hook that will look for a script in the same
directory, called .on-save.  It will then execute that script
asynchronously."
  (interactive)
  (let* ((filename (buffer-file-name))
         (parent   (locate-dominating-file filename ".on-save"))
         (cmd      (concat parent ".on-save " filename)))
    (write-file filename nil)

    ;; Need to re-add the hook to the local file:
    (add-hook 'local-write-file-hooks 'ha/folder-action-save-hook)

    (when parent
      (async-shell-command cmd))))

Also, noticed that once a local-write-file-hook runs, it is removed, so, we set it again.

Single Buffer On-Save

Instead of creating a script for a project, I sometimes want an on-save script for a single file. Easy to add a command as a buffer-local variable:

(defun ha/on-save (script)
  (interactive "sScript to run on save: ")
  (lexical-let ((run-this (replace-regexp-in-string "%" (buffer-file-name) script)))
    (message "We'll be running this:  %s" run-this)
    (add-hook 'local-write-file-hooks
              (lambda () (async-shell-command run-this)) nil t)))

Yeah, the lexical-let stores the command, script into the lambda clojure. Remember, if you get stuck with making a bad save hook with lambdas, remove it via this magic:

(remove-hook 'local-write-file-hooks (car local-write-file-hooks))

Summary

A single function adds a powerful feature to my work-flow in a platform independent way (although, my .on-save scripts may be os-dependent).

Here is an example script. The first parameter is the name of the file I’m saving, and in this case, I ignore it.

# We need to ignore the special file name on $1
HOST="10.98.19.229"

DIR=$(dirname $0)
if [ $DIR = "-bash" ]
then
    DIR=$(pwd)
fi

rsync -avz --exclude .git $DIR $HOST:

Since this script is a bit more complicated that it should, I may enhance how I call the script to pass in the current directory, and maybe extra command line arguments.