6
\$\begingroup\$

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
# Copyright © 2015 Jean Nassar
#
# 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: [email protected]
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()
\$\endgroup\$
8
  • \$\begingroup\$ off topic, but why do you use docopt instead of argparse (or click)? btw, gitpython may be a better choice. \$\endgroup\$ Sep 24, 2015 at 17:49
  • \$\begingroup\$ @HongxuChen: I had no idea that gitpython existed. I'll take a look at it. \$\endgroup\$ Sep 25, 2015 at 3:36
  • \$\begingroup\$ @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. \$\endgroup\$ Sep 25, 2015 at 4:01
  • \$\begingroup\$ 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? \$\endgroup\$ Sep 25, 2015 at 13:04
  • \$\begingroup\$ 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. \$\endgroup\$ Sep 25, 2015 at 14:02

1 Answer 1

1
\$\begingroup\$

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.

\$\endgroup\$
1
  • \$\begingroup\$ 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. \$\endgroup\$ Mar 27, 2016 at 6:31

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

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