10
\$\begingroup\$

Description

If you install this hook in your repository and push branch foo-automerge to this repository, this hook will merge this branch into working copy if:

  • currently checked out branch is named foo.
  • working directory is clean
  • merge would be fast-forward

Reasons why I need it are outlined on my question on SO so I'll just ask you to trust me here.

I'm mostly looking for advice from people on if and how this will explode and mess up my repositories. However, any criticisms on the python are also welcome.

This will be used in the simple case where there are two repositories A and B, one on my laptop one on the grid. I'll configure the grid repository to checkout on every push. Both repositories are mine, and the program in the repository does not need any post install step.

Known issues

List of issues I know there are here but I don't really know to resolve:

  • If repository I'm pushing too is actually a submodule (and so GIT_DIR is other_repo/.git/modules/something) this will hoke and will require me to set by hand FORCE_WORKING_DIR. I don't know how easily find where is the working copy for submodule.
  • Branch names containing non-ascii characters might be treated wrong.

I have put in on github gists, but have it also here:

#!/usr/bin/env python
"""

Description
-----------

Update hook that automatically merges changes inside the repository.
It's creation was sparked by
`my question on SO <https://stackoverflow.com/q/25207942/7918>`_.
Especially by `this answer <https://stackoverflow.com/a/25209198/7918>`_.

Logic is as follows:
--------------------

If repository recleives a push we will merge pushed branch to curren branch if
following conditions are met:

* This repo is not bare. It is bare **push will fail**
* pushed branch has a special suffix (by default ``-automerge``). If
 pushed branch has no suffix push will not be aborted.
* Currently checked out branch is named as branch we push to without suffix.
 so if push was to ``foo-automerge``, we will merge iff checked out branch
 is ``foo``. If other branch is checked out push will not be aborted.
* Working directory is clean. If working directory is dirty (and previous
 condition are met) **push will fail**.
* We can guess working copy directory. If not can't  **push will fail**.
* Merge is fast-forward if not **push will fail**.

Installation:
-------------

Copy this file to .git/hooks, and link it to **both**, ``update``
and ``pre-update``. You need to add it to both hooks because ``pre-update`` is
needed to fail push when error conditions are met, and ``update`` is needed
to actually do merge.

Add executable permissions to both hooks.



"""
from __future__ import unicode_literals, print_function

import os, sys, subprocess, logging

logging.basicConfig(level=logging.INFO)
"""
Set do ``DEBUG`` for debug info, or to ``ERROR`` to get error conditions only.
"""

AUTOMERGE_SUFFIX = "-automerge"

FORCE_WORKING_DIR = None
"""
You may override working copy by setting this to a path
"""

CAN_GUESS_GIT_WORK_TREE = True
"""
If false we will not quess working tree directory, so either set ``GIT_WORK_TREE``
envvar or ``FORCE_WORKING_DIR`` python variable.
"""


def get_working_copy_dir():
    """
    Tries to guess and returns working copy directory.
    """

    if FORCE_WORKING_DIR:
        return FORCE_WORKING_DIR

    working_copy = os.environ.get("GIT_WORK_TREE", None)
    if working_copy is not None:
        return working_copy
    if not CAN_GUESS_GIT_WORK_TREE:
        logging.error("Can't quess working copy dir and 'GIT_WORK_TREE' was "
                      "not set")
        sys.exit(1)
    path = os.path.abspath(os.environ['GIT_DIR'])
    working_copy, git_dir = os.path.split(path)
    git_dir = git_dir.strip()
    if git_dir != ".git":
        logging.error("Can't guess working copy dir, because GIT_DIR does not "
                      "point to directory named '.git'")
        sys.exit(1)
    return working_copy


def strip_branch(branch):
    """
    Removes git decorations from branch name.

    >>> strip_branch("refs/heads/feature/master-automerge")
    'feature/master-automerge'
    >>> strip_branch("refs/heads/master-automerge")
    'master-automerge'
    >>> strip_branch('refs/heads/master-automerge ')
    'master-automerge'
    """
    return "/".join(branch.split("/")[2:]).strip()


def checked_out_branch_is_valid(pushed_branch):
    """
    Check whether checked out branch mathes branch we are pushing to.

    """
    pushed_branch_sans_automerge = pushed_branch[:-len(AUTOMERGE_SUFFIX)]
    checked_out_branch = get_checked_out_branch()

    if checked_out_branch != pushed_branch_sans_automerge:
        logging.info("Other branch is checked out, will not merge working copy")
        sys.exit(0)


def is_this_repo_bare():
    """
    :return: True if this repository is bare, False otherwise.
    """
    result = subprocess.check_output("git rev-parse --is-bare-repository".split()).strip()
    if result == "true":
        return True
    if result == "false":
        return False
    raise ValueError("Can't guess whether this repository is bare")


def git_subprocess(args):
    """
    Utility function to call git process in the working copy directory.
    :param list args: list of string containing command to call
    """
    new_cwd = get_working_copy_dir()
    logging.debug("Working copy dir %s", new_cwd)

    new_env = dict(os.environ)
    new_env["GIT_DIR"] = os.path.abspath(os.environ['GIT_DIR'])
    return subprocess.check_output(args, cwd=new_cwd, env=new_env)


def get_checked_out_branch():
    """
    :return: String with currently checked out branch
    """
    return git_subprocess("git rev-parse --symbolic-full-name --abbrev-ref HEAD".split()).strip()


def check_working_directory_clean():
    """
    This is adapted from here: https://stackoverflow.com/a/3879077/7918

    Will exit id working copy is dirty.
    """
    try:
        # Update the index
        git_subprocess("git update-index -q --ignore-submodules --refresh".split())
        # Disallow unstaged changes in the working tree
        git_subprocess("git diff-files --quiet --ignore-submodules --".split())
        # Disallow uncommitted changes in the index
        git_subprocess("git diff-index --cached --quiet HEAD --ignore-submodules --".split())
    except subprocess.CalledProcessError:
        logging.error("Working directory here is not clean. Will not merge")
        sys.exit(1)


def validate(pushed_branch):

    """
    If we can't merge ``pushed_branch`` to current branch this function will
    print error and call ``sys.exit`` with apropriate exit code. 
    """

    if is_this_repo_bare():
        logging.error("Sorry this hook will not work on bare repository")
        sys.exit(1)

    logging.debug("Updating branch %s", pushed_branch)
    if not pushed_branch.endswith(AUTOMERGE_SUFFIX):
        logging.debug("Branch has no automerge suffix, will not automatically merge")
        sys.exit(0)

    checked_out_branch_is_valid(pushed_branch)

    check_working_directory_clean()

    logging.debug("OK to merge")


def update_hook(pushed_branch):

    validate(pushed_branch)


def post_update_hook(pushed_branch):

    validate(pushed_branch)

    try:
        git_subprocess(['git', 'merge', '--ff-only', pushed_branch])
    except subprocess.CalledProcessError:
        logging.error("Couldn't merge --- merge was not fast forward")
        sys.exit(1)


def main():

    script_name = sys.argv[0]
    pushed_branch = sys.argv[1]

    pushed_branch = strip_branch(pushed_branch)

    if script_name.endswith('/update'):
        update_hook(pushed_branch)
    if script_name.endswith('/post-update'):

        post_update_hook(pushed_branch)

if __name__ == "__main__":
    main()
\$\endgroup\$
4
\$\begingroup\$

I'm mostly looking for advice from git people on if and how this will explode and mess up my repositories.

As far as the Git operations go, I don't see big problems or dangers. That's not to say there aren't any.

These commands have a trailing --, which seems odd but maybe you have your reasons or I'm missing something:

git diff-files --quiet --ignore-submodules --
git diff-index --cached --quiet HEAD --ignore-submodules --

However, any criticisms on the python are also welcome.

Overall the code is very nice. It mostly passes PEP8 as well. The documentation is great. But some improvements are possible.

Avoid multiple imports on one line

Instead of this:

import os, sys, subprocess, logging

Break it to multiple lines:

import sys
import subprocess
import logging
import os

Put comments on the previous line

I'm not familiar with this commenting style, and use it a lot:

logging.basicConfig(level=logging.INFO)
"""
Set do ``DEBUG`` for debug info, or to ``ERROR`` to get error conditions only.
"""

I would much prefer to have it this way:

# Set do ``DEBUG`` for debug info, or to ``ERROR`` to get error conditions only.
logging.basicConfig(level=logging.INFO)

Simplify

This can be written simpler:

working_copy = os.environ.get("GIT_WORK_TREE", None)
if working_copy is not None:
    return working_copy

Like this:

working_copy = os.environ.get("GIT_WORK_TREE")
if working_copy:
    return working_copy

Duplicated code

This kind of code appears a lot:

logging.error("some message")
sys.exit(1)

I suggest to add a helper method:

def fatal(message, exit_code=1):
    logging.error(message)
    sys.exit(exit_code)

Another pattern that appears a lot:

git_subprocess("some command".split()).strip()

I suggest to change the git_subprocess to take care of the splitting and stripping too.

Coding style

script_name = sys.argv[0]
pushed_branch = sys.argv[1]

pushed_branch = strip_branch(pushed_branch)

if script_name.endswith('/update'):
    update_hook(pushed_branch)
if script_name.endswith('/post-update'):

    post_update_hook(pushed_branch)

The pushed_branch can be simply created in one step, and since script_name cannot end with /update and /post-update at the same time, it would be better to write with an elif:

script_name = sys.argv[0]
pushed_branch = strip_branch(sys.argv[1])

if script_name.endswith('/update'):
    update_hook(pushed_branch)
elif script_name.endswith('/post-update'):
    post_update_hook(pushed_branch)
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.