Git trick #481: Prevent accidentally pushing into git instead of Gerrit

Some while ago I wrote about a little [git hook that automatically sets up your commit author identity][1]  after git clone based on the remote origin address. Recently I learned that in git 2.8 a new pre-push hook was introduced, and I immediately knew it will fix my second biggest pain point: accidentally pushing directly into git instead of Gerrit.

If you often switch between different projects where some use Gerrit for code review and some don’t, it’s very easy to just mistakenly do

git push master

when in fact you wanted to

git push HEAD:refs/for/master

There are some tricks how to make it harder for you to accidentally do this, like creating a “gpush” alias that pushes to refs/for/master and disabling pushing into the ‘origin’ remote by changing the push URL to something invalid. That, however, is not perfect because there are still ways how to by-pass it. And it becomes complicated if you use more than one remote and it’s clumsy if you sometimes do want to push directly into git (for example to submit a large patch series).

With a custom pre-push hook, we can check if the remote that we are pushing into is a Gerrit instance and then check if the remote ref that we are pushing into is a “Gerrit ref” (refs/for/foo) instead of a regular branch and we can have a nice “Are you sure you want to do this?” prompt:

#!/usr/bin/python3
# -*- coding: utf-8 -*-
#
# (C) 2017 Daniel Vrátil <dvratil@kde.org>
# License: GPL

import os
import sys

def remoteIsGerrit(remoteName, remoteUrl):
    # if the remote is called "gerrit", assume it's Gerrit
    if 'gerrit' in remoteName:
        return True
    # if the remote URL contains the default Gerrit port, assume it's Gerrit
    if ':29418/' in remoteUrl:
        return True

    # TODO: Add custom checks to match your non-standard Gerrit configuration
    return False
def main():
    # name and URL of the remote we are pushing into is passed as arguments
    if not remoteIsGerrit(sys.argv[1], sys.argv[2]):
        # If we are not pushing into gerrit, then simply allow the push
        return
    # The pushed refs are passed in via stdin
    for line in sys.stdin:
        # line = "localRef localRev remoteRef remoteRev"
        remoteRef = line.split(' ')[2]
        # Check if the remoteRef contains the typical Gerrit 'refs/for/foo'.
        if not remoteRef.startswith('refs/for/'):
            print('!!')
            print('!! You are pushing directly into git instead of Gerrit !!')
            print('!! Do you want to continue? [y/N] ', end = '', flush = True)
            if open('/dev/tty', 'rb').readline().decode().strip().lower() == 'y':
                return
            else:
                sys.exit(1)

if __name__ == "__main__":
    main()

Save this a file as “pre-push” and move it into .git/hooks/ folder in your local repository clone. Remember to make the script executable.

Here is how it works: trying to push into “gerrit” remote to branch “5.9” directly gets intercepted by our new hook and if you press ‘n’ the push gets aborted. If I would’ve pressed ‘y’, then the push would proceed.

$ git push gerrit 5.9
Enter passphrase for key '/home/dvratil/.ssh/id_rsa.qt':  
!! 
!! You are pushing directly into git instead of Gerrit !! 
!! Do you want to continue? [y/N] n
error: failed to push some refs to 'ssh://dvratil@codereview.qt-project.org:29418/qt/qtbase.git'</pre>
<p>Now when we try to push to the correct ref (`refs/for/5.9`) the hook accepts the push without any complaints:</p>
<pre>$ git push gerrit HEAD:refs/for/5.9
Counting objects: 6, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (4/4), 407 bytes, done.
Total 4 (delta 2), reused 0 (delta 0)
remote: Resolving deltas: 100% (2/2)
remote: Processing changes: new: 1, refs: 1, done    
remote: 
remote: New Changes:
remote:   https://codereview.qt-project.org/......
remote: 
To ssh://dvratil@codereview.qt-project.org:29418/qt/qtbase
 * [new branch] HEAD -&gt; refs/for/5.9

To have the hook automatically copied into every new repository that you clone, save it as “pre-push” into .git-templates/hooks/ and run the following command:

git config --global init.templatedir ~/.git-templates

Git will automatically copy everything from the ‘templatedir’ into the .git directory after every new git clone, so you don’t need to bother with doing that manually. Unfortunately for all your existing checkouts, you have to copy the hook manually

[1] /2015/12/git-trick-628-automatically-set-commit-author-based-on-repo-url

Git trick #628: automatically set commit author based on repo URL

If you have more than one email identity that you use to commit to different projects you have to remember to change it in .git/config every time you git clone a new repository. I suck at remembering things and it’s been annoying me for a long time that I kept pushing commits with wrong email addresses to wrong repositories.

I can’t believe I am the only one having this problem, but I could not find anything on the interwebs so I just fixed it myself and I’m posting it here so that maybe hopefuly someone else will find it useful too :).

The trick is very simple: we create a post-checkout hook that will check the value of user.email in .git/config and set it to whatever we want based on URL of the “origin” remote.  Why post-checkout? Because there’s no post-clone hook, but git automatically checkouts master after clone so the hook gets executed. It also gets executed every time you run git checkout by hand but the overhead is minimal and we have a guard against overwriting the identity in case it’s already set.

#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# (C) 2015 Daniel Vrátil &lt;dvratil@kde.org&gt;
# License: GPL
#
# Requires: Python 2 or 3 and compatible GitPython
#
# https://github.com/gitpython-developers/GitPython
import git
import ConfigParser
import os
import sys

repo = git.Repo(os.getcwd())

# Don't do anything if an identity is already configured in this
# repo's .git/config
config = repo.config_reader(config_level = 'repository')
try:
    # The value of user.email is non-empty, stop here
    if config.get_value('user', 'email'):
        sys.exit(0)
except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
    # Section or option does not exist, continue
    pass
origin = repo.remote('origin')
if not origin:
    print('** Failed to detect remote origin, identity not updated! **')
    sys.exit(0)
# This is where you adjust the code to fit your needs
if 'kde.org' in origin.url or origin.url.startswith('kde:'):
    email = 'dvratil@kde.org'
elif 'fedoraproject.org' in origin.url:
    email = 'dvratil@fedoraproject.org'
elif 'kdab.com' in origin.url:
    email = 'daniel.vratil@kdab.com'
else:
    print('** Failed to detect identity! **')
    sys.exit(0)
# Write the option to .git/config
config = repo.config_writer()
config.set_value('user', 'email', email)
config.release()
print('** User identity for this repository set to \'%s\' **' % email)

To install it, just copy the script above to ~/.git-templates/hooks/post-checkout, make it executable and run

git config --global init.templatedir ~/.git-templates

All hooks from templatedir are automatically copied into .git/hooks when a new repository is created (git init or git clone) - this way the hook will get automatically deployed to every new repo.

And here’s a proof that it works :-)

[dvratil@Odin ~/devel/KDE]
$ git clone kde:kasync
Cloning into 'kasync'...
remote: Counting objects: 450, done.
remote: Compressing objects: 100% (173/173), done.
remote: Total 450 (delta 285), reused 431 (delta 273)
Receiving objects: 100% (450/450), 116.44 KiB | 0 bytes/s, done.
Resolving deltas: 100% (285/285), done.
Checking connectivity... done.
** User identity for this repository set to 'dvratil@kde.org' **

[dvratil@Odin ~/packaging/fedpkg]
$ git clone ssh://dvratil@pkgs.fedoraproject.org/gammaray
Cloning into 'gammaray'...
remote: Counting objects: 287, done.
remote: Compressing objects: 100% (286/286), done.
remote: Total 287 (delta 113), reused 0 (delta 0)
Receiving objects: 100% (287/287), 57.24 KiB | 0 bytes/s, done.
Resolving deltas: 100% (113/113), done.
Checking connectivity... done.
** User identity for this repository set to 'dvratil@fedoraproject.org' **

Update 1: added utf-8 coding (thanks, Andrea) Update 2: changed shebang to more common /usr/bin/python (/bin/python is rather Fedora-specific), added “Requires” comment to top of the script (thanks, Derek)