Repository: gitpython-developers/GitPython
Pull request #835 (2019-02-02 21:52:34)

README: Add repology badge
This badge will display all the downstream repositories that carry GitPython and the version number.

Edit: To see a preview go to https://github.com/luzpaz/GitPython/tree/README-repology-badge#development-status

Edit2: fixed the above link


Patch:
diff --git a/.appveyor.yml b/.appveyor.yml
index 8eeca501..017cf120 100644
--- a/.appveyor.yml
+++ b/.appveyor.yml
@@ -5,8 +5,6 @@ environment:
     CYGWIN64_GIT_PATH: "C:\\cygwin64\\bin;%GIT_DAEMON_PATH%"
 
     matrix:
-        ## MINGW
-        #
       - PYTHON: "C:\\Python27"
         PYTHON_VERSION: "2.7"
         GIT_PATH: "%GIT_DAEMON_PATH%"
@@ -16,24 +14,33 @@ environment:
       - PYTHON: "C:\\Python35-x64"
         PYTHON_VERSION: "3.5"
         GIT_PATH: "%GIT_DAEMON_PATH%"
+      - PYTHON: "C:\\Python36-x64"
+        PYTHON_VERSION: "3.6"
+        GIT_PATH: "%GIT_DAEMON_PATH%"
+      - PYTHON: "C:\\Python37-x64"
+        PYTHON_VERSION: "3.7"
+        GIT_PATH: "%GIT_DAEMON_PATH%"
       - PYTHON: "C:\\Miniconda35-x64"
         PYTHON_VERSION: "3.5"
         IS_CONDA: "yes"
+        MAYFAIL: "yes"
         GIT_PATH: "%GIT_DAEMON_PATH%"
-
         ## Cygwin
-        #
       - PYTHON: "C:\\Miniconda-x64"
         PYTHON_VERSION: "2.7"
         IS_CONDA: "yes"
         IS_CYGWIN: "yes"
+        MAYFAIL: "yes"
         GIT_PATH: "%CYGWIN_GIT_PATH%"
       - PYTHON: "C:\\Python35-x64"
         PYTHON_VERSION: "3.5"
-        GIT_PATH: "%CYGWIN64_GIT_PATH%"
         IS_CYGWIN: "yes"
+        MAYFAIL: "yes"
+        GIT_PATH: "%CYGWIN64_GIT_PATH%"
 
-
+matrix:
+    allow_failures:
+        - MAYFAIL: "yes"
 install:
     - set PATH=%PYTHON%;%PYTHON%\Scripts;%GIT_PATH%;%PATH%
 
@@ -51,6 +58,7 @@ install:
         conda info -a &
         conda install --yes --quiet  pip
       )
+    - pip install -r requirements.txt
     - pip install -r test-requirements.txt
     - pip install codecov
 
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..b59962d2
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+.git/
+.tox/
diff --git a/.travis.yml b/.travis.yml
index adb693dd..aed714af 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -9,12 +9,11 @@ matrix:
   include:
     - python: 3.7
       dist: xenial
-      sudo: required
     - python: "nightly"
       dist: xenial
-      sudo: required
   allow_failures:
     - python: "nightly"
+      dist: xenial
 git:
   # a higher depth is needed for most of the tests - must be high enough to not actually be shallow
   # as we clone our own repository in the process
@@ -40,8 +39,7 @@ script:
   - ulimit -n 128
   - ulimit -n
   - nosetests -v --with-coverage
-  - if [ "$TRAVIS_PYTHON_VERSION" == '3.4' ]; then flake8 --ignore=W293,E265,E266,W503,W504,E731; fi
   - if [ "$TRAVIS_PYTHON_VERSION" == '3.5' ]; then cd doc && make html; fi
-  -
+  - if [ "$TRAVIS_PYTHON_VERSION" == '3.6' ]; then flake8 --ignore=W293,E265,E266,W503,W504,E731; fi
 after_success:
   - codecov
diff --git a/AUTHORS b/AUTHORS
index 2e006ba2..9941d4d9 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -28,5 +28,7 @@ Contributors are:
 -Yaroslav Halchenko <debian _at_ onerussian.com>
 -Tim Swast <swast _at_ google.com>
 -William Luc Ritchie
+-David Host <hostdm _at_ outlook.com>
+-A. Jesse Jiryu Davis <jesse _at_ emptysquare.net>
 
 Portions derived from other open source works and are clearly marked.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..fc42f18f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,80 @@
+#
+# Contributed by: James E. King III (@jeking3) <jking@apache.org>
+#
+# This Dockerfile creates an Ubuntu Xenial build environment
+# that can run the same test suite as Travis CI.
+#
+
+FROM ubuntu:xenial
+MAINTAINER James E. King III <jking@apache.org>
+ENV CONTAINER_USER=user
+ENV DEBIAN_FRONTEND noninteractive
+
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends \
+      add-apt-key \
+      apt \
+      apt-transport-https \
+      apt-utils \
+      ca-certificates \
+      curl \
+      git \
+      net-tools \
+      openssh-client \
+      sudo \
+      vim \
+      wget
+
+RUN add-apt-key -v 6A755776 -k keyserver.ubuntu.com && \
+    add-apt-key -v E1DF1F24 -k keyserver.ubuntu.com && \
+    echo "deb http://ppa.launchpad.net/git-core/ppa/ubuntu xenial main" >> /etc/apt/sources.list && \
+    echo "deb http://ppa.launchpad.net/deadsnakes/ppa/ubuntu xenial main" >> /etc/apt/sources.list && \
+    apt-get update && \
+    apt-get install -y --install-recommends git python2.7 python3.4 python3.5 python3.6 python3.7 && \
+    update-alternatives --install /usr/bin/python3 python3 /usr/bin/python2.7 27 && \
+    update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.4 34 && \
+    update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.5 35 && \
+    update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.6 36 && \
+    update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.7 37
+
+RUN curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py && \
+    python3 get-pip.py && \
+    pip3 install tox
+
+# Clean up
+RUN rm -rf /var/cache/apt/* && \
+    rm -rf /var/lib/apt/lists/* && \
+    rm -rf /tmp/* && \
+    rm -rf /var/tmp/*
+
+#################################################################
+# Build as a regular user
+# Credit: https://github.com/delcypher/docker-ubuntu-cxx-dev/blob/master/Dockerfile
+# License: None specified at time of import
+# Add non-root user for container but give it sudo access.
+# Password is the same as the username
+RUN useradd -m ${CONTAINER_USER} && \
+    echo ${CONTAINER_USER}:${CONTAINER_USER} | chpasswd && \
+    echo "${CONTAINER_USER}  ALL=(root) ALL" >> /etc/sudoers
+RUN chsh --shell /bin/bash ${CONTAINER_USER}
+USER ${CONTAINER_USER}
+#################################################################
+
+# The test suite will not tolerate running against a branch that isn't "master", so
+# check out the project to a well-known location that can be used by the test suite.
+# This has the added benefit of protecting the local repo fed into the container
+# as a volume from getting destroyed by a bug exposed by the test suite. :)
+ENV TRAVIS=ON
+RUN git clone --recursive https://github.com/gitpython-developers/GitPython.git /home/${CONTAINER_USER}/testrepo && \
+    cd /home/${CONTAINER_USER}/testrepo && \
+    ./init-tests-after-clone.sh
+ENV GIT_PYTHON_TEST_GIT_REPO_BASE=/home/${CONTAINER_USER}/testrepo
+ENV TRAVIS=
+
+# Ensure any local pip installations get on the path
+ENV PATH=/home/${CONTAINER_USER}/.local/bin:${PATH}
+
+# Set the global default git user to be someone non-descript
+RUN git config --global user.email ci@gitpython.org && \
+    git config --global user.name "GitPython CI User"
+
diff --git a/MANIFEST.in b/MANIFEST.in
index 15ac959e..e6bf5249 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -2,8 +2,10 @@ include VERSION
 include LICENSE
 include CHANGES
 include AUTHORS
-include README
+include CONTRIBUTING.md
+include README.md
 include requirements.txt
+include test-requirements.txt
 
 recursive-include doc *
 
diff --git a/Makefile b/Makefile
index 648b8595..ae74a0d8 100644
--- a/Makefile
+++ b/Makefile
@@ -1,8 +1,10 @@
+.PHONY: all clean release force_release docker-build test nose-pdb
+
 all:
 	@grep -Ee '^[a-z].*:' Makefile | cut -d: -f1 | grep -vF all
 
 clean:
-	rm -rf build/ dist/
+	rm -rf build/ dist/ .eggs/ .tox/
 
 release: clean
 	# Check if latest tag is the current head we're releasing
@@ -16,3 +18,17 @@ force_release: clean
 	git push --tags origin master
 	python3 setup.py sdist bdist_wheel
 	twine upload -s -i byronimo@gmail.com dist/*
+
+docker-build:
+	docker build --quiet -t gitpython:xenial -f Dockerfile .
+
+test: docker-build
+	# NOTE!!!
+	# NOTE!!! If you are not running from master or have local changes then tests will fail
+	# NOTE!!!
+	docker run --rm -v ${CURDIR}:/src -w /src -t gitpython:xenial tox
+
+nose-pdb: docker-build
+	# run tests under nose and break on error or failure into python debugger
+	# HINT: set PYVER to "pyXX" to change from the default of py37 to pyXX for nose tests
+	docker run --rm --env PYVER=${PYVER} -v ${CURDIR}:/src -w /src -it gitpython:xenial /bin/bash dockernose.sh
diff --git a/README.md b/README.md
index f5735bc2..e252c34c 100644
--- a/README.md
+++ b/README.md
@@ -192,8 +192,10 @@ New BSD License.  See the LICENSE file.
 [![Code Climate](https://codeclimate.com/github/gitpython-developers/GitPython/badges/gpa.svg)](https://codeclimate.com/github/gitpython-developers/GitPython)
 [![Documentation Status](https://readthedocs.org/projects/gitpython/badge/?version=stable)](https://readthedocs.org/projects/gitpython/?badge=stable)
 [![Stories in Ready](https://badge.waffle.io/gitpython-developers/GitPython.png?label=ready&title=Ready)](https://waffle.io/gitpython-developers/GitPython)
+[![Packaging status](https://repology.org/badge/tiny-repos/python:gitpython.svg)](https://repology.org/metapackage/python:gitpython/versions)
 [![Throughput Graph](https://graphs.waffle.io/gitpython-developers/GitPython/throughput.svg)](https://waffle.io/gitpython-developers/GitPython/metrics/throughput)
 
+
 Now that there seems to be a massive user base, this should be motivation enough to let git-python
 return to a proper state, which means
 
diff --git a/doc/source/changes.rst b/doc/source/changes.rst
index 32e58c7f..92c28b69 100644
--- a/doc/source/changes.rst
+++ b/doc/source/changes.rst
@@ -2,6 +2,17 @@
 Changelog
 =========
 
+2.1.12 - Bugfixes and Features
+==============================
+
+* Multi-value support and interface improvements for Git configuration. Thanks to A. Jesse Jiryu Davis.
+
+see the following for (most) details:
+https://github.com/gitpython-developers/gitpython/milestone/27?closed=1
+
+or run have a look at the difference between tags v2.1.11 and v2.1.12:
+https://github.com/gitpython-developers/GitPython/compare/2.1.11...2.1.12
+
 2.1.11 - Bugfixes
 =================
 
diff --git a/dockernose.sh b/dockernose.sh
new file mode 100755
index 00000000..c9227118
--- /dev/null
+++ b/dockernose.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -ex
+if [ -z "${PYVER}" ]; then
+    PYVER=py37
+fi
+
+# remember to use "-s" if you inject pdb.set_trace() as this disables nosetests capture of streams
+
+tox -e ${PYVER} --notest
+PYTHONPATH=/src/.tox/${PYVER}/lib/python*/site-packages /src/.tox/${PYVER}/bin/nosetests --pdb $*
diff --git a/git/cmd.py b/git/cmd.py
index a4faefe2..64c3d480 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -43,6 +43,10 @@ from .util import (
     stream_copy,
 )
 
+try:
+    PermissionError
+except NameError:  # Python < 3.3
+    PermissionError = OSError
 
 execute_kwargs = {'istream', 'with_extended_output',
                   'with_exceptions', 'as_process', 'stdout_as_string',
@@ -211,22 +215,15 @@ class Git(LazyMixin):
 
         # test if the new git executable path is valid
 
-        if sys.version_info < (3,):
-            # - a GitCommandNotFound error is spawned by ourselves
-            # - a OSError is spawned if the git executable provided
-            #   cannot be executed for whatever reason
-            exceptions = (GitCommandNotFound, OSError)
-        else:
-            # - a GitCommandNotFound error is spawned by ourselves
-            # - a PermissionError is spawned if the git executable provided
-            #   cannot be executed for whatever reason
-            exceptions = (GitCommandNotFound, PermissionError)
-
+        # - a GitCommandNotFound error is spawned by ourselves
+        # - a PermissionError is spawned if the git executable provided
+        #   cannot be executed for whatever reason
+        
         has_git = False
         try:
             cls().version()
             has_git = True
-        except exceptions:
+        except (GitCommandNotFound, PermissionError):
             pass
 
         # warn or raise exception if test failed
@@ -718,8 +715,11 @@ class Git(LazyMixin):
         stdout_sink = (PIPE
                        if with_stdout
                        else getattr(subprocess, 'DEVNULL', None) or open(os.devnull, 'wb'))
-        log.debug("Popen(%s, cwd=%s, universal_newlines=%s, shell=%s)",
-                  command, cwd, universal_newlines, shell)
+        istream_ok = "None"
+        if istream:
+            istream_ok = "<valid stream>"
+        log.debug("Popen(%s, cwd=%s, universal_newlines=%s, shell=%s, istream=%s)",
+                  command, cwd, universal_newlines, shell, istream_ok)
         try:
             proc = Popen(command,
                          env=env,
diff --git a/git/config.py b/git/config.py
index 68d65ae9..b03d9d42 100644
--- a/git/config.py
+++ b/git/config.py
@@ -146,6 +146,51 @@ class SectionConstraint(object):
         self._config.__exit__(exception_type, exception_value, traceback)
 
 
+class _OMD(OrderedDict):
+    """Ordered multi-dict."""
+
+    def __setitem__(self, key, value):
+        super(_OMD, self).__setitem__(key, [value])
+
+    def add(self, key, value):
+        if key not in self:
+            super(_OMD, self).__setitem__(key, [value])
+            return
+
+        super(_OMD, self).__getitem__(key).append(value)
+
+    def setall(self, key, values):
+        super(_OMD, self).__setitem__(key, values)
+
+    def __getitem__(self, key):
+        return super(_OMD, self).__getitem__(key)[-1]
+
+    def getlast(self, key):
+        return super(_OMD, self).__getitem__(key)[-1]
+
+    def setlast(self, key, value):
+        if key not in self:
+            super(_OMD, self).__setitem__(key, [value])
+            return
+
+        prior = super(_OMD, self).__getitem__(key)
+        prior[-1] = value
+
+    def get(self, key, default=None):
+        return super(_OMD, self).get(key, [default])[-1]
+
+    def getall(self, key):
+        return super(_OMD, self).__getitem__(key)
+
+    def items(self):
+        """List of (key, last value for key)."""
+        return [(k, self[k]) for k in self]
+
+    def items_all(self):
+        """List of (key, list of values for key)."""
+        return [(k, self.getall(k)) for k in self]
+
+
 class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, object)):
 
     """Implements specifics required to read git style configuration files.
@@ -200,7 +245,7 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje
             contents into ours. This makes it impossible to write back an individual configuration file.
             Thus, if you want to modify a single configuration file, turn this off to leave the original
             dataset unaltered when reading it."""
-        cp.RawConfigParser.__init__(self, dict_type=OrderedDict)
+        cp.RawConfigParser.__init__(self, dict_type=_OMD)
 
         # Used in python 3, needs to stay in sync with sections for underlying implementation to work
         if not hasattr(self, '_proxies'):
@@ -348,7 +393,8 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje
                         is_multi_line = True
                         optval = string_decode(optval[1:])
                     # end handle multi-line
-                    cursect[optname] = optval
+                    # preserves multiple values for duplicate optnames
+                    cursect.add(optname, optval)
                 else:
                     # check if it's an option with no value - it's just ignored by git
                     if not self.OPTVALUEONLY.match(line):
@@ -362,7 +408,8 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje
                     is_multi_line = False
                     line = line[:-1]
                 # end handle quotations
-                cursect[optname] += string_decode(line)
+                optval = cursect.getlast(optname)
+                cursect.setlast(optname, optval + string_decode(line))
             # END parse section or option
         # END while reading
 
@@ -442,9 +489,12 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje
         git compatible format"""
         def write_section(name, section_dict):
             fp.write(("[%s]\n" % name).encode(defenc))
-            for (key, value) in section_dict.items():
-                if key != "__name__":
-                    fp.write(("\t%s = %s\n" % (key, self._value_to_string(value).replace('\n', '\n\t'))).encode(defenc))
+            for (key, values) in section_dict.items_all():
+                if key == "__name__":
+                    continue
+
+                for v in values:
+                    fp.write(("\t%s = %s\n" % (key, self._value_to_string(v).replace('\n', '\n\t'))).encode(defenc))
                 # END if key is not __name__
         # END section writing
 
@@ -457,6 +507,22 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje
         """:return: list((option, value), ...) pairs of all items in the given section"""
         return [(k, v) for k, v in super(GitConfigParser, self).items(section_name) if k != '__name__']
 
+    def items_all(self, section_name):
+        """:return: list((option, [values...]), ...) pairs of all items in the given section"""
+        rv = _OMD(self._defaults)
+
+        for k, vs in self._sections[section_name].items_all():
+            if k == '__name__':
+                continue
+
+            if k in rv and rv.getall(k) == vs:
+                continue
+
+            for v in vs:
+                rv.add(k, v)
+
+        return rv.items_all()
+
     @needs_values
     def write(self):
         """Write changes to our file, if there are changes at all
@@ -508,7 +574,11 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje
         return self._read_only
 
     def get_value(self, section, option, default=None):
-        """
+        """Get an option's value.
+
+        If multiple values are specified for this option in the section, the
+        last one specified is returned.
+
         :param default:
             If not None, the given default value will be returned in case
             the option did not exist
@@ -523,6 +593,31 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje
                 return default
             raise
 
+        return self._string_to_value(valuestr)
+
+    def get_values(self, section, option, default=None):
+        """Get an option's values.
+
+        If multiple values are specified for this option in the section, all are
+        returned.
+
+        :param default:
+            If not None, a list containing the given default value will be
+            returned in case the option did not exist
+        :return: a list of properly typed values, either int, float or string
+
+        :raise TypeError: in case the value could not be understood
+            Otherwise the exceptions known to the ConfigParser will be raised."""
+        try:
+            lst = self._sections[section].getall(option)
+        except Exception:
+            if default is not None:
+                return [default]
+            raise
+
+        return [self._string_to_value(valuestr) for valuestr in lst]
+
+    def _string_to_value(self, valuestr):
         types = (int, float)
         for numtype in types:
             try:
@@ -545,7 +640,9 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje
             return True
 
         if not isinstance(valuestr, string_types):
-            raise TypeError("Invalid value type: only int, long, float and str are allowed", valuestr)
+            raise TypeError(
+                "Invalid value type: only int, long, float and str are allowed",
+                valuestr)
 
         return valuestr
 
@@ -572,6 +669,25 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje
         self.set(section, option, self._value_to_string(value))
         return self
 
+    @needs_values
+    @set_dirty_and_flush_changes
+    def add_value(self, section, option, value):
+        """Adds a value for the given option in section.
+        It will create the section if required, and will not throw as opposed to the default
+        ConfigParser 'set' method. The value becomes the new value of the option as returned
+        by 'get_value', and appends to the list of values returned by 'get_values`'.
+
+        :param section: Name of the section in which the option resides or should reside
+        :param option: Name of the option
+
+        :param value: Value to add to option. It must be a string or convertible
+            to a string
+        :return: this instance"""
+        if not self.has_section(section):
+            self.add_section(section)
+        self._sections[section].add(option, self._value_to_string(value))
+        return self
+
     def rename_section(self, section, new_name):
         """rename the given section to new_name
         :raise ValueError: if section doesn't exit
@@ -584,8 +700,9 @@ class GitConfigParser(with_metaclass(MetaParserBuilder, cp.RawConfigParser, obje
             raise ValueError("Destination section '%s' already exists" % new_name)
 
         super(GitConfigParser, self).add_section(new_name)
-        for k, v in self.items(section):
-            self.set(new_name, k, self._value_to_string(v))
+        new_section = self._sections[new_name]
+        for k, vs in self.items_all(section):
+            new_section.setall(k, vs)
         # end for each value to copy
 
         # This call writes back the changes, which is why we don't have the respective decorator
diff --git a/git/objects/commit.py b/git/objects/commit.py
index b7d27d92..9736914a 100644
--- a/git/objects/commit.py
+++ b/git/objects/commit.py
@@ -81,7 +81,7 @@ class Commit(base.Object, Iterable, Diffable, Traversable, Serializable):
         :param tree: Tree
             Tree object
         :param author: Actor
-            is the author string ( will be implicitly converted into an Actor object )
+            is the author Actor object
         :param authored_date: int_seconds_since_epoch
             is the authored DateTime - use time.gmtime() to convert it into a
             different format
diff --git a/git/remote.py b/git/remote.py
index 8aec68e1..0965c1f6 100644
--- a/git/remote.py
+++ b/git/remote.py
@@ -695,6 +695,8 @@ class Remote(LazyMixin, Iterable):
             msg += "Will ignore extra progress lines or fetch head lines."
             msg %= (l_fil, l_fhi)
             log.debug(msg)
+            log.debug("info lines: " + str(fetch_info_lines))
+            log.debug("head info : " + str(fetch_head_info))
             if l_fil < l_fhi:
                 fetch_head_info = fetch_head_info[:l_fil]
             else:
diff --git a/git/repo/base.py b/git/repo/base.py
index 304faa76..5f42dd29 100644
--- a/git/repo/base.py
+++ b/git/repo/base.py
@@ -97,6 +97,7 @@ class Repo(object):
                 repo = Repo("/Users/mtrier/Development/git-python.git")
                 repo = Repo("~/Development/git-python.git")
                 repo = Repo("$REPOSITORIES/Development/git-python.git")
+                repo = Repo("C:\\Users\\mtrier\\Development\\git-python\\.git")
 
             - In *Cygwin*, path may be a `'cygdrive/...'` prefixed path.
             - If it evaluates to false, :envvar:`GIT_DIR` is used, and if this also evals to false,
@@ -558,11 +559,11 @@ class Repo(object):
         return res
 
     def is_ancestor(self, ancestor_rev, rev):
-        """Check if a commit  is an ancestor of another
+        """Check if a commit is an ancestor of another
 
         :param ancestor_rev: Rev which should be an ancestor
         :param rev: Rev to test against ancestor_rev
-        :return: ``True``, ancestor_rev is an accestor to rev.
+        :return: ``True``, ancestor_rev is an ancestor to rev.
         """
         try:
             self.git.merge_base(ancestor_rev, rev, is_ancestor=True)
diff --git a/git/util.py b/git/util.py
index a3b1fbfb..3ba58857 100644
--- a/git/util.py
+++ b/git/util.py
@@ -534,8 +534,8 @@ class Actor(object):
     can be committers and authors or anything with a name and an email as
     mentioned in the git log entries."""
     # PRECOMPILED REGEX
-    name_only_regex = re.compile(r'<(.+)>')
-    name_email_regex = re.compile(r'(.*) <(.+?)>')
+    name_only_regex = re.compile(r'<(.*)>')
+    name_email_regex = re.compile(r'(.*) <(.*?)>')
 
     # ENVIRONMENT VARIABLES
     # read when creating new commits
diff --git a/requirements.txt b/requirements.txt
index 39644606..63d5ddfe 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,1 @@
-gitdb>=0.6.4
-ddt>=1.1.1
+gitdb2 (>=2.0.0)
diff --git a/setup.py b/setup.py
index cb0300f7..49288f69 100755
--- a/setup.py
+++ b/setup.py
@@ -19,6 +19,9 @@ with open(path.join(path.dirname(__file__), 'VERSION')) as v:
 with open('requirements.txt') as reqs_file:
     requirements = reqs_file.read().splitlines()
 
+with open('test-requirements.txt') as reqs_file:
+    test_requirements = reqs_file.read().splitlines()
+
 
 class build_py(_build_py):
 
@@ -63,10 +66,6 @@ def _stamp_version(filename):
         print("WARNING: Couldn't find version line in file %s" % filename, file=sys.stderr)
 
 
-install_requires = ['gitdb2 >= 2.0.0']
-test_requires = ['ddt>=1.1.1']
-# end
-
 setup(
     name="GitPython",
     cmdclass={'build_py': build_py, 'sdist': sdist},
@@ -81,9 +80,8 @@ setup(
     package_dir={'git': 'git'},
     license="BSD License",
     python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*',
-    requires=['gitdb2 (>=2.0.0)'],
-    install_requires=install_requires,
-    test_requirements=test_requires + install_requires,
+    install_requires=requirements,
+    tests_require=requirements + test_requirements,
     zip_safe=False,
     long_description="""GitPython is a python library used to interact with Git repositories""",
     classifiers=[
@@ -110,6 +108,6 @@ setup(
         "Programming Language :: Python :: 3.4",
         "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.7"
     ]
 )


Test patch:
diff --git a/git/test/fixtures/git_config_multiple b/git/test/fixtures/git_config_multiple
new file mode 100644
index 00000000..03a97568
--- /dev/null
+++ b/git/test/fixtures/git_config_multiple
@@ -0,0 +1,7 @@
+[section0]
+    option0 = value0
+
+[section1]
+    option1 = value1a
+    option1 = value1b
+    other_option1 = other_value1
diff --git a/git/test/test_config.py b/git/test/test_config.py
index 4d6c8236..93f94748 100644
--- a/git/test/test_config.py
+++ b/git/test/test_config.py
@@ -11,7 +11,7 @@ from git import (
     GitConfigParser
 )
 from git.compat import string_types
-from git.config import cp
+from git.config import _OMD, cp
 from git.test.lib import (
     TestCase,
     fixture_path,
@@ -265,3 +265,110 @@ class TestBase(TestCase):
 
         with self.assertRaises(cp.NoOptionError):
             cr.get_value('color', 'ui')
+
+    def test_multiple_values(self):
+        file_obj = self._to_memcache(fixture_path('git_config_multiple'))
+        with GitConfigParser(file_obj, read_only=False) as cw:
+            self.assertEqual(cw.get('section0', 'option0'), 'value0')
+            self.assertEqual(cw.get_values('section0', 'option0'), ['value0'])
+            self.assertEqual(cw.items('section0'), [('option0', 'value0')])
+
+            # Where there are multiple values, "get" returns the last.
+            self.assertEqual(cw.get('section1', 'option1'), 'value1b')
+            self.assertEqual(cw.get_values('section1', 'option1'),
+                             ['value1a', 'value1b'])
+            self.assertEqual(cw.items('section1'),
+                             [('option1', 'value1b'),
+                              ('other_option1', 'other_value1')])
+            self.assertEqual(cw.items_all('section1'),
+                             [('option1', ['value1a', 'value1b']),
+                              ('other_option1', ['other_value1'])])
+            with self.assertRaises(KeyError):
+                cw.get_values('section1', 'missing')
+
+            self.assertEqual(cw.get_values('section1', 'missing', 1), [1])
+            self.assertEqual(cw.get_values('section1', 'missing', 's'), ['s'])
+
+    def test_multiple_values_rename(self):
+        file_obj = self._to_memcache(fixture_path('git_config_multiple'))
+        with GitConfigParser(file_obj, read_only=False) as cw:
+            cw.rename_section('section1', 'section2')
+            cw.write()
+            file_obj.seek(0)
+            cr = GitConfigParser(file_obj, read_only=True)
+            self.assertEqual(cr.get_value('section2', 'option1'), 'value1b')
+            self.assertEqual(cr.get_values('section2', 'option1'),
+                             ['value1a', 'value1b'])
+            self.assertEqual(cr.items('section2'),
+                             [('option1', 'value1b'),
+                              ('other_option1', 'other_value1')])
+            self.assertEqual(cr.items_all('section2'),
+                             [('option1', ['value1a', 'value1b']),
+                              ('other_option1', ['other_value1'])])
+
+    def test_multiple_to_single(self):
+        file_obj = self._to_memcache(fixture_path('git_config_multiple'))
+        with GitConfigParser(file_obj, read_only=False) as cw:
+            cw.set_value('section1', 'option1', 'value1c')
+
+            cw.write()
+            file_obj.seek(0)
+            cr = GitConfigParser(file_obj, read_only=True)
+            self.assertEqual(cr.get_value('section1', 'option1'), 'value1c')
+            self.assertEqual(cr.get_values('section1', 'option1'), ['value1c'])
+            self.assertEqual(cr.items('section1'),
+                             [('option1', 'value1c'),
+                              ('other_option1', 'other_value1')])
+            self.assertEqual(cr.items_all('section1'),
+                             [('option1', ['value1c']),
+                              ('other_option1', ['other_value1'])])
+
+    def test_single_to_multiple(self):
+        file_obj = self._to_memcache(fixture_path('git_config_multiple'))
+        with GitConfigParser(file_obj, read_only=False) as cw:
+            cw.add_value('section1', 'other_option1', 'other_value1a')
+
+            cw.write()
+            file_obj.seek(0)
+            cr = GitConfigParser(file_obj, read_only=True)
+            self.assertEqual(cr.get_value('section1', 'option1'), 'value1b')
+            self.assertEqual(cr.get_values('section1', 'option1'),
+                             ['value1a', 'value1b'])
+            self.assertEqual(cr.get_value('section1', 'other_option1'),
+                             'other_value1a')
+            self.assertEqual(cr.get_values('section1', 'other_option1'),
+                             ['other_value1', 'other_value1a'])
+            self.assertEqual(cr.items('section1'),
+                             [('option1', 'value1b'),
+                              ('other_option1', 'other_value1a')])
+            self.assertEqual(
+                cr.items_all('section1'),
+                [('option1', ['value1a', 'value1b']),
+                 ('other_option1', ['other_value1', 'other_value1a'])])
+
+    def test_add_to_multiple(self):
+        file_obj = self._to_memcache(fixture_path('git_config_multiple'))
+        with GitConfigParser(file_obj, read_only=False) as cw:
+            cw.add_value('section1', 'option1', 'value1c')
+            cw.write()
+            file_obj.seek(0)
+            cr = GitConfigParser(file_obj, read_only=True)
+            self.assertEqual(cr.get_value('section1', 'option1'), 'value1c')
+            self.assertEqual(cr.get_values('section1', 'option1'),
+                             ['value1a', 'value1b', 'value1c'])
+            self.assertEqual(cr.items('section1'),
+                             [('option1', 'value1c'),
+                              ('other_option1', 'other_value1')])
+            self.assertEqual(cr.items_all('section1'),
+                             [('option1', ['value1a', 'value1b', 'value1c']),
+                              ('other_option1', ['other_value1'])])
+
+    def test_setlast(self):
+        # Test directly, not covered by higher-level tests.
+        omd = _OMD()
+        omd.setlast('key', 'value1')
+        self.assertEqual(omd['key'], 'value1')
+        self.assertEqual(omd.getall('key'), ['value1'])
+        omd.setlast('key', 'value2')
+        self.assertEqual(omd['key'], 'value2')
+        self.assertEqual(omd.getall('key'), ['value2'])
diff --git a/git/test/test_remote.py b/git/test/test_remote.py
index 7c1711c2..99949b9e 100644
--- a/git/test/test_remote.py
+++ b/git/test/test_remote.py
@@ -253,9 +253,15 @@ class TestRemote(TestBase):
         self.assertEqual(tinfo.ref.commit, rtag.commit)
         self.assertTrue(tinfo.flags & tinfo.NEW_TAG)
 
-        # adjust tag commit
+        # adjust the local tag commit
         Reference.set_object(rtag, rhead.commit.parents[0].parents[0])
-        res = fetch_and_test(remote, tags=True)
+
+        # as of git 2.20 one cannot clobber local tags that have changed without
+        # specifying --force, and the test assumes you can clobber, so...
+        force = None
+        if rw_repo.git.version_info[:2] >= (2, 20):
+            force = True
+        res = fetch_and_test(remote, tags=True, force=force)
         tinfo = res[str(rtag)]
         self.assertEqual(tinfo.commit, rtag.commit)
         self.assertTrue(tinfo.flags & tinfo.TAG_UPDATE)
@@ -632,7 +638,7 @@ class TestRemote(TestBase):
 
     def test_fetch_error(self):
         rem = self.rorepo.remote('origin')
-        with self.assertRaisesRegex(GitCommandError, "Couldn't find remote ref __BAD_REF__"):
+        with self.assertRaisesRegex(GitCommandError, "[Cc]ouldn't find remote ref __BAD_REF__"):
             rem.fetch('__BAD_REF__')
 
     @with_rw_repo('0.1.6', bare=False)
diff --git a/git/test/test_util.py b/git/test/test_util.py
index 9c993205..b5f9d222 100644
--- a/git/test/test_util.py
+++ b/git/test/test_util.py
@@ -212,6 +212,12 @@ class TestUtils(TestBase):
             self.assertIsInstance(Actor.author(cr), Actor)
         # END assure config reader is handled
 
+    def test_actor_from_string(self):
+        self.assertEqual(Actor._from_string("name"), Actor("name", None))
+        self.assertEqual(Actor._from_string("name <>"), Actor("name", ""))
+        self.assertEqual(Actor._from_string("name last another <some-very-long-email@example.com>"),
+                         Actor("name last another", "some-very-long-email@example.com"))
+
     @ddt.data(('name', ''), ('name', 'prefix_'))
     def test_iterable_list(self, case):
         name, prefix = case
diff --git a/test-requirements.txt b/test-requirements.txt
index 1cea3aa2..ec0e4c56 100644
--- a/test-requirements.txt
+++ b/test-requirements.txt
@@ -1,6 +1,6 @@
--r requirements.txt
-
+ddt>=1.1.1
 coverage
 flake8
 nose
-mock; python_version=='2.7'
\ No newline at end of file
+tox
+mock; python_version=='2.7'