Writing a git-annex plugin for ranger

I use git-annex to distribute and synchronize fairly large and mostly static files across different machines. However, being based on Git makes it pretty uncomfortable to use from the command line. So, why not integrating it into our favorite command line file manager, ranger? Because I struggled a bit with ranger’s internals, I will outline how I wrote the plugin.

Initialization

First of all, we need a place for our plugin. By default ranger imports every Python module from $XDG_CONFIG_HOME/ranger/plugins in lexicographic order. If $XDG_CONFIG_HOME is not set, ~/.config is used as the default alternative.

To use the plugin from within ranger, you need to provide code that hooks into one of the two methods provided by the ranger API. hook_init is called before the UI is ready, which means you can dump output on stdout, whereas the UI can be used in hook_ready. The suggested way is not to replace the original function but chaining up like this:

import ranger.api

old_hook_init = ranger.api.hook_init

def hook_init(fm):
    # setup
    return old_hook_init(fm)

ranger.api.hook_init = hook_init

Adding new commands

In the introductory post, I briefly explained how to write custom commands which you add to your commands.py file: You simply subclass from ranger.api.commands.Command and write code in the execute method. However, commands defined in a plugin are not automatically added to the global command list. For this you need to extend the commands dictionary of the file manager instance, i.e.

def hook_init(fm):
    fm.commands.commands['annex_copy'] = copy

When using the annex_copy command, tab-completion should cycle through all available remotes. This is done by returning an iterable in the tab method. In the execute method you can access arguments, by calls to the arg method:

class copy(ranger.api.commands.Command):
    def tab(self):
        return ('annex_copy {}'.format(r) for r in remotes)

    def execute(self):
        remote = self.arg(1)

Asynchronous calls

The git-annex plugin works on the current or currently selected list of files, which you can get via fm.env.get_selection(). To avoid blocking the UI while fetching large files, I use the CommandLoader to run the git annex commands in the background (thanks @hut). The loader emits a signal when the action is finished to which we subscribe in order to refresh the directory content:

class copy(ranger.api.commands.Command):
    def execute(self):
        remote = self.arg(1)

        def reload_dir():
            self.fm.thisdir.unload()
            self.fm.thisdir.load_content()

        for path in (str(p) for p in self.fm.env.get_selection()):
            fname = os.path.basename(path)
            cmd = ['git', 'annex', 'copy', '-t', remote, fname]
            loader = CommandLoader(cmd, "annex_copy to remote")
            loader.signal_bind('after', reload_dir)
            fm.loader.add(loader)

Long running actions can be cancelled by the user, so if you need to clean up you should add that extra code to the cancel method.

Wrap up

That’s it, a plugin that registers new commands for interacting with git-annex an asynchronous way. Bug reports and pull requests are welcome.