# Automate branch merging with git

I wanted to learn how to use python to replace bash, and found this old post from 2008 or 2009. The output of git has changed a bit, though, so I decided to remake it.

I also decided to try out docopt and sh. Everything from sh I guess can be replaced with subprocess or os, but I thought it looked like an extremely convenient module.

Most of the code is logging and error messages. Due to attempting to follow PEP-8, I wrapped my lines by 80 characters.

Note that the documentation style is numpydoc.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Automate merging of git branches
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""
File: merge_branch.py
Author: Jean Nassar
Email: jeannassar5@gmail.com
Github: https://github.com/masasin
Description: Automate merging of git branches

Note that the default upstream branch is develop.

This code uses docopt and sh, both of which can be downloaded from PyPi. It is
compatible with Python versions 2 and 3.

Inspired by: http://www.jperla.com/blog/post/a-clean-python-shell-script

Usage:
merge_branch.py -u | -b [--upstream=<branch>]
merge_branch.py -h | --help
merge_branch.py --version

Options:
-h --help                Show this screen.
--version                Show version.
-u --merge-upstream      Merge the latest upstream into the current branch.
-b --merge-branch        Merge the current branch into upstream; force -u.
-U --upstream=<branch>   The branch to be merged. [default: develop]

"""
__version__ = "0.1"

import re
import logging

from docopt import docopt
import sh
from sh import git

class MergerError(Exception):
pass

def main():
args = docopt(__doc__, version=__version__)

upstream = args["--upstream"]
current = get_current_branch()

if args["--merge-upstream"]:
merge_upstream(upstream, current)
elif args["--merge-branch"]:
merge_branch(upstream, current)

def get_current_branch():
"""
Get the current branch.

Raises
------
RuntimeError
If the git status could not be obtained (e.g. the directory is not a git
repository).
MergerError
If there are changes to be committed.

"""
try:
status = str(git("status"))
except sh.ErrorReturnCode as e:
raise RuntimeError(e.stderr.decode())

match = re.match("On branch (\w+)", status)
current = match.group(1)

logging.info("In {curr} branch".format(curr=current))

if status.endswith("nothing to commit, working directory clean\n"):
logging.debug("Directory clean in {} branch".format(current))
else:
raise MergerError("Directory not clean, must commit:\n"
"{status}".format(status=status))
return current

def merge_upstream(upstream, current):
"""
Merge the latest upstream into the current branch.

Parameters
----------
upstream : str
The name of the branch to be merged.
current : str
The name of the current branch

Raises
------
RuntimeError
If the upstream and current branches are identical.
MergerError
If a branch cannot be brought up to date.

"""
if current == upstream:
raise RuntimeError("You must be in the branch you want to merge, "
"not the upstream branch ({}).".format(upstream))

logging.info("Switching to {upstream} branch".format(upstream=upstream))
git("checkout", upstream)
git("pull")
logging.info("Pulled latest changes from origin into {}".format(upstream))
logging.debug("Ensuring {} has the latest changes".format(upstream))
pull_result = git("pull")
if "up-to-date" in pull_result:
logging.debug("Local copy up-to-date")
else:
raise MergerError("Local copy of {upstream} was not up to date:\n"
.format(upstream)
+ "{pull_result}".format(pull_result=pull_result))

logging.info("Switching back to {curr} branch".format(curr=current))
git("checkout", current)
git("merge", upstream)
logging.info("Merged latest {upstream} changes into {curr} branch"
.format(upstream=upstream, curr=current))
logging.debug("Ensuring latest {upstream} changes in {curr} branch"
.format(upstream=upstream, curr=current))
merge_result = git("merge", upstream)
if "up-to-date" in merge_result:
logging.debug("{curr} branch is up-to-date".format(curr=current))
else:
raise MergerError("{curr} branch not up to date:\n".format(curr=current)
+ "{merge_result}".format(merge_result=merge_result))
logging.info("Successfully merged {upstream} branch into {curr} branch!"
.format(upstream=upstream, curr=current))

def merge_branch(upstream, current):
"""
Merge the the current branch into upstream and pushes to remote.

Note that this operation merges upstream into the current branch first, and
that the branch is deleted before the push to remote.

Parameters
----------
upstream : str
The name of the branch to be merged.
current : str
The name of the current branch

Raises
------
RuntimeError
If the upstream and current branches are identical.
MergerError
If the remote repository cannot be brought up to date.

"""
if current == upstream:
raise RuntimeError("You must be in the branch you want to merge, "
"not the upstream branch ({}).".format(upstream))

merge_upstream(upstream, current)
logging.info("Switching to {upstream} branch".format(upstream=upstream))
git("checkout", upstream)
git("merge", current)
logging.info("Merged latest {curr} changes into {upstream} branch"
.format(curr=current, upstream=upstream))

git("branch", "-d", current)
logging.info("Safely deleted {curr}".format(curr=current))

git("push")
logging.info("Pushed {upstream} to origin".format(upstream=upstream))
logging.debug("Ensuring that origin has latest {}".format(upstream))
push_result = git("push")
if "up-to-date" in push_result:
logging.debug("Remote repository is up-to-date: {}".format(upstream))
else:
raise MergerError("Remote repository is not up to date:\n{push_result}"
.format(push_result=push_result))
logging.info("Successfully merged {curr} into {upstream} "
.format(curr=current, upstream=upstream)
+ "and pushed to origin!")

if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()

• off topic, but why do you use docopt instead of argparse (or click)? btw, gitpython may be a better choice. – Hongxu Chen Sep 24 '15 at 17:49
• @HongxuChen: I had no idea that gitpython existed. I'll take a look at it. – Jean Nassar Sep 25 '15 at 3:36
• @HongxuChen: I was wondering whether to use click, but decided to use docopt because it just seemed simpler. (At least, it's simple enough for what I am doing with this particular script.) I will be trying click in the future when I have more time. Also, argparse I didn't use because I didn't like the interface. I have used it in the past, and it was (to me) tedious and repetitive and I had to keep going back to the docs to figure out keywords etc. – Jean Nassar Sep 25 '15 at 4:01
• actually i prefer agparse and click is based on optparse (the old standard lib). i'm not familiar with docopt, can it support subcommand and options that require list of args? – Hongxu Chen Sep 25 '15 at 13:04
• Yes it can. It can even display the usage of the subcommand too. Here is their examples repo: github.com/docopt/docopt/tree/master/examples Their git directory shows an example of the subcommands. start with git.py, and look at git_add.py as well. – Jean Nassar Sep 25 '15 at 14:02

You're doing all the right things: following PEP8, well documented, all code inside functions (as opposed to in global scope), responsibilities well decomposed to separate functions.

But... As you yourself have noticed:

The output of git has changed a bit, though, so I decided to remake it.

The text output of Git (or any other program), is NOT an API. It's not a good idea to build anything based on it. Always look for an existing library that let's you work with a native API, for example in this case gitpython. Parsing the text output of Git is a fragile idea, future maintenance burden, and unnecessary complexity. I suggest to rewrite this using gitpython.

About the code itself, as I mentioned it's nicely written, there's little to say. Only a tiny point I'd propose to improve. Instead of this:

import sh
from sh import git


This would be slightly better:

from sh import git, ErrorReturnCode


Update

@Vogel612 suggested in a comment:

Maybe interesting to mention is the --porcelain option, which spits out text intended to be processed by programs, which is as close as an API you'll ever get with something like output-processing

It's true, that would be a good improvement over the current solution. But really, as of 2015 (when you posted the question), Git is a mainstream tool, with good support in multiple languages, so it's hard to find a good reason to use anything less than a native API.

• Thanks for the answer. I did use gitpython in a résumé generator I made last month. github.com/masasin/resume I might rewrite the merge script some other time. – Jean Nassar Mar 27 '16 at 6:31