Mimsy Were the Borogoves

Hacks: Articles about programming in Python, Perl, Swift, BASIC, and whatever else I happen to feel like hacking at.

Using version control with AppleScripts

Jerry Stratton, July 21, 2021

Save Clipboard changes in Mercurial: An example of changes to the Save Clipboard AppleScript in SourceTree.; AppleScript; Mercurial; version control

Tracking an AppleScript in Mercurial.

In 42 Astoundingly Useful Scripts and Automations for the Macintosh I devoted one diversion to the importance of version control. One thing I left unmentioned except through omission is that AppleScripts created with Script Editor (as most will be, due to Script Editor’s verification and testing ability) can’t really be tracked in version control. They aren’t text files, so while changes will be noted, what those changes are will not. Change tracking pretty much requires line-oriented text files, and Script Editor files are not text files, at least in any sense meaningful to Mercurial or Git.

I have a lot of AppleScripts in my User Scripts folder for several applications, as well as a handful of AppleScripts in my Finder window toolbars and my Favorites folder. I’ve always been disappointed that I can’t track changes to them in Mercurial. And I’ve always been worried that I don’t have a backup of them in easily-readable text format.

While working on this problem, I noticed that I have a lot of scripts I’ll never read again because the format is no longer valid on macOS.

I’ve considered, and occasionally tried, keeping two copies of every script I write: when I’m done editing a script in Script Editor, copy the text to a text file and save that as a readable, future-proofed version. It always works for a very short period and then I forget, and the two sets of scripts get out of sync. As I was writing this post, I discovered an abandoned .hg folder in my user scripts folder, last touched on September 20, 2014.

It occurred to me while writing the Save Clipboard script that there are so many commands beginning with osa there must be one for getting the text of an AppleScript out of a .scpt file or .app folder.1 And to ask the question is to answer it: osadecompile does exactly that. This makes it trivial to write a script that keeps the two locations—the live location and the text repository—in sync. The text backup can then be tracked easily in any version control system, including Mercurial.

Use a text editor to save this Perl script to your ~/bin directory, perhaps using the edit script from 42 Astounding Scripts.

[toggle code]

  • #!/usr/bin/perl
  • # copy AppleScripts as text to a backup folder and Mercurial repository
  • # Jerry Stratton astoundingscripts.com
  • $HOME = $ENV{'HOME'};
  • #where should the decompiled AppleScripts be stored?
  • $repository = "$HOME/Documents/Backups/AppleScripts";
  • mkdir $repository if !-d $repository;
  • #what folders hold AppleScripts?
  • %scriptFolders = (
    • 'User Scripts', "$HOME/Library/Scripts",
    • 'Programming', "$HOME/Documents/Programming/Applescripts",
  • );
  • while (($section, $folder) = each %scriptFolders) {
    • chdir $folder or die("Unable to find $section folder $folder: $!.\n");
    • decompileFolder('.', $section);
  • }
  • sub decompileFolder {
    • my $folder = shift;
    • my $section = shift;
    • opendir(my $handler, $folder);
    • my @files = readdir($handler);
    • closedir($handler);
    • foreach my $file (@files) {
      • next if $file =~ /^\./;
      • my $source = "$folder/$file";
      • if ($file =~ /\.(scpt|app|applescript)$/) {
        • my $backupFolder = "$repository/$section";
        • my $destination = "$backupFolder/$file";
        • #text files shouldn't have the .app extension
        • $destination =~ s/\.app$/ (Application).txt/;
        • if ($codeToSave = decompileIfChanged($source, $destination)) {
          • print "Backing up $source.\n";
          • open (my $destinationHandle, '>', $destination) or die("Cannot open $destination for writing.\n");
          • print $destinationHandle $codeToSave;
          • close $destinationHandle;
        • }
      • } elsif (-d $source) {
        • decompileFolder($source, "$section/$file") if $file !~ /^Old /;
      • } else {
        • print "UNKNOWN ITEM: $source\n";
      • }
    • }
  • }
  • #decompile from source if the source has changed
  • sub decompileIfChanged {
    • my $source = shift;
    • my $destination = shift;
    • #decompile source if the destination doesn't exist or the source is younger
    • if (!-e $destination || -M $source < -M $destination) {
      • my $escapedSource = quotemeta($source);
      • my $escapedDestination = quotemeta($destination);
      • my $sourceCode = `/usr/bin/osadecompile $escapedSource`;
      • die("There is no code for $source\n") if $sourceCode eq '';
      • #always save if the destination doesn't exist
      • return $sourceCode if !-e $destination;
      • #otherwise, check to see if the source is different than the destination
      • my $destinationCode = `/bin/cat $escapedDestination`;
      • if ($sourceCode ne $destinationCode) {
        • return $sourceCode;
      • }
    • }
  • }

I call this “osaBackup”.

The only drawback is that I have to run the script every time I make a change if I want the change to be fresh in my mind. I’m very unlikely to remember to do that. So it goes in crontab and I’ll have to remember what it was I changed or added when I commit the changes in Mercurial later. Usually the next morning when I see a notice from cron that an AppleScript file changed and was copied over. And I remember (if I’m lucky), oh yeah, I changed that file yesterday to handle xyz correctly. But it does mean always having text versions of the scripts2 and tracking changes.

This is a very simple script. It changes the working directory to the folder where my AppleScripts are stored, and then walks that directory for everything that’s changed. If it sees something it doesn’t recognize, it says so. Otherwise, it saves the output of osadecompile to the destination repository. Except for .app folders, it leaves the extension the same. Script Editor handles text .scpt and .applescript files fine.

It only decompiles an AppleScript if the script file is younger than the destination and the source has changed, or if the destination doesn’t exist. It checks that the source has changed because AppleScript files can change without changing the source code. When a script is run, if that script format can store properties the script will be marked as changed. This is true even if the script doesn’t have any properties—it’s saving more about the state than just the value of properties.

I have two places where I store AppleScripts. The obvious is in the User Scripts folder in ~/Library/Scripts. This is where all of the User Scripts in Script Menu reside. The other is in an AppleScripts folder in ~/Documents/Programming. This script backs up both of those areas; because there are multiple areas, I have a $section variable that gets used for where in the repository this folder of files should go. It’s set to “User Scripts” for ~/Library/Scripts and “Programming” for ~/Documents/Programming/AppleScripts.

You can probably leave the User Scripts line alone, but you’ll need to change the second line to point to your own AppleScripts.3 You will also want to change the $repository path to where the decompiled scripts are saved.

There are more complicated ways of handling version control for AppleScripts; Git, for example, can set up a pre- and post- processing command by filetype for check-in and check-out. For my purposes, however, I’m the only person editing my scripts, so I don’t need that complication. This technique is simpler and solves two problems at once: tracking changes, and keeping text backups.

Now if we could just do the same for Automator scripts!

  1. AppleScripts saved as applications are stored like any other application on the Mac: they are folders with the .app extension; the folder contains any resources the application needs, such as icons and the actual code.

  2. When an application is deleted, its dictionary will no longer be available. So some code will get changed from (for example, since iPhoto no longer exists) “album” to “«class ipal»”. But when this happens, you’ll see it in your version control and be able to revert the change and/or remove the original script so it doesn’t get backed up any more.

  3. If you don’t currently store all of your non-User Scripts AppleScripts in a single location, you may want to consider it. Besides being more useful for this particular script, it also makes it a lot easier to find a script when you want to modify it after a few years of use.

  1. <- Save clipboard script
  2. Caption this! ->