summaryrefslogtreecommitdiffstats
path: root/contrib/utils
diff options
context:
space:
mode:
Diffstat (limited to 'contrib/utils')
-rwxr-xr-xcontrib/utils/ad_groups.sh40
-rwxr-xr-xcontrib/utils/gitolite-local136
-rwxr-xr-xcontrib/utils/ipa_groups.pl229
-rwxr-xr-xcontrib/utils/ldap_groups.sh22
-rwxr-xr-xcontrib/utils/rc-format-v3.4212
-rwxr-xr-xcontrib/utils/testconf130
6 files changed, 769 insertions, 0 deletions
diff --git a/contrib/utils/ad_groups.sh b/contrib/utils/ad_groups.sh
new file mode 100755
index 0000000..cc86692
--- /dev/null
+++ b/contrib/utils/ad_groups.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+
+# author derived from: damien.nozay@gmail.com
+# author: Jonathan Gray
+
+# Given a username,
+# Provides a space-separated list of groups that the user is a member of.
+#
+# see http://gitolite.com/gitolite/conf.html#ldap
+# GROUPLIST_PGM => /path/to/ldap_groups.sh
+
+# Be sure to add your domain CA to the trusted certificates in /etc/openldap/ldap.conf using the TLS_CACERT option or you'll get certificate validation errors
+
+ldaphost='ldap://AD.DC1.local:3268,ldap://AD.DC2.local:3268,ldap://AD.DC3.local:3268'
+ldapuser='git@domain.local'
+ldappass='super.secret.password'
+binddn='dc=domain,dc=local'
+username=$1;
+
+# I don't assume your users share a common OU, so I search the entire domain
+ldap_groups() {
+ # Go fetch the full user CN as it could be anywhere inside the DN
+ usercn=$(
+ ldapsearch -ZZ -H ${ldaphost} -D ${ldapuser} -w ${ldappass} -b ${binddn} -LLL -o ldif-wrap=no "(sAMAccountName=${username})" \
+ | grep "^dn:" \
+ | perl -pe 's|dn: (.*?)|\1|'
+ )
+
+ # Using a proprietary AD extension, let the AD Controller resolve all nested group memberships
+ # http://ddkonline.blogspot.com/2010/05/how-to-recursively-get-group-membership.html
+ # Also, substitute spaces in AD group names for '_' since gitolite expects a space separated list
+ echo $(
+ ldapsearch -ZZ -H ${ldaphost} -D ${ldapuser} -w ${ldappass} -b ${binddn} -LLL -o ldif-wrap=no "(member:1.2.840.113556.1.4.1941:=${usercn})" \
+ | grep "^dn:" \
+ | perl -pe 's|dn: CN=(.*?),.*|\1|' \
+ | sed 's/ /_/g'
+ )
+}
+
+ldap_groups $@
diff --git a/contrib/utils/gitolite-local b/contrib/utils/gitolite-local
new file mode 100755
index 0000000..903b868
--- /dev/null
+++ b/contrib/utils/gitolite-local
@@ -0,0 +1,136 @@
+#!/bin/bash
+
+# ----------------------------------------------------------------------
+# change these lines to suit
+testconf=$HOME/GITOLITE-TESTCONF
+gitolite_url=https://github.com/sitaramc/gitolite
+ # change it to something local for frequent use
+ # gitolite_url=file:///tmp/gitolite.git
+
+# ----------------------------------------------------------------------
+# Usage: gitolite-local <options>
+#
+# Test your gitolite.conf rule lists on your LOCAL machine (without even
+# pushing to the server!)
+#
+# (one-time)
+#
+# 1. put this code somewhere in your $PATH if you wish
+# 2. edit the line near the top of the script if you want to use some other
+# directory than the default, for "testconf".
+# 2. prepare the "testconf" directory by running:
+# gitolite-local prep
+#
+# (lather, rinse, repeat)
+#
+# 1. edit the conf (see notes below for more)
+# gitolite-local edit
+# 2. compile the conf
+# gitolite-local compile
+# 3. check permissions using "info" command:
+# gitolite-local info USERNAME
+# 4. check permissions using "access" command:
+# gitolite-local access <options for gitolite access command>
+# 5. clone, fetch, and push if you like!
+# gitolite-local clone <username> <reponame> <other options for clone>
+# gitolite-local fetch <username> <options for fetch>
+# gitolite-local push <username> <options for push>
+#
+# note on editing the conf: you don't have to use the edit command; you can
+# also directly edit '.gitolite/conf/gitolite.conf' in the 'testconf'
+# directory. You'll need to do that if your gitolite conf consists of more
+# than just one file (like if you have includes, etc.)
+#
+# note on the clone command: most of the options won't work for clone, unless
+# git is ok with them being placed *after* the repo name.
+
+# ----------------------------------------------------------------------
+die() { echo "$@" >&2; exit 1; }
+usage() { perl -lne 'print substr($_, 2) if /^# Usage/../^$/' < $0; exit 1; }
+[ -z "$1" ] && usage
+
+# ----------------------------------------------------------------------
+if [ $1 == prep ]
+then
+ set -e
+
+ [ -d $testconf ] && die "directory '$testconf' already exists"
+
+ mkdir $testconf
+ cd $testconf
+
+ export HOME=$PWD
+
+ echo getting gitolite source...
+ git clone $gitolite_url gitolite
+ echo
+
+ echo installing gitolite...
+ gitolite/install >/dev/null
+ echo
+
+ echo setting up gitolite...
+ export PATH=$PWD/gitolite/src:$PATH
+ gitolite setup -a admin
+ echo
+
+ exit 0
+fi
+
+od=$PWD
+cd $testconf
+export HOME=$PWD
+export PATH=$PWD/gitolite/src:$PATH
+
+if [ $1 = edit ]
+then
+ editor=${EDITOR:-vim}
+ $editor .gitolite/conf/gitolite.conf
+elif [ $1 = compile ]
+then
+ gitolite compile
+elif [ $1 = compile+ ]
+then
+ gitolite compile\; gitolite trigger POST_COMPILE
+elif [ $1 = info ]
+then
+ shift
+ user=$1
+ shift
+
+ GL_USER=$user gitolite info "$@"
+elif [ $1 = access ]
+then
+ shift
+
+ gitolite access "$@"
+elif [ $1 = clone ]
+then
+ shift
+ export G3T_USER=$1
+ shift
+
+ cd $od
+ export GL_BINDIR=$HOME/gitolite/t
+ # or you could do it the long way, using 'gitolite query-rc GL_BINDIR'
+ repo=$1; shift
+ git clone --upload-pack=$GL_BINDIR/gitolite-upload-pack file:///$repo "$@"
+elif [ $1 = fetch ]
+then
+ shift
+ export G3T_USER=$1
+ shift
+
+ cd $od
+ export GL_BINDIR=$HOME/gitolite/t
+ git fetch --upload-pack=$GL_BINDIR/gitolite-upload-pack "$@"
+elif [ $1 = push ]
+then
+ shift
+ export G3T_USER=$1
+ shift
+
+ cd $od
+ export GL_BINDIR=$HOME/gitolite/t
+ git push --receive-pack=$GL_BINDIR/gitolite-receive-pack "$@"
+fi
diff --git a/contrib/utils/ipa_groups.pl b/contrib/utils/ipa_groups.pl
new file mode 100755
index 0000000..9cffa40
--- /dev/null
+++ b/contrib/utils/ipa_groups.pl
@@ -0,0 +1,229 @@
+#!/usr/bin/env perl
+#
+# ipa_groups.pl
+#
+# See perldoc for usage
+#
+use Net::LDAP;
+use Net::LDAP::Control::Paged;
+use Net::LDAP::Constant qw(LDAP_CONTROL_PAGED);
+use strict;
+use warnings;
+
+my $usage = <<EOD;
+Usage: $0 \$uid
+This script returns a list of groups that \$uid is a member of
+EOD
+
+my $uid = shift or die $usage;
+
+## CONFIG SECTION
+
+# If you want to do plain-text LDAP, then set ldap_opts to an empty hash and
+# then set protocols of ldap_hosts to ldap://
+my @ldap_hosts = [
+ 'ldaps://auth-ldap-001.prod.example.net',
+ 'ldaps://auth-ldap-002.prod.example.net',
+];
+my %ldap_opts = (
+ verify => 'require',
+ cafile => '/etc/pki/tls/certs/prod.example.net_CA.crt'
+);
+
+# Base DN to search
+my $base_dn = 'dc=prod,dc=example,dc=net';
+
+# User for binding to LDAP server with
+my $user = 'uid=svc_gitolite_bind,cn=sysaccounts,cn=etc,dc=prod,dc=example,dc=net';
+my $pass = 'reallysecurepasswordstringhere';
+
+## Below variables should not need to be changed under normal circumstances
+
+# OU where groups are located. Anything return that is not within this OU is
+# removed from results. This OU is static on FreeIPA so will only need updating
+# if you want to support other LDAP servers. This is a regex so can be set to
+# anything you want (E.G '.*').
+my $groups_ou = qr/cn=groups,cn=accounts,${base_dn}$/;
+
+# strip path - if you want to return the full path of the group object then set
+# this to 0
+my $strip_group_paths = 1;
+
+# Number of seconds before timeout (for each query)
+my $timeout=5;
+
+# user object class
+my $user_oclass = 'person';
+
+# group attribute
+my $group_attrib = 'memberOf';
+
+## END OF CONFIG SECTION
+
+# Catch timeouts here
+$SIG{'ALRM'} = sub {
+ die "LDAP queries timed out";
+};
+
+alarm($timeout);
+
+# try each server until timeout is reached, has very fast failover if a server
+# is totally unreachable
+my $ldap = Net::LDAP->new(@ldap_hosts, %ldap_opts) ||
+ die "Error connecting to specified servers: $@ \n";
+
+my $mesg = $ldap->bind(
+ dn => $user,
+ password => $pass
+);
+
+if ($mesg->code()) {
+ die ("error:", $mesg->code(),"\n",
+ "error name: ",$mesg->error_name(),"\n",
+ "error text: ",$mesg->error_text(),"\n");
+}
+
+# How many LDAP query results to grab for each paged round
+# Set to under 1000 to limit load on LDAP server
+my $page = Net::LDAP::Control::Paged->new(size => 500);
+
+# @queries is an array or array references. We initially fill it up with one
+# arrayref (The first LDAP search) and then add more during the execution.
+# First start by resolving the group.
+my @queries = [ ( base => $base_dn,
+ filter => "(&(objectClass=${user_oclass})(uid=${uid}))",
+ control => [ $page ],
+) ];
+
+# array to store groups matching $groups_ou
+my @verified_groups;
+
+# Loop until @queries is empty...
+foreach my $queryref (@queries) {
+
+ # set cookie for paged querying
+ my $cookie;
+ alarm($timeout);
+ while (1) {
+ # Perform search
+ my $mesg = $ldap->search( @{$queryref} );
+
+ foreach my $entry ($mesg->entries) {
+ my @groups = $entry->get_value($group_attrib);
+ # find any groups matching $groups_ou regex and push onto $verified_groups array
+ foreach my $group (@groups) {
+ if ($group =~ /$groups_ou/) {
+ push @verified_groups, $group;
+ }
+ }
+ }
+
+ # Only continue on LDAP_SUCCESS
+ $mesg->code and last;
+
+ # Get cookie from paged control
+ my($resp) = $mesg->control(LDAP_CONTROL_PAGED) or last;
+ $cookie = $resp->cookie or last;
+
+ # Set cookie in paged control
+ $page->cookie($cookie);
+ } # END: while(1)
+
+ # Reset the page control for the next query
+ $page->cookie(undef);
+
+ if ($cookie) {
+ # We had an abnormal exit, so let the server know we do not want any more
+ $page->cookie($cookie);
+ $page->size(0);
+ $ldap->search( @{$queryref} );
+ # Then die
+ die("LDAP query unsuccessful");
+ }
+
+} # END: foreach my $queryref (...)
+
+# we're assuming that the group object looks something like
+# cn=name,cn=groups,cn=accounts,dc=X,dc=Y and there are no ',' chars in group
+# names
+if ($strip_group_paths) {
+ for (@verified_groups) { s/^cn=([^,]+),.*$/$1/g };
+}
+
+foreach my $verified (@verified_groups) {
+ print $verified . "\n";
+}
+
+alarm(0);
+
+__END__
+
+=head1 NAME
+
+ipa_groups.pl
+
+=head2 VERSION
+
+0.1.1
+
+=head2 DESCRIPTION
+
+Connects to one or more FreeIPA-based LDAP servers in a first-reachable fashion and returns a newline separated list of groups for a given uid. Uses memberOf attribute and thus supports nested groups.
+
+=head2 AUTHOR
+
+Richard Clark <rclark@telnic.org>
+
+=head2 FreeIPA vs Generic LDAP
+
+This script uses regular LDAP, but is focussed on support for FreeIPA, where users and groups are generally contained within single OUs, and memberOf attributes within the user object are enumerated with a recursive list of groups that the user is a member of.
+
+It is mostly impossible to provide generic out of the box LDAP support due to varying schemas, supported extensions and overlays between implementations.
+
+=head2 CONFIGURATION
+
+=head3 LDAP Bind Account
+
+To setup an LDAP bind user in FreeIPA, create a svc_gitolite_bind.ldif file along the following lines:
+
+ dn: uid=svc_gitolite_bind,cn=sysaccounts,cn=etc,dc=prod,dc=example,dc=net
+ changetype: add
+ objectclass: account
+ objectclass: simplesecurityobject
+ uid: svc_gitolite_bind
+ userPassword: reallysecurepasswordstringhere
+ passwordExpirationTime: 20150201010101Z
+ nsIdleTimeout: 0
+
+Then create the service account user, using ldapmodify authenticating as the the directory manager account (or other acccount with appropriate privileges to the sysaccounts OU):
+
+ $ ldapmodify -h auth-ldap-001.prod.example.net -Z -x -D "cn=Directory Manager" -W -f svc_gitolite_bind.ldif
+
+=head3 Required Configuration
+
+The following variables within the C<## CONFIG SECTION ##> need to be configured before the script will work.
+
+C<@ldap_hosts> - Should be set to an array of URIs or hosts to connect to. Net::LDAP will attempt to connect to each host in this list and stop on the first reachable server. The example shows TLS-supported URIs, if you want to use plain-text LDAP then set the protocol part of the URI to LDAP:// or just provide hostnames as this is the default behavior for Net::LDAP.
+
+C<%ldap_opts> - To use LDAP-over-TLS, provide the CA certificate for your LDAP servers. To use plain-text LDAP, then empty this hash of it's values or provide other valid arguments to Net::LDAP.
+
+C<%base_dn> - This can either be set to the 'true' base DN for your directory, or alternatively you can set it the the OU that your users are located in (E.G cn=users,cn=accounts,dc=prod,dc=example,dc=net).
+
+C<$user> - Provide the full Distinguished Name of your directory bind account as configured above.
+
+C<$pass> - Set to password of your directory bind account as configured above.
+
+=head3 Optional Configuration
+
+C<$groups_ou> - By default this is a regular expression matching the default groups OU. Any groups not matching this regular expression are removed from the search results. This is because FreeIPA enumerates non-user type groups (E.G system, sudoers, policy and other types) within the memberOf attribute. To change this behavior, set C<$groups_ou> to a regex matching anything you want (E.G: '.*').
+
+C<$strip_group_paths> - If this is set to perl boolean false (E.G '0') then groups will be returned in DN format. Default is true, so just the short/CN value is returned.
+
+C<$timeout> - Number of seconds to wait for an LDAP query before determining that it has failed and trying the next server in the list. This does not affect unreachable servers, which are failed immediately.
+
+C<$user_oclass> - Object class of the user to search for.
+
+C<$group_attrib> - Attribute to search for within the user object that denotes the membership of a group.
+
+=cut
+
diff --git a/contrib/utils/ldap_groups.sh b/contrib/utils/ldap_groups.sh
new file mode 100755
index 0000000..01bf5ee
--- /dev/null
+++ b/contrib/utils/ldap_groups.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+
+# author: damien.nozay@gmail.com
+
+# Given a username,
+# Provides a space-separated list of groups that the user is a member of.
+#
+# see http://gitolite.com/gitolite/conf.html#ldap
+# GROUPLIST_PGM => /path/to/ldap_groups.sh
+
+ldap_groups() {
+ username=$1;
+ # this relies on openldap / pam_ldap to be configured properly on your
+ # system. my system allows anonymous search.
+ echo $(
+ ldapsearch -x -LLL "(&(objectClass=posixGroup)(memberUid=${username}))" cn \
+ | grep "^cn" \
+ | cut -d' ' -f2
+ );
+}
+
+ldap_groups $@
diff --git a/contrib/utils/rc-format-v3.4 b/contrib/utils/rc-format-v3.4
new file mode 100755
index 0000000..1a11737
--- /dev/null
+++ b/contrib/utils/rc-format-v3.4
@@ -0,0 +1,212 @@
+#!/usr/bin/perl
+
+# help with rc file format change at v3.4 -- help upgrade v3 rc files from
+# v3.3 and below to the new v3.4 and above format
+
+# once you upgrade gitolite past 3.4, you may want to use the new rc file
+# format, because it's really much nicer (just to recap: the old format will
+# still work, in fact internally the new format gets converted to the old
+# format before actually being used. However, the new format makes it much
+# easier to enable and disable features).
+
+# PLEASE SEE WARNINGS BELOW
+
+# this program helps you upgrade your rc file.
+
+# STEPS
+# cd gitolite-source-repo-clone
+# contrib/utils/upgrade-rc33 /path/to/old.gitolite.rc > new.gitolite.rc
+
+# WARNINGS
+# make sure you also READ ALL ERROR/WARNING MESSAGES GENERATED
+# make sure you EXAMINE THE FILE AND CHECK THAT EVERYTHING LOOKS GOOD before using it
+# be especially careful about
+# variables which contains single/double quotes or other special characters
+# variables that stretch across multiple lines
+# features which take arguments (like 'renice')
+# new features you've enabled which don't exist in the default rc
+
+# ----------------------------------------------------------------------
+
+use strict;
+use warnings;
+use 5.10.0;
+use Cwd;
+use Data::Dumper;
+$Data::Dumper::Terse = 1;
+$Data::Dumper::Indent = 1;
+$Data::Dumper::Sortkeys = 1;
+
+BEGIN {
+ $ENV{HOME} = getcwd;
+ $ENV{HOME} .= "/.home.rcupgrade.$$";
+ mkdir $ENV{HOME} or die "mkdir '$ENV{HOME}': $!\n";
+}
+
+END {
+ system("rm -rf ./.home.rcupgrade.$$");
+}
+
+use lib "./src/lib";
+use Gitolite::Rc;
+{
+ no warnings 'redefine';
+ sub Gitolite::Common::gl_log { }
+}
+
+# ----------------------------------------------------------------------
+
+# everything happens inside a fresh v3.6.1+ gitolite clone; no other
+# directories are used.
+
+# the old rc file to be migrated is somewhere *else* and is supplied as a
+# command line argument.
+
+# ----------------------------------------------------------------------
+
+my $oldrc = shift or die "need old rc filename as arg-1\n";
+
+{
+
+ package rcup;
+ do $oldrc;
+}
+
+my %oldrc;
+{
+ no warnings 'once';
+ %oldrc = %rcup::RC;
+}
+
+delete $rcup::{RC};
+{
+ my @extra = sort keys %rcup::;
+ warn "**** WARNING ****\nyou have variables declared outside the %RC hash; you must handle them manually\n" if @extra;
+}
+
+# this is the new rc text being built up
+my $newrc = glrc('default-text');
+
+# ----------------------------------------------------------------------
+
+# default disable all features in newrc
+map { disable( $_, 'sq' ) } (qw(help desc info perms writable ssh-authkeys git-config daemon gitweb));
+# map { disable($_, '') } (qw(GIT_CONFIG_KEYS));
+
+set_s('HOSTNAME');
+set_s( 'UMASK', 'num' );
+set_s( 'GIT_CONFIG_KEYS', 'sq' );
+set_s( 'LOG_EXTRA', 'num' );
+set_s( 'DISPLAY_CPU_TIME', 'num' );
+set_s( 'CPU_TIME_WARN_LIMIT', 'num' );
+set_s('SITE_INFO');
+
+set_s('LOCAL_CODE');
+
+if ( $oldrc{WRITER_CAN_UPDATE_DESC} ) {
+ die "tell Sitaram he changed the default rc too much" unless $newrc =~ /rc variables used by various features$/m;
+ $newrc =~ s/(rc variables used by various features\n)/$1\n # backward compat\n WRITER_CAN_UPDATE_DESC => 1,\n/;
+
+ delete $oldrc{WRITER_CAN_UPDATE_DESC};
+}
+
+if ( $oldrc{ROLES} ) {
+ my $t = '';
+ for my $r ( sort keys %{ $oldrc{ROLES} } ) {
+ $t .= ( " " x 8 ) . $r . ( " " x ( 28 - length($r) ) ) . "=> 1,\n";
+ }
+ $newrc =~ s/(ROLES *=> *\{\n).*?\n( *\},)/$1$t$2/s;
+
+ delete $oldrc{ROLES};
+}
+
+if ( $oldrc{DEFAULT_ROLE_PERMS} ) {
+ warn "DEFAULT_ROLE_PERMS has been replaced by per repo option\nsee http://gitolite.com/gitolite/wild.html\n";
+ delete $oldrc{DEFAULT_ROLE_PERMS};
+}
+
+# the following is a bit like the reverse of what the new Rc.pm does...
+
+for my $l ( split /\n/, $Gitolite::Rc::non_core ) {
+ next if $l =~ /^ *#/ or $l !~ /\S/;
+
+ my ( $name, $where, $module ) = split ' ', $l;
+ $module = $name if $module eq '.';
+ ( $module = $name ) .= "::" . lc($where) if $module eq '::';
+
+ # if you find $module as an element of $where, enable $name
+ enable($name) if miw( $module, $where );
+}
+
+# now deal with commands
+if ( $oldrc{COMMANDS} ) {
+ for my $c ( sort keys %{ $oldrc{COMMANDS} } ) {
+ if ( $oldrc{COMMANDS}{$c} == 1 ) {
+ enable($c);
+ # we don't handle anything else right (and so far only git-annex
+ # is affected, as far as I remember)
+
+ delete $oldrc{COMMANDS}{$c};
+ }
+ }
+}
+
+print $newrc;
+
+for my $w (qw(INPUT POST_COMPILE PRE_CREATE ACCESS_1 POST_GIT PRE_GIT ACCESS_2 POST_CREATE SYNTACTIC_SUGAR)) {
+ delete $oldrc{$w} unless scalar( @{ $oldrc{$w} } );
+}
+delete $oldrc{COMMANDS} unless scalar keys %{ $oldrc{COMMANDS} };
+
+exit 0 unless %oldrc;
+
+warn "the following parts of the old rc were NOT converted:\n";
+print STDERR Dumper \%oldrc;
+
+# ----------------------------------------------------------------------
+
+# set scalars that the new file defaults to "commented out"
+sub set_s {
+ my ( $key, $type ) = @_;
+ $type ||= '';
+ return unless exists $oldrc{$key};
+
+ # special treatment for UMASK
+ $oldrc{$key} = substr( "00" . sprintf( "%o", $oldrc{$key} ), -4 ) if ( $key eq 'UMASK' );
+
+ $newrc =~ s/# $key /$key /; # uncomment if needed
+ if ( $type eq 'num' ) {
+ $newrc =~ s/$key ( *=> *).*/$key $1$oldrc{$key},/;
+ } elsif ( $type eq 'sq' ) {
+ $newrc =~ s/$key ( *=> *).*/$key $1'$oldrc{$key}',/;
+ } else {
+ $newrc =~ s/$key ( *=> *).*/$key $1"$oldrc{$key}",/;
+ }
+
+ delete $oldrc{$key};
+}
+
+sub disable {
+ my ( $key, $type ) = @_;
+ if ( $type eq 'sq' ) {
+ $newrc =~ s/^( *)'$key'/$1# '$key'/m;
+ } else {
+ $newrc =~ s/^( *)$key\b/$1# $key/m;
+ }
+}
+
+sub enable {
+ my $key = shift;
+ $newrc =~ s/^( *)# *'$key'/$1'$key'/m;
+ return if $newrc =~ /^ *'$key'/m;
+ $newrc =~ s/(add new commands here.*\n)/$1 '$key',\n/;
+}
+
+sub miw {
+ my ( $m, $w ) = @_;
+ return 0 unless $oldrc{$w};
+ my @in = @{ $oldrc{$w} };
+ my @out = grep { !/^$m$/ } @{ $oldrc{$w} };
+ $oldrc{$w} = \@out;
+ return not scalar(@in) == scalar(@out);
+}
diff --git a/contrib/utils/testconf b/contrib/utils/testconf
new file mode 100755
index 0000000..03580f9
--- /dev/null
+++ b/contrib/utils/testconf
@@ -0,0 +1,130 @@
+#!/bin/bash
+
+# this is meant to be run on your *client* (where you edit and commit files
+# in a gitolite-admin *working* repo), not on the gitolite server.
+#
+# TO USE
+# ======
+
+# To use this, first upgrade gitolite to the latest on the server; you need at
+# least v3.6.7.
+#
+# Then, on the client:
+#
+# 1. copy this file (contrib/utils/testconf in the latest gitolite) to
+# somewhere in your $PATH
+# 2. modify the following lines if you wish (default should be fine for
+# most people):
+
+ # a semi-permanent area to play in (please delete it manually if you want to start afresh).
+ testconf=$HOME/GITOLITE-TESTCONF
+ # the gitolite source code
+ gitolite_url=https://github.com/sitaramc/gitolite
+
+# 3. go to your gitolite-admin clone and make suitable changes; see example
+# below. No need to push to the server, yet.
+# 4. run 'testconf`
+#
+# CAVEAT: include files are not handled the same way gitolite parsing handles
+# them -- we just cat all the conf files together, in sorted order.
+#
+# If the tests ran OK, push your changes to the server as usual.
+
+# EXAMPLE changes to gitolite.conf
+# ================================
+# Say you have these rules in the conf file:
+#
+# repo foo
+# R = u1
+# RW = u2
+# RW+ = u3
+#
+# To create test code for this, add the following lines to the conf file.
+#
+# =begin testconf
+# # you can put arbitrary bash code here, but a simple example follows
+#
+# ok() { "$@" && echo ok || echo "not ok ($*)"; }
+# nok() { ! "$@" && echo ok || echo "not ok ($*)"; }
+#
+# ok gitolite access -q foo u1 R
+# nok gitolite access -q foo u1 W
+#
+# ok gitolite access -q foo u2 W
+# nok gitolite access -q foo u2 +
+#
+# ok gitolite access -q foo u3 +
+# =end
+#
+# Note that you can actually put in any bash code between the 'begin' and
+# 'end' lines; the above is just a useful sample/template.
+#
+# Because of the 'begin' and 'end' lines, gitolite will ignore those lines
+# when processing the conf file ON THE SERVER.
+#
+# (optional) TAP compliance
+# =========================
+# if you add a line 'echo 1..5' (in this case, since there are 5 ok/nok lines;
+# you will certainly have more) to the top the file, you can run
+#
+# prove `which testconf`
+#
+# which will give you a much nicer output. The only issue is if you have
+# include files, you will need to put that in the file whose name is sorted
+# first!
+#
+# Using a non-default ".gitolite.rc"
+# ==================================
+#
+# If your conf needs a non-default `~/.gitolite.rc`, copy the file you need as
+# "testconf.gitolite.rc" in the root directory of the gitolite-admin clone
+# where you are running "testconf". (Whether you commit this file to the
+# gitolite-admin repo, or keep it local/untracked, is your call).
+
+# ----------------------------------------------------------------------
+od=$PWD
+
+# prep
+
+mkdir -p $testconf
+cd $testconf
+
+export HOME=$PWD
+export PATH=$PWD/gitolite/src:$PATH
+
+[ -d gitolite ] || {
+
+ echo getting gitolite source...
+ git clone $gitolite_url gitolite
+ echo
+
+ echo installing gitolite...
+ gitolite/install >/dev/null
+ echo
+
+ echo setting up gitolite...
+ gitolite setup -a admin
+ echo
+
+}
+
+# copy conf from $od
+
+rm -rf $testconf/.gitolite/conf
+mkdir -p $testconf/.gitolite/conf
+cp -a $od/conf/* $testconf/.gitolite/conf/
+
+# copy rc from $od, if it exists
+[ -f $od/testconf.gitolite.rc ] && cp $od/testconf.gitolite.rc $testconf/.gitolite.rc
+
+# compile+
+
+gitolite compile
+gitolite trigger POST_COMPILE
+
+# snarf bits of code from conf files and run them
+
+cat `find $testconf/.gitolite/conf -type f -name "*.conf" | sort` |
+ perl -ne '
+ print if /^=begin testconf$/ .. /^=end$/ and not /^=(begin|end)/;
+ ' | /bin/bash