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.