yet more effort toward keeping config out of git

01/03/2011

It seems that I spend most of my waking life concerned about keeping usernames and passwords out of version control. It causes at least 117% of the merge/rebase conflicts that I come across every day, and I'm getting pretty tired of it, to tell you the truth.

My latest genius idea involves the much-lauded pre-commit hook in git. To save your mouse-clickin' finger; the pre-commit hook is executed before git-commit does anything dangerous, and if you exit non-zero, it'll stop the commit. That's pretty useful in my book.

So, here's the idea. You do some work involving something that needs a config file. You commit it with blank config. Lovely. Now, you add your configuration to the files that need it, but don't commit. Instead, format a patch of the changes to your working copy. I use the script below:

1 #!/bin/bash
2 if [ $# -ne 1 ]; then
3  echo "Supply a patch name."
4  exit
5 fi
6 git diff --no-prefix --no-ext-diff > $@

I save my patch to the following location: ~/Patches/repo.directory.branchname.patch, then install this pre-commit hook:

 1 #!/usr/bin/env ruby
 2 
 3 dir = File.expand_path( File.dirname('.') )
 4 repo = dir.split('/').last
 5 branch = `git branch | grep '^*'`.to_s.gsub(/^\*/ , '').strip
 6 patchname = "#{repo}.#{branch}.patch"
 7 patchdir = File.expand_path( '~/Patches' )
 8 patchpath = "#{patchdir}/#{patchname}"
 9 
10 if File.exists? patchpath
11  # try to dry-run removing the patch - if it spits shit out that doesn't
12  # contain the word "FAILED", means the patch was already removed so we
13  # can continue
14  #
15  # if the patch runs silently or contains the word "FAILED" it means
16  # something's up
17  dry = `patch -s --no-backup --dry-run -p0 -R < "#{patchpath}"`.to_s.strip
18  if dry == ''
19  puts 'There\'s a patch that you should remove. Remove it then commit.'
20  puts "Patch File: #{patchpath}"
21  exit 1
22  elsif dry.index( 'FAILED' ) != nil
23  puts 'A broken patch was found. Fix it before proceeding.'
24  puts "Patch File: #{patchpath}"
25  exit 1
26  end
27 end
28 exit 0

This basically dry-runs the removal of the appropriate patch. If the patch would exit without complaint, that means that the patch could be applied and what you're committing contains configuration, so the commit fails. If the output of the dry-run contains the word "FAILED", that means that the patch is invalid, so we err on the side of caution and fail. If the patch is noisy, but doesn't contain the word fail, we can safely assume that the patch has already been removed and we're OK to continue exiting.

I'm sure that next week, there'll be a totally new, genius way to solve this problem. For now, however, this is working really well.

For ease, I have the following git commands, also:

git-apatch

 1 #!/usr/bin/env ruby
 2 
 3 dir = File.expandpath( </span><span class="Constant">git rev-parse --show-toplevel</span><span class="Delimiter"> )
 4 repo = dir.split('/').last.strip
 5 branch = </span><span class="Constant">git branch | grep '^*'</span><span class="Delimiter">.tos.gsub(/^*/ , '').strip
 6 patchname = "#{repo}.#{branch}.patch"
 7 patchdir = File.expand_path( '~/Patches' )
 8 patchpath = "#{patchdir}/#{patchname}"
 9 
10 if File.exists? patchpath
11  puts </span><span class="Constant">patch -p0 < &quot;</span><span class="Delimiter">#{</span>patchpath<span class="Delimiter">}</span><span class="Constant">&quot;</span><span class="Delimiter">
12 else
13  puts "No patch in #{patchpath}"
14 end

git-rmpatch

 1 #!/usr/bin/env ruby
 2 
 3 dir = File.expandpath( </span><span class="Constant">git rev-parse --show-toplevel</span><span class="Delimiter"> )
 4 repo = dir.split('/').last.strip
 5 branch = </span><span class="Constant">git branch | grep '^*'</span><span class="Delimiter">.tos.gsub(/^*/ , '').strip
 6 patchname = "#{repo}.#{branch}.patch"
 7 patchdir = File.expand_path( '~/Patches' )
 8 patchpath = "#{patchdir}/#{patchname}"
 9 
10 if File.exists? patchpath
11  puts </span><span class="Constant">patch -p0 -R < &quot;</span><span class="Delimiter">#{</span>patchpath<span class="Delimiter">}</span><span class="Constant">&quot;</span><span class="Delimiter">
12 else
13  puts "No patch in #{patchpath}"
14 end

All these do is look for a patch to be replied or moved for the current repo/branch and apply or remove it, failing noisily if the patch doesn't exist. I realise they share some code that could be extracted, but for the sake of easy display, this is what the scripts basically contain.

The only thing I think that could make this more useful would be to detect which files the patch would modify, apply the patch and unstage those files to allow the commit to proceed, thus causing fewer interruptions. That's a nice-to-have, though. Absent-minded as I am, this has already saved me from committing config twice today!

If you've got a different approach that works well for you, I'd love to hear about it on the twitters