Effortless Ctags with Git

08 Aug 2011

In case you’ve been living under a programming rock, Ctags (specifically Exuberant Ctags, not the BSD version shipped with OS X) indexes source code to make it easy to jump to functions, variables, classes, and other identifiers in (among other editors) Vim (see :help tags). The major downside to Ctags is having to manually rebuild that index all the time. That’s where the not-so-novel idea of re-indexing from various Git commit hooks comes in.

Git hooks are repository specific. Some would recommend using a script to install said hooks into a given repository. But for me, that’s too manual. Let’s set up a default set of hooks that Git will use as a template when creating or cloning a repository (requires Git 1.7.1 or newer):

git config --global init.templatedir '~/.git_template'
mkdir -p ~/.git_template/hooks

Now onto the first hook, which isn’t actually a hook at all, but rather a script the other hooks will call. Place in .git_template/hooks/ctags and mark as executable:

set -e
trap "rm -f .git/tags.$$" EXIT
ctags --tag-relative -Rf.git/tags.$$ --exclude=.git --languages=-javascript,sql
mv .git/tags.$$ .git/tags

Making this a separate script makes it easy to invoke .git/hooks/ctags for a one-off re-index (or git config --global alias.ctags '!.git/hooks/ctags', then git ctags), as well as easy to edit for that special case repository that needs a different set of options to ctags. For example, I might want to re-enable indexing for JavaScript or SQL files, which I’ve disabled here because I’ve found both to be of limited value and noisy in the warning department.

I stick the tags file in .git because if fugitive.vim is installed, Vim will be configured to look for it there automatically, regardless of your current working directory. Plus, you don’t need to worry about adding it to .gitignore.

Here come the hooks. Mark all four of them executable and place them in .git_template/hooks. Use this same content for the first three: post-commit, post-merge, and post-checkout (actually my post-checkout hook includes hookup as well).

.git/hooks/ctags >/dev/null 2>&1 &

I’ve forked it into the background so that my Git workflow remains as latency-free as possible.

One more hook that oftentimes gets overlooked: post-rewrite. This is fired after git commit --amend and git rebase, but the former is already covered by post-commit. Here’s mine:

case "$1" in
  rebase) exec .git/hooks/post-merge ;;

Once you get this all set up, you can use git init in existing repositories to copy these hooks in.

So what does this get you? Any new repositories you create or clone will be immediately indexed with Ctags and set up to re-index every time you check out, commit, merge, or rebase. Basically, you’ll never have to manually run Ctags on a Git repository again.

Is tpope asleep?

10 Mar 2011

I was long overdue for a stupid project, so I’ve put my current sleep status online at istpopeasleep.com. Here’s how I did it:

  1. I bought a Zeo Personal Sleep Coach, which is an awesome little device for tracking your sleeping habits. Basically, you wear a headband while you sleep, and the base unit records your sleep state to an SD card. Although it also has a mysterious port on the back.

  2. I built this USB cable to connect to the Zeo. I have no idea why they couldn’t just use a more standard connector, but it wasn’t that hard to build.

  3. I wrote Zerbo, a Ruby library for reading from the USB cable.

  4. I wrote a stupid web app, API, and corresponding client. (The authentication mechanism is “simple” so it’s not open source.)

Now I hear you saying, “That’s awesome, tpope, but does it have any practical applications?” Well, no, not yet, but check out all these ideas:

  1. Use Tasker to automatically mute my phone when I’m trying to sleep.

  2. And so on.

Bundle While You Git

07 Feb 2011

Update: hookup does all this for you, plus migrates your database!

I recently discovered RVM’s ability to create a project .rvmrc when running rvm use --rvmrc. Commented out by default is a section for automatically running Bundler when cding to your project:

# Bundle while reducing excess noise.
printf "Bundling your gems. This may take a few minutes on a fresh clone.\n"
bundle | grep -v '^Using ' | grep -v ' is complete' | sed '/^$/d'

I excitedly enabled this, as the less I have to run the bundle command manually, the better. But it quickly became apparent that this is the wrong time to bundle. Nothing changes when I cd into a project, so why would the bundle be out of date? Except after the initial clone, this trick did nothing but eat valuable seconds (sometimes minutes). What I really needed was a way to bundle right after a git checkout or git pull --rebase (or throughout a git bisect, if we want to get really ambitious).

As luck would have it, Git provides a post-checkout hook that runs in all those cases. Let’s take a stab at it, shall we? Create .git/hooks/post-checkout with the following content and chmod +x it:

if [ -f Gemfile ] && command -v bundle >/dev/null; then
  # $GIT_DIR can cause chaos if Bundler in turn invokes Git.
  # Unset it in a subshell so it remains set later in the hook.
  (unset GIT_DIR; exec bundle)
  # Even if bundling fails, exit from `git checkout` with a zero status.

Awesome? You bet, but it runs on every HEAD switch, which means we still lose valuable seconds even when the Gemfile isn’t updated. Luckily, the post-checkout hook receives the old and new HEAD as arguments, so it’s easy to check for changes to the Gemfile, Gemfile.lock, or the project’s gem spec:

if [ $1 = 0000000000000000000000000000000000000000 ]; then
  # Special case for initial clone: compare to empty directory.
if [ -f Gemfile ] && command -v bundle >/dev/null &&
  git diff --name-only $old $2 | egrep -q '^Gemfile|\.gemspec$'
  (unset GIT_DIR; exec bundle)

What else? Well, we could silence some of the noise like RVM does:

if [ $1 = 0000000000000000000000000000000000000000 ]; then
if [ -f Gemfile ] && command -v bundle >/dev/null &&
  git diff --name-only $old $2 | egrep -q '^Gemfile|\.gemspec$'
  (unset GIT_DIR; exec bundle) | grep -v '^Using ' | grep -v ' is complete'

Left as an exercise to the reader: installing Bundler if it’s not already installed (personally, I still do that from the .rvmrc), and configuring Git to install that hook into new and freshly cloned repositories.

Last but not least, here’s a complimentary, complementary pre-commit hook:

git diff --exit-code --cached -- Gemfile Gemfile.lock >/dev/null || bundle check

Reduce Your Rails Schema Conflicts

24 Oct 2010

Update: hookup does all this for you and more!

Tired of conflicts on your schema file that boil down to the version specification at the top? Me too. After reading Will Leinweber’s article on resolving Gemfile.lock conflicts automatically, I decided I could do better. I started with a tweet and a gist, but after seeing the reception it received, I’m expanding it to a full-on blog post.

The snippet below goes in your ~/.gitconfig, and is basically a simple algorithm that resolves a schema version conflict by picking the higher number. Personally, I’d rather have an ugly mess in my Git config file than a second file I have to copy around everywhere or a gem I have to install.

[merge "railsschema"]
	name = newer Rails schema version
	driver = "ruby -e '\n\
		system %(git), %(merge-file), %(--marker-size=%L), %(%A), %(%O), %(%B)\n\
		b = File.read(%(%A))\n\
		b.sub!(/^<+ .*\\nActiveRecord::Schema\\.define.:version => (\\d+). do\\n=+\\nActiveRecord::Schema\\.define.:version => (\\d+). do\\n>+ .*/) do\n\
		  %(ActiveRecord::Schema.define(:version => #{[$1, $2].max}) do)\n\
		File.open(%(%A), %(w)) {|f| f.write(b)}\n\
		exit 1 if b.include?(%(<)*%L)'"

Now all that’s left is to tell Git to use that algorithm for db/schema.rb. You have to do this on a per project basis. You can either commit it to .gitattributes or keep it locally in .git/info/attributes.

db/schema.rb merge=railsschema

That’s it. I haven’t beat on it too hard yet, but it’s handled simple conflicts beautifully.

Smack a Ho.st

04 Mar 2010

With lvh.me being shorter and less offensive, I’ve decided this joke has run its course and am letting the domain expire.