Browse Source

New upstream version 0.16.2

Jelmer Vernooij 8 years ago
parent
commit
5d7d8c194f

+ 55 - 3
NEWS

@@ -1,3 +1,58 @@
+0.16.2	2016-01-14
+
+ IMPROVEMENTS
+
+  * Fixed failing test-cases on windows.
+    (Koen Martens)
+
+ API CHANGES
+
+  * Repo is now a context manager, so that it can be easily
+    closed using a ``with`` statement. (Søren Løvborg)
+
+ TEST FIXES
+
+  * Only run worktree list compat tests against git 2.7.0,
+    when 'git worktree list' was introduced. (Jelmer Vernooij)
+
+ BUG FIXES
+
+  * Ignore filemode when building index when core.filemode
+    is false.
+    (Koen Martens)
+
+  * Initialize core.filemode configuration setting by
+    probing the filesystem for trustable permissions.
+    (Koen Martens)
+
+  * Fix ``porcelain.reset`` to respect the comittish argument.
+    (Koen Martens)
+
+  * Fix dulwich.porcelain.ls_remote() on Python 3.
+    (#471, Jelmer Vernooij)
+
+  * Allow both unicode and byte strings for host paths
+    in dulwich.client. (#435, Jelmer Vernooij)
+
+  * Add remote from porcelain.clone. (#466, Jelmer Vernooij)
+
+  * Fix unquoting of credentials before passing to urllib2.
+    (#475, Volodymyr Holovko)
+
+  * Cope with submodules in `build_index_from_tree`.
+    (#477, Jelmer Vernooij)
+
+  * Handle deleted files in `get_unstaged_changes`.
+    (#483, Doug Hellmann)
+
+  * Don't overwrite files when they haven't changed in
+    `build_file_from_blob`.
+    (#479, Benoît HERVIER)
+
+  * Check for existence of index file before opening pack.
+    Fixes a race when new packs are being added.
+    (#482, wme)
+
 0.16.1	2016-12-25
 
  BUG FIXES
@@ -27,9 +82,6 @@
 
  BUG FIXES
 
-  * Fix ``porcelain.reset`` to respect the comittish argument.
-    (Koen Martens)
-
   * Fix handling of ``Commit.tree`` being set to an actual
     tree object rather than a tree id. (Jelmer Vernooij)
 

+ 1 - 1
PKG-INFO

@@ -1,6 +1,6 @@
 Metadata-Version: 1.1
 Name: dulwich
-Version: 0.16.1
+Version: 0.16.2
 Summary: Python Git Library
 Home-page: https://www.dulwich.io/
 Author: Jelmer Vernooij

+ 1 - 1
dulwich.egg-info/PKG-INFO

@@ -1,6 +1,6 @@
 Metadata-Version: 1.1
 Name: dulwich
-Version: 0.16.1
+Version: 0.16.2
 Summary: Python Git Library
 Home-page: https://www.dulwich.io/
 Author: Jelmer Vernooij

+ 1 - 1
dulwich/__init__.py

@@ -22,4 +22,4 @@
 
 """Python implementation of the Git file formats and protocols."""
 
-__version__ = (0, 16, 1)
+__version__ = (0, 16, 2)

+ 29 - 14
dulwich/client.py

@@ -50,8 +50,10 @@ import sys
 
 try:
     from urllib import quote as urlquote
+    from urllib import unquote as urlunquote
 except ImportError:
     from urllib.parse import quote as urlquote
+    from urllib.parse import unquote as urlunquote
 
 try:
     import urllib2
@@ -497,6 +499,12 @@ class GitClient(object):
 class TraditionalGitClient(GitClient):
     """Traditional Git client."""
 
+    DEFAULT_ENCODING = 'utf-8'
+
+    def __init__(self, path_encoding=DEFAULT_ENCODING, **kwargs):
+        self._remote_path_encoding = path_encoding
+        super(TraditionalGitClient, self).__init__(**kwargs)
+
     def _connect(self, cmd, path):
         """Create a connection to the server.
 
@@ -672,9 +680,9 @@ class TCPGitClient(TraditionalGitClient):
 
     def _connect(self, cmd, path):
         if type(cmd) is not bytes:
-            raise TypeError(path)
+            raise TypeError(cmd)
         if type(path) is not bytes:
-            raise TypeError(path)
+            path = path.encode(self._remote_path_encoding)
         sockaddrs = socket.getaddrinfo(
             self._host, self._port, socket.AF_UNSPEC, socket.SOCK_STREAM)
         s = None
@@ -772,9 +780,9 @@ class SubprocessGitClient(TraditionalGitClient):
 
     def _connect(self, service, path):
         if type(service) is not bytes:
-            raise TypeError(path)
+            raise TypeError(service)
         if type(path) is not bytes:
-            raise TypeError(path)
+            path = path.encode(self._remote_path_encoding)
         if self.git_command is None:
             git_command = find_git_command()
         argv = git_command + [service.decode('ascii'), path]
@@ -806,6 +814,13 @@ class LocalGitClient(GitClient):
     def from_parsedurl(cls, parsedurl, **kwargs):
         return cls(**kwargs)
 
+    @classmethod
+    def _open_repo(cls, path):
+        from dulwich.repo import Repo
+        if not isinstance(path, str):
+            path = path.decode(sys.getfilesystemencoding())
+        return closing(Repo(path))
+
     def send_pack(self, path, determine_wants, generate_pack_contents,
                   progress=None, write_pack=write_pack_objects):
         """Upload a pack to a remote repository.
@@ -825,9 +840,8 @@ class LocalGitClient(GitClient):
         """
         if not progress:
             progress = lambda x: None
-        from dulwich.repo import Repo
 
-        with closing(Repo(path)) as target:
+        with self._open_repo(path)  as target:
             old_refs = target.get_refs()
             new_refs = determine_wants(dict(old_refs))
 
@@ -863,8 +877,7 @@ class LocalGitClient(GitClient):
         :param progress: Optional progress function
         :return: Dictionary with all remote refs (not just those fetched)
         """
-        from dulwich.repo import Repo
-        with closing(Repo(path)) as r:
+        with self._open_repo(path) as r:
             return r.fetch(target, determine_wants=determine_wants,
                            progress=progress)
 
@@ -878,8 +891,7 @@ class LocalGitClient(GitClient):
         :param progress: Callback for progress reports (strings)
         :return: Dictionary with all remote refs (not just those fetched)
         """
-        from dulwich.repo import Repo
-        with closing(Repo(path)) as r:
+        with self._open_repo(path) as r:
             objects_iter = r.fetch_objects(determine_wants, graph_walker, progress)
 
             # Did the process short-circuit (e.g. in a stateless RPC call)? Note
@@ -891,9 +903,8 @@ class LocalGitClient(GitClient):
 
     def get_refs(self, path):
         """Retrieve the current refs from a git smart server."""
-        from dulwich.repo import Repo
 
-        with closing(Repo(path)) as target:
+        with self._open_repo(path) as target:
             return target.get_refs()
 
 
@@ -994,9 +1005,9 @@ class SSHGitClient(TraditionalGitClient):
 
     def _connect(self, cmd, path):
         if type(cmd) is not bytes:
-            raise TypeError(path)
+            raise TypeError(cmd)
         if type(path) is not bytes:
-            raise TypeError(path)
+            path = path.encode(self._remote_path_encoding)
         if path.startswith(b"/~"):
             path = path[1:]
         argv = self._get_cmd_path(cmd) + b" '" + path + b"'"
@@ -1055,7 +1066,11 @@ class HttpGitClient(GitClient):
     def from_parsedurl(cls, parsedurl, **kwargs):
         auth, host = urllib2.splituser(parsedurl.netloc)
         password = parsedurl.password
+        if password is not None:
+            password = urlunquote(password)
         username = parsedurl.username
+        if username is not None:
+            username = urlunquote(username)
         # TODO(jelmer): This also strips the username
         parsedurl = parsedurl._replace(netloc=host)
         return cls(urlparse.urlunparse(parsedurl),

+ 4 - 0
dulwich/config.py

@@ -149,6 +149,10 @@ class ConfigDict(Config, MutableMapping):
     def set(self, section, name, value):
         if not isinstance(section, tuple):
             section = (section, )
+        if not isinstance(name, bytes):
+            raise TypeError(name)
+        if type(value) not in (bool, bytes):
+            raise TypeError(value)
         self._values.setdefault(section, OrderedDict())[name] = value
 
     def iteritems(self, section):

+ 7 - 0
dulwich/contrib/swift.py

@@ -927,6 +927,13 @@ class SwiftRepo(BaseRepo):
         refs = SwiftInfoRefsContainer(self.scon, object_store)
         BaseRepo.__init__(self, object_store, refs)
 
+    def _determine_file_mode(self):
+        """Probe the file-system to determine whether permissions can be trusted.
+
+        :return: True if permissions can be trusted, False otherwise.
+        """
+        return False
+
     def _put_named_file(self, filename, contents):
         """Put an object in a Swift container
 

+ 41 - 15
dulwich/index.py

@@ -422,21 +422,28 @@ def build_file_from_blob(blob, mode, target_path, honor_filemode=True):
     :param honor_filemode: An optional flag to honor core.filemode setting in
         config file, default is core.filemode=True, change executable bit
     """
+    try:
+        oldstat = os.stat(target_path)
+    except OSError as e:
+        if e.errno == errno.ENOENT:
+            oldstat = None
+        else:
+            raise
+    contents = blob.as_raw_string()
     if stat.S_ISLNK(mode):
         # FIXME: This will fail on Windows. What should we do instead?
-        src_path = blob.as_raw_string()
-        try:
-            os.symlink(src_path, target_path)
-        except OSError as e:
-            if e.errno == errno.EEXIST:
-                os.unlink(target_path)
-                os.symlink(src_path, target_path)
-            else:
-                raise
+        if oldstat:
+            os.unlink(target_path)
+        os.symlink(contents, target_path)
     else:
+        if oldstat is not None and oldstat.st_size == len(contents):
+            with open(target_path, 'rb') as f:
+                if f.read() == contents:
+                    return
+
         with open(target_path, 'wb') as f:
             # Write out file
-            f.write(blob.as_raw_string())
+            f.write(contents)
 
         if honor_filemode:
             os.chmod(target_path, mode)
@@ -499,11 +506,22 @@ def build_index_from_tree(root_path, index_path, object_store, tree_id,
             os.makedirs(os.path.dirname(full_path))
 
         # FIXME: Merge new index into working tree
-        obj = object_store[entry.sha]
-        build_file_from_blob(obj, entry.mode, full_path,
-            honor_filemode=honor_filemode)
+        if S_ISGITLINK(entry.mode):
+            os.mkdir(full_path)
+        else:
+            obj = object_store[entry.sha]
+            build_file_from_blob(obj, entry.mode, full_path,
+                honor_filemode=honor_filemode)
         # Add file to index
         st = os.lstat(full_path)
+        if not honor_filemode or S_ISGITLINK(entry.mode):
+            # we can not use tuple slicing to build a new tuple,
+            # because on windows that will convert the times to
+            # longs, which causes errors further along
+            st_tuple = (entry.mode, st.st_ino, st.st_dev, st.st_nlink,
+                        st.st_uid, st.st_gid, st.st_size, st.st_atime,
+                        st.st_mtime, st.st_ctime)
+            st = st.__class__(st_tuple)
         index[entry.path] = index_entry_from_stat(st, entry.sha, 0)
 
     index.write()
@@ -539,9 +557,17 @@ def get_unstaged_changes(index, root_path):
 
     for tree_path, entry in index.iteritems():
         full_path = _tree_to_fs_path(root_path, tree_path)
-        blob = blob_from_path_and_stat(full_path, os.lstat(full_path))
-        if blob.id != entry.sha:
+        try:
+            blob = blob_from_path_and_stat(full_path, os.lstat(full_path))
+        except OSError as e:
+            if e.errno != errno.ENOENT:
+                raise
+            # The file was removed, so we assume that counts as
+            # different from whatever file used to exist.
             yield tree_path
+        else:
+            if blob.id != entry.sha:
+                yield tree_path
 
 
 os_sep_bytes = os.sep.encode('ascii')

+ 5 - 2
dulwich/object_store.py

@@ -481,9 +481,12 @@ class DiskObjectStore(PackBasedObjectStore):
         pack_files = set()
         for name in pack_dir_contents:
             assert isinstance(name, basestring if sys.version_info[0] == 2 else str)
-            # TODO: verify that idx exists first
             if name.startswith("pack-") and name.endswith(".pack"):
-                pack_files.add(name[:-len(".pack")])
+                # verify that idx exists first (otherwise the pack was not yet fully written)
+                idx_name = os.path.splitext(name)[0] + ".idx"
+                if idx_name in pack_dir_contents:
+                    pack_name = name[:-len(".pack")]
+                    pack_files.add(pack_name)
 
         # Open newly appeared pack files
         for f in pack_files:

+ 17 - 9
dulwich/porcelain.py

@@ -101,6 +101,7 @@ from dulwich.protocol import (
     Protocol,
     ZERO_SHA,
     )
+from dulwich.refs import ANNOTATED_TAG_SUFFIX
 from dulwich.repo import (BaseRepo, Repo)
 from dulwich.server import (
     FileSystemBackend,
@@ -122,13 +123,6 @@ default_bytes_err_stream = getattr(sys.stderr, 'buffer', sys.stderr)
 DEFAULT_ENCODING = 'utf-8'
 
 
-def encode_path(path, default_encoding=DEFAULT_ENCODING):
-    """Encode a path as bytestring."""
-    if not isinstance(path, bytes):
-        path = path.encode(default_encoding)
-    return path
-
-
 def open_repo(path_or_repo):
     """Open an argument that can be a repository or a path for a repository."""
     if isinstance(path_or_repo, BaseRepo):
@@ -248,6 +242,7 @@ def clone(source, target=None, bare=False, checkout=None,
     :param source: Path or URL for source repository
     :param target: Path to target repository (optional)
     :param bare: Whether or not to create a bare repository
+    :param checkout: Whether or not to check-out HEAD after cloning
     :param errstream: Optional stream to write progress to
     :param outstream: Optional stream to write progress to (deprecated)
     :return: The new repository
@@ -285,8 +280,16 @@ def clone(source, target=None, bare=False, checkout=None,
         r.refs.import_refs(
             b'refs/tags',
             {n[len(b'refs/tags/'):]: v for (n, v) in remote_refs.items()
-                if n.startswith(b'refs/tags/')})
+                if n.startswith(b'refs/tags/') and
+                not n.endswith(ANNOTATED_TAG_SUFFIX)})
         r[b"HEAD"] = remote_refs[b"HEAD"]
+        target_config = r.get_config()
+        if not isinstance(source, bytes):
+            source = source.encode(DEFAULT_ENCODING)
+        target_config.set((b'remote', b'origin'), b'url', source)
+        target_config.set((b'remote', b'origin'), b'fetch',
+            b'+refs/heads/*:refs/remotes/origin/*')
+        target_config.write_to_path()
         if checkout:
             errstream.write(b'Checking out HEAD\n')
             r.reset_index()
@@ -903,8 +906,13 @@ def fetch(repo, remote_location, outstream=sys.stdout,
 
 
 def ls_remote(remote):
+    """List the refs in a remote.
+
+    :param remote: Remote repository location
+    :return: Dictionary with remote refs
+    """
     client, host_path = get_transport_and_path(remote)
-    return client.get_refs(encode_path(host_path))
+    return client.get_refs(host_path)
 
 
 def repack(repo):

+ 4 - 3
dulwich/refs.py

@@ -44,6 +44,7 @@ from dulwich.file import (
 SYMREF = b'ref: '
 LOCAL_BRANCH_PREFIX = b'refs/heads/'
 BAD_REF_CHARS = set(b'\177 ~^:?*[')
+ANNOTATED_TAG_SUFFIX = b'^{}'
 
 
 def check_ref_format(refname):
@@ -374,7 +375,7 @@ class InfoRefsContainer(RefsContainer):
         self._peeled = {}
         for l in f.readlines():
             sha, name = l.rstrip(b'\n').split(b'\t')
-            if name.endswith(b'^{}'):
+            if name.endswith(ANNOTATED_TAG_SUFFIX):
                 name = name[:-3]
                 if not check_ref_format(name):
                     raise ValueError("invalid ref name %r" % name)
@@ -685,7 +686,7 @@ class DiskRefsContainer(RefsContainer):
 
 def _split_ref_line(line):
     """Split a single ref line into a tuple of SHA1 and name."""
-    fields = line.rstrip(b'\n').split(b' ')
+    fields = line.rstrip(b'\n\r').split(b' ')
     if len(fields) != 2:
         raise PackedRefsException("invalid ref line %r" % line)
     sha, name = fields
@@ -782,7 +783,7 @@ def write_info_refs(refs, store):
         peeled = store.peel_sha(sha)
         yield o.id + b'\t' + name + b'\n'
         if o.id != peeled.id:
-            yield peeled.id + b'\t' + name + b'^{}\n'
+            yield peeled.id + b'\t' + name + ANNOTATED_TAG_SUFFIX + b'\n'
 
 
 is_local_branch = lambda x: x.startswith(b'refs/heads/')

+ 54 - 1
dulwich/repo.py

@@ -32,6 +32,7 @@ from io import BytesIO
 import errno
 import os
 import sys
+import stat
 
 from dulwich.errors import (
     NoIndexPresent,
@@ -180,6 +181,13 @@ class BaseRepo(object):
         self._graftpoints = {}
         self.hooks = {}
 
+    def _determine_file_mode(self):
+        """Probe the file-system to determine whether permissions can be trusted.
+
+        :return: True if permissions can be trusted, False otherwise.
+        """
+        raise NotImplementedError(self._determine_file_mode)
+
     def _init_files(self, bare):
         """Initialize a default set of named files."""
         from dulwich.config import ConfigFile
@@ -187,7 +195,11 @@ class BaseRepo(object):
         f = BytesIO()
         cf = ConfigFile()
         cf.set(b"core", b"repositoryformatversion", b"0")
-        cf.set(b"core", b"filemode", b"true")
+        if self._determine_file_mode():
+            cf.set(b"core", b"filemode", True)
+        else:
+            cf.set(b"core", b"filemode", False)
+
         cf.set(b"core", b"bare", bare)
         cf.set(b"core", b"logallrefupdates", True)
         cf.write_to_file(f)
@@ -745,6 +757,26 @@ class Repo(BaseRepo):
 
         return self._commondir
 
+    def _determine_file_mode(self):
+        """Probe the file-system to determine whether permissions can be trusted.
+
+        :return: True if permissions can be trusted, False otherwise.
+        """
+        fname = os.path.join(self.path, '.probe-permissions')
+        with open(fname, 'w') as f:
+            f.write('')
+
+        st1 = os.lstat(fname)
+        os.chmod(fname, st1.st_mode ^ stat.S_IXUSR)
+        st2 = os.lstat(fname)
+
+        os.unlink(fname)
+
+        mode_differs = st1.st_mode != st2.st_mode
+        st2_has_exec = (st2.st_mode & stat.S_IXUSR) != 0
+
+        return mode_differs and st2_has_exec
+
     def _put_named_file(self, path, contents):
         """Write a file to the control dir with the given name and contents.
 
@@ -858,6 +890,14 @@ class Repo(BaseRepo):
             target.refs.add_if_new(DEFAULT_REF, self.refs[DEFAULT_REF])
         except KeyError:
             pass
+        target_config = target.get_config()
+        encoded_path = self.path
+        if not isinstance(encoded_path, bytes):
+            encoded_path = encoded_path.encode(sys.getfilesystemencoding())
+        target_config.set((b'remote', b'origin'), b'url', encoded_path)
+        target_config.set((b'remote', b'origin'), b'fetch',
+            b'+refs/heads/*:refs/remotes/origin/*')
+        target_config.write_to_path()
 
         # Update target head
         head_chain, head_sha = self.refs.follow(b'HEAD')
@@ -1017,6 +1057,12 @@ class Repo(BaseRepo):
         """Close any files opened by this repository."""
         self.object_store.close()
 
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.close()
+
 
 class MemoryRepo(BaseRepo):
     """Repo that stores refs, objects, and named files in memory.
@@ -1032,6 +1078,13 @@ class MemoryRepo(BaseRepo):
         self.bare = True
         self._config = ConfigFile()
 
+    def _determine_file_mode(self):
+        """Probe the file-system to determine whether permissions can be trusted.
+
+        :return: True if permissions can be trusted, False otherwise.
+        """
+        return sys.platform != 'win32'
+
     def _put_named_file(self, path, contents):
         """Write a file to the control dir with the given name and contents.
 

+ 3 - 1
dulwich/server.py

@@ -106,6 +106,7 @@ from dulwich.protocol import (
     extract_want_line_capabilities,
     )
 from dulwich.refs import (
+    ANNOTATED_TAG_SUFFIX,
     write_info_refs,
     )
 from dulwich.repo import (
@@ -537,7 +538,8 @@ class ProtocolGraphWalker(object):
                 self.proto.write_pkt_line(line + b'\n')
                 peeled_sha = self.get_peeled(ref)
                 if peeled_sha != sha:
-                    self.proto.write_pkt_line(peeled_sha + b' ' + ref + b'^{}\n')
+                    self.proto.write_pkt_line(
+                        peeled_sha + b' ' + ref + ANNOTATED_TAG_SUFFIX + b'\n')
 
             # i'm done..
             self.proto.write_pkt_line(None)

+ 12 - 13
dulwich/tests/compat/test_client.py

@@ -20,7 +20,6 @@
 
 """Compatibilty tests between the Dulwich client and the cgit server."""
 
-from contextlib import closing
 import copy
 from io import BytesIO
 import os
@@ -87,8 +86,8 @@ class DulwichClientTestBase(object):
     def assertDestEqualsSrc(self):
         repo_dir = os.path.join(self.gitroot, 'server_new.export')
         dest_repo_dir = os.path.join(self.gitroot, 'dest')
-        with closing(repo.Repo(repo_dir)) as src:
-            with closing(repo.Repo(dest_repo_dir)) as dest:
+        with repo.Repo(repo_dir) as src:
+            with repo.Repo(dest_repo_dir) as dest:
                 self.assertReposEqual(src, dest)
 
     def _client(self):
@@ -100,7 +99,7 @@ class DulwichClientTestBase(object):
     def _do_send_pack(self):
         c = self._client()
         srcpath = os.path.join(self.gitroot, 'server_new.export')
-        with closing(repo.Repo(srcpath)) as src:
+        with repo.Repo(srcpath) as src:
             sendrefs = dict(src.get_refs())
             del sendrefs[b'HEAD']
             c.send_pack(self._build_path(b'/dest'), lambda _: sendrefs,
@@ -120,7 +119,7 @@ class DulwichClientTestBase(object):
         c = self._client()
         c._send_capabilities.remove(b'report-status')
         srcpath = os.path.join(self.gitroot, 'server_new.export')
-        with closing(repo.Repo(srcpath)) as src:
+        with repo.Repo(srcpath) as src:
             sendrefs = dict(src.get_refs())
             del sendrefs[b'HEAD']
             c.send_pack(self._build_path(b'/dest'), lambda _: sendrefs,
@@ -157,7 +156,7 @@ class DulwichClientTestBase(object):
         dest, dummy_commit = self.disable_ff_and_make_dummy_commit()
         dest.refs[b'refs/heads/master'] = dummy_commit
         repo_dir = os.path.join(self.gitroot, 'server_new.export')
-        with closing(repo.Repo(repo_dir)) as src:
+        with repo.Repo(repo_dir) as src:
             sendrefs, gen_pack = self.compute_send(src)
             c = self._client()
             try:
@@ -175,7 +174,7 @@ class DulwichClientTestBase(object):
         branch, master = b'refs/heads/branch', b'refs/heads/master'
         dest.refs[branch] = dest.refs[master] = dummy
         repo_dir = os.path.join(self.gitroot, 'server_new.export')
-        with closing(repo.Repo(repo_dir)) as src:
+        with repo.Repo(repo_dir) as src:
             sendrefs, gen_pack = self.compute_send(src)
             c = self._client()
             try:
@@ -200,7 +199,7 @@ class DulwichClientTestBase(object):
 
     def test_fetch_pack(self):
         c = self._client()
-        with closing(repo.Repo(os.path.join(self.gitroot, 'dest'))) as dest:
+        with repo.Repo(os.path.join(self.gitroot, 'dest')) as dest:
             refs = c.fetch(self._build_path(b'/server_new.export'), dest)
             for r in refs.items():
                 dest.refs.set_if_equals(r[0], None, r[1])
@@ -212,7 +211,7 @@ class DulwichClientTestBase(object):
         dest.refs[b'refs/heads/master'] = dummy
         c = self._client()
         repo_dir = os.path.join(self.gitroot, 'server_new.export')
-        with closing(repo.Repo(repo_dir)) as dest:
+        with repo.Repo(repo_dir) as dest:
             refs = c.fetch(self._build_path(b'/dest'), dest)
             for r in refs.items():
                 dest.refs.set_if_equals(r[0], None, r[1])
@@ -221,7 +220,7 @@ class DulwichClientTestBase(object):
     def test_fetch_pack_no_side_band_64k(self):
         c = self._client()
         c._fetch_capabilities.remove(b'side-band-64k')
-        with closing(repo.Repo(os.path.join(self.gitroot, 'dest'))) as dest:
+        with repo.Repo(os.path.join(self.gitroot, 'dest')) as dest:
             refs = c.fetch(self._build_path(b'/server_new.export'), dest)
             for r in refs.items():
                 dest.refs.set_if_equals(r[0], None, r[1])
@@ -231,14 +230,14 @@ class DulwichClientTestBase(object):
         # zero sha1s are already present on the client, and should
         # be ignored
         c = self._client()
-        with closing(repo.Repo(os.path.join(self.gitroot, 'dest'))) as dest:
+        with repo.Repo(os.path.join(self.gitroot, 'dest')) as dest:
             refs = c.fetch(self._build_path(b'/server_new.export'), dest,
                 lambda refs: [protocol.ZERO_SHA])
             for r in refs.items():
                 dest.refs.set_if_equals(r[0], None, r[1])
 
     def test_send_remove_branch(self):
-        with closing(repo.Repo(os.path.join(self.gitroot, 'dest'))) as dest:
+        with repo.Repo(os.path.join(self.gitroot, 'dest')) as dest:
             dummy_commit = self.make_dummy_commit(dest)
             dest.refs[b'refs/heads/master'] = dummy_commit
             dest.refs[b'refs/heads/abranch'] = dummy_commit
@@ -256,7 +255,7 @@ class DulwichClientTestBase(object):
         refs = c.get_refs(self._build_path(b'/server_new.export'))
 
         repo_dir = os.path.join(self.gitroot, 'server_new.export')
-        with closing(repo.Repo(repo_dir)) as dest:
+        with repo.Repo(repo_dir) as dest:
             self.assertDictEqual(dest.refs.as_dict(), refs)
 
 

+ 11 - 3
dulwich/tests/compat/test_repository.py

@@ -26,6 +26,8 @@ from itertools import chain
 import os
 import tempfile
 
+import sys
+
 from dulwich.objects import (
     hex_to_sha,
     )
@@ -34,6 +36,7 @@ from dulwich.repo import (
     Repo,
     )
 from dulwich.tests.compat.utils import (
+    require_git_version,
     rmtree_ro,
     run_git_or_fail,
     CompatTestCase,
@@ -172,17 +175,22 @@ class WorkingTreeTestCase(ObjectStoreTestCase):
         return worktrees
 
     def test_git_worktree_list(self):
+        # 'git worktree list' was introduced in 2.7.0
+        require_git_version((2, 7, 0))
         output = run_git_or_fail(['worktree', 'list'], cwd=self._repo.path)
         worktrees = self._parse_worktree_list(output)
         self.assertEqual(len(worktrees), self._number_of_working_tree)
         self.assertEqual(worktrees[0][1], '(bare)')
-        self.assertEqual(worktrees[0][0], self._mainworktree_repo.path)
+        self.assertEqual(os.path.normcase(worktrees[0][0]),
+                         os.path.normcase(self._mainworktree_repo.path))
 
-        output = run_git_or_fail(['worktree', 'list'], cwd=self._mainworktree_repo.path)
+        output = run_git_or_fail(['worktree', 'list'],
+            cwd=self._mainworktree_repo.path)
         worktrees = self._parse_worktree_list(output)
         self.assertEqual(len(worktrees), self._number_of_working_tree)
         self.assertEqual(worktrees[0][1], '(bare)')
-        self.assertEqual(worktrees[0][0], self._mainworktree_repo.path)
+        self.assertEqual(os.path.normcase(worktrees[0][0]),
+                         os.path.normcase(self._mainworktree_repo.path))
 
 
 class InitNewWorkingDirectoryTestCase(WorkingTreeTestCase):

+ 32 - 2
dulwich/tests/test_client.py

@@ -18,12 +18,20 @@
 # License, Version 2.0.
 #
 
-from contextlib import closing
 from io import BytesIO
 import sys
 import shutil
 import tempfile
 
+try:
+    from urllib import quote as urlquote
+except ImportError:
+    from urllib.parse import quote as urlquote
+
+try:
+    import urlparse
+except ImportError:
+    import urllib.parse as urlparse
 
 import dulwich
 from dulwich import (
@@ -753,7 +761,7 @@ class LocalGitClientTests(TestCase):
 
         target_path = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, target_path)
-        with closing(Repo.init_bare(target_path)) as target:
+        with Repo.init_bare(target_path) as target:
             self.send_and_verify(b"master", local, target)
 
     def test_get_refs(self):
@@ -820,6 +828,28 @@ class HttpGitClientTests(TestCase):
             h for h in c.opener.handlers if getattr(h, 'passwd', None) is not None]
         self.assertEqual(0, len(pw_handler))
 
+    def test_from_parsedurl_on_url_with_quoted_credentials(self):
+        original_username = 'john|the|first'
+        quoted_username = urlquote(original_username)
+
+        original_password = 'Ya#1$2%3'
+        quoted_password = urlquote(original_password)
+
+        url = 'https://{username}:{password}@github.com/jelmer/dulwich'.format(
+            username=quoted_username,
+            password=quoted_password
+        )
+
+        c = HttpGitClient.from_parsedurl(urlparse.urlparse(url))
+        self.assertEqual(original_username, c._username)
+        self.assertEqual(original_password, c._password)
+        [pw_handler] = [
+            h for h in c.opener.handlers if getattr(h, 'passwd', None) is not None]
+        self.assertEqual(
+            (original_username, original_password),
+            pw_handler.passwd.find_user_password(
+                None, 'https://github.com/jelmer/dulwich'))
+
 
 class TCPGitClientTests(TestCase):
 

+ 109 - 7
dulwich/tests/test_index.py

@@ -23,7 +23,6 @@
 """Tests for the index."""
 
 
-from contextlib import closing
 from io import BytesIO
 import os
 import shutil
@@ -54,7 +53,9 @@ from dulwich.object_store import (
     )
 from dulwich.objects import (
     Blob,
+    Commit,
     Tree,
+    S_IFGITLINK,
     )
 from dulwich.repo import Repo
 from dulwich.tests import (
@@ -267,7 +268,7 @@ class BuildIndexTests(TestCase):
     def test_empty(self):
         repo_dir = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, repo_dir)
-        with closing(Repo.init(repo_dir)) as repo:
+        with Repo.init(repo_dir) as repo:
             tree = Tree()
             repo.object_store.add_object(tree)
 
@@ -284,7 +285,7 @@ class BuildIndexTests(TestCase):
     def test_git_dir(self):
         repo_dir = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, repo_dir)
-        with closing(Repo.init(repo_dir)) as repo:
+        with Repo.init(repo_dir) as repo:
 
             # Populate repo
             filea = Blob.from_string(b'file a')
@@ -318,7 +319,7 @@ class BuildIndexTests(TestCase):
     def test_nonempty(self):
         repo_dir = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, repo_dir)
-        with closing(Repo.init(repo_dir)) as repo:
+        with Repo.init(repo_dir) as repo:
 
             # Populate repo
             filea = Blob.from_string(b'file a')
@@ -367,11 +368,51 @@ class BuildIndexTests(TestCase):
             self.assertEqual(['d'],
                 sorted(os.listdir(os.path.join(repo.path, 'c'))))
 
+    def test_norewrite(self):
+        sync = getattr(os, 'sync', lambda: os.system('sync'))
+        repo_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, repo_dir)
+        with Repo.init(repo_dir) as repo:
+            # Populate repo
+            filea = Blob.from_string(b'file a')
+            filea_path = os.path.join(repo_dir, 'a')
+            tree = Tree()
+            tree[b'a'] = (stat.S_IFREG | 0o644, filea.id)
+
+            repo.object_store.add_objects([(o, None)
+                for o in [filea, tree]])
+
+            # First Write
+            build_index_from_tree(repo.path, repo.index_path(),
+                                  repo.object_store, tree.id)
+            # Use sync as metadata can be cached on some FS
+            sync()
+            mtime = os.stat(filea_path).st_mtime
+
+            # Test Rewrite
+            build_index_from_tree(repo.path, repo.index_path(),
+                                  repo.object_store, tree.id)
+            sync()
+            self.assertEqual(mtime, os.stat(filea_path).st_mtime)
+
+            # Modify content
+            with open(filea_path, 'wb') as fh:
+                fh.write(b'test a')
+            sync()
+            mtime = os.stat(filea_path).st_mtime
+
+            # Test rewrite
+            build_index_from_tree(repo.path, repo.index_path(),
+                                  repo.object_store, tree.id)
+            sync()
+            self.assertNotEqual(mtime, os.stat(filea_path).st_mtime)
+
+
     @skipIf(not getattr(os, 'symlink', None), 'Requires symlink support')
     def test_symlink(self):
         repo_dir = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, repo_dir)
-        with closing(Repo.init(repo_dir)) as repo:
+        with Repo.init(repo_dir) as repo:
 
             # Populate repo
             filed = Blob.from_string(b'file d')
@@ -403,7 +444,7 @@ class BuildIndexTests(TestCase):
         repo_dir = tempfile.mkdtemp()
         repo_dir_bytes = repo_dir.encode(sys.getfilesystemencoding())
         self.addCleanup(shutil.rmtree, repo_dir)
-        with closing(Repo.init(repo_dir)) as repo:
+        with Repo.init(repo_dir) as repo:
 
             # Populate repo
             file = Blob.from_string(b'foo')
@@ -430,6 +471,46 @@ class BuildIndexTests(TestCase):
             utf8_path = os.path.join(repo_dir_bytes, utf8_name)
             self.assertTrue(os.path.exists(utf8_path))
 
+    def test_git_submodule(self):
+        repo_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, repo_dir)
+        with Repo.init(repo_dir) as repo:
+            filea = Blob.from_string(b'file alalala')
+
+            subtree = Tree()
+            subtree[b'a'] = (stat.S_IFREG | 0o644, filea.id)
+
+            c = Commit()
+            c.tree = subtree.id
+            c.committer = c.author = b'Somebody <somebody@example.com>'
+            c.commit_time = c.author_time = 42342
+            c.commit_timezone = c.author_timezone = 0
+            c.parents = []
+            c.message = b'Subcommit'
+
+            tree = Tree()
+            tree[b'c'] = (S_IFGITLINK, c.id)
+
+            repo.object_store.add_objects(
+                [(o, None) for o in [tree]])
+
+            build_index_from_tree(repo.path, repo.index_path(),
+                    repo.object_store, tree.id)
+
+            # Verify index entries
+            index = repo.open_index()
+            self.assertEqual(len(index), 1)
+
+            # filea
+            apath = os.path.join(repo.path, 'c/a')
+            self.assertFalse(os.path.exists(apath))
+
+            # dir c
+            cpath = os.path.join(repo.path, 'c')
+            self.assertTrue(os.path.isdir(cpath))
+            self.assertEqual(index[b'c'][4], S_IFGITLINK)  # mode
+            self.assertEqual(index[b'c'][8], c.id)  # sha
+
 
 class GetUnstagedChangesTests(TestCase):
 
@@ -438,7 +519,7 @@ class GetUnstagedChangesTests(TestCase):
 
         repo_dir = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, repo_dir)
-        with closing(Repo.init(repo_dir)) as repo:
+        with Repo.init(repo_dir) as repo:
 
             # Commit a dummy file then modify it
             foo1_fullpath = os.path.join(repo_dir, 'foo1')
@@ -462,6 +543,27 @@ class GetUnstagedChangesTests(TestCase):
 
             self.assertEqual(list(changes), [b'foo1'])
 
+    def test_get_unstaged_deleted_changes(self):
+        """Unit test for get_unstaged_changes."""
+
+        repo_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, repo_dir)
+        with Repo.init(repo_dir) as repo:
+
+            # Commit a dummy file then remove it
+            foo1_fullpath = os.path.join(repo_dir, 'foo1')
+            with open(foo1_fullpath, 'wb') as f:
+                f.write(b'origstuff')
+
+            repo.stage(['foo1'])
+            repo.do_commit(b'test status', author=b'', committer=b'')
+
+            os.unlink(foo1_fullpath)
+
+            changes = get_unstaged_changes(repo.open_index(), repo_dir)
+
+            self.assertEqual(list(changes), [b'foo1'])
+
 
 class TestValidatePathElement(TestCase):
 

+ 31 - 9
dulwich/tests/test_porcelain.py

@@ -20,7 +20,6 @@
 
 """Tests for dulwich.porcelain."""
 
-from contextlib import closing
 from io import BytesIO
 try:
     from StringIO import StringIO
@@ -126,6 +125,14 @@ class CloneTests(PorcelainTestCase):
         self.assertEqual(c3.id, target_repo.refs[b'refs/tags/foo'])
         self.assertTrue(b'f1' not in os.listdir(target_path))
         self.assertTrue(b'f2' not in os.listdir(target_path))
+        c = r.get_config()
+        encoded_path = self.repo.path
+        if not isinstance(encoded_path, bytes):
+            encoded_path = encoded_path.encode('utf-8')
+        self.assertEqual(encoded_path, c.get((b'remote', b'origin'), b'url'))
+        self.assertEqual(
+            b'+refs/heads/*:refs/remotes/origin/*',
+            c.get((b'remote', b'origin'), b'fetch'))
 
     def test_simple_local_with_checkout(self):
         f1_1 = make_object(Blob, data=b'f1')
@@ -140,11 +147,11 @@ class CloneTests(PorcelainTestCase):
         target_path = tempfile.mkdtemp()
         errstream = BytesIO()
         self.addCleanup(shutil.rmtree, target_path)
-        with closing(porcelain.clone(self.repo.path, target_path,
-                                     checkout=True,
-                                     errstream=errstream)) as r:
+        with porcelain.clone(self.repo.path, target_path,
+                             checkout=True,
+                             errstream=errstream) as r:
             self.assertEqual(r.path, target_path)
-        with closing(Repo(target_path)) as r:
+        with Repo(target_path) as r:
             self.assertEqual(r.head(), c3.id)
         self.assertTrue('f1' in os.listdir(target_path))
         self.assertTrue('f2' in os.listdir(target_path))
@@ -511,7 +518,7 @@ class PushTests(PorcelainTestCase):
             errstream=errstream)
 
         # Check that the target and source
-        with closing(Repo(clone_path)) as r_clone:
+        with Repo(clone_path) as r_clone:
             self.assertEqual({
                 b'HEAD': new_id,
                 b'refs/heads/foo': r_clone[b'HEAD'].id,
@@ -598,7 +605,7 @@ class PullTests(PorcelainTestCase):
             outstream=outstream, errstream=errstream)
 
         # Check the target repo for pushed changes
-        with closing(Repo(self.target_path)) as r:
+        with Repo(self.target_path) as r:
             self.assertEqual(r[b'HEAD'].id, self.repo[b'HEAD'].id)
 
     def test_no_refspec(self):
@@ -610,7 +617,7 @@ class PullTests(PorcelainTestCase):
                        errstream=errstream)
 
         # Check the target repo for pushed changes
-        with closing(Repo(self.target_path)) as r:
+        with Repo(self.target_path) as r:
             self.assertEqual(r[b'HEAD'].id, self.repo[b'HEAD'].id)
 
 
@@ -830,7 +837,7 @@ class FetchTests(PorcelainTestCase):
             errstream=errstream)
 
         # Check the target repo for pushed changes
-        with closing(Repo(target_path)) as r:
+        with Repo(target_path) as r:
             self.assertTrue(self.repo[b'HEAD'].id in r)
 
 
@@ -872,3 +879,18 @@ class LsTreeTests(PorcelainTestCase):
         self.assertEqual(
                 f.getvalue(),
                 '100644 blob 8b82634d7eae019850bb883f06abf428c58bc9aa\tfoo\n')
+
+
+class LsRemoteTests(PorcelainTestCase):
+
+    def test_empty(self):
+        self.assertEqual({}, porcelain.ls_remote(self.repo.path))
+
+    def test_some(self):
+        cid = porcelain.commit(repo=self.repo.path, message=b'test status',
+            author=b'', committer=b'')
+
+        self.assertEqual({
+            b'refs/heads/master': cid,
+            b'HEAD': cid},
+            porcelain.ls_remote(self.repo.path))

+ 3 - 2
dulwich/tests/test_refs.py

@@ -327,8 +327,9 @@ class DiskRefsContainerTests(RefsContainerTests, TestCase):
 
         # ensure HEAD was not modified
         f = open(os.path.join(self._refs.path, 'HEAD'), 'rb')
-        self.assertEqual(b'ref: refs/heads/master', next(iter(f)).rstrip(b'\n'))
+        v = next(iter(f)).rstrip(b'\n\r')
         f.close()
+        self.assertEqual(b'ref: refs/heads/master', v)
 
         # ensure the symbolic link was written through
         f = open(os.path.join(self._refs.path, 'refs', 'heads', 'master'), 'rb')
@@ -450,7 +451,7 @@ class DiskRefsContainerTests(RefsContainerTests, TestCase):
             encoded_ref = u'refs/tags/schön'.encode(sys.getfilesystemencoding())
         except UnicodeEncodeError:
             raise SkipTest("filesystem encoding doesn't support special character")
-        p = os.path.join(self._repo.path, 'refs', 'tags', 'schön')
+        p = os.path.join(self._repo.path, 'refs', 'tags', u'schön')
         with open(p, 'w') as f:
             f.write('00' * 20)
 

+ 17 - 4
dulwich/tests/test_repository.py

@@ -21,7 +21,6 @@
 
 """Tests for the repository."""
 
-from contextlib import closing
 import locale
 import os
 import stat
@@ -73,6 +72,12 @@ class CreateRepositoryTests(TestCase):
         with repo.get_named_file('config') as f:
             config_text = f.read()
             self.assertTrue(barestr in config_text, "%r" % config_text)
+        expect_filemode = sys.platform != 'win32'
+        barestr = b'filemode = ' + str(expect_filemode).lower().encode('ascii')
+        with repo.get_named_file('config') as f:
+            config_text = f.read()
+            self.assertTrue(barestr in config_text, "%r" % config_text)
+
 
     def test_create_memory(self):
         repo = MemoryRepo.init_bare([], {})
@@ -233,7 +238,7 @@ class RepositoryRootTests(TestCase):
         r = self.open_repo('a.git')
         tmp_dir = self.mkdtemp()
         self.addCleanup(shutil.rmtree, tmp_dir)
-        with closing(r.clone(tmp_dir, mkdir=False)) as t:
+        with r.clone(tmp_dir, mkdir=False) as t:
             self.assertEqual({
                 b'HEAD': b'a90fa2d900a17e99b433217e988c4eb4a2e9a097',
                 b'refs/remotes/origin/master':
@@ -246,6 +251,14 @@ class RepositoryRootTests(TestCase):
             shas = [e.commit.id for e in r.get_walker()]
             self.assertEqual(shas, [t.head(),
                              b'2a72d929692c41d8554c07f6301757ba18a65d91'])
+            c = t.get_config()
+            encoded_path = r.path
+            if not isinstance(encoded_path, bytes):
+                encoded_path = encoded_path.encode(sys.getfilesystemencoding())
+            self.assertEqual(encoded_path, c.get((b'remote', b'origin'), b'url'))
+            self.assertEqual(
+                b'+refs/heads/*:refs/remotes/origin/*',
+                c.get((b'remote', b'origin'), b'fetch'))
 
     def test_clone_no_head(self):
         temp_dir = self.mkdtemp()
@@ -316,7 +329,7 @@ class RepositoryRootTests(TestCase):
                         os.path.join(temp_dir, 'a.git'), symlinks=True)
         rel = os.path.relpath(os.path.join(repo_dir, 'submodule'), temp_dir)
         os.symlink(os.path.join(rel, 'dotgit'), os.path.join(temp_dir, '.git'))
-        with closing(Repo(temp_dir)) as r:
+        with Repo(temp_dir) as r:
             self.assertEqual(r.head(), b'a90fa2d900a17e99b433217e988c4eb4a2e9a097')
 
     def test_common_revisions(self):
@@ -517,7 +530,7 @@ exit 1
         bare = self.open_repo('a.git')
         tmp_dir = self.mkdtemp()
         self.addCleanup(shutil.rmtree, tmp_dir)
-        with closing(bare.clone(tmp_dir, mkdir=False)) as nonbare:
+        with bare.clone(tmp_dir, mkdir=False) as nonbare:
             check(nonbare)
             check(bare)
 

+ 6 - 1
dulwich/tests/test_server.py

@@ -25,6 +25,8 @@ import os
 import shutil
 import tempfile
 
+import sys
+
 from dulwich.errors import (
     GitProtocolError,
     NotGitRepository,
@@ -993,7 +995,10 @@ class FileSystemBackendTests(TestCase):
         self.path = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, self.path)
         self.repo = Repo.init(self.path)
-        self.backend = FileSystemBackend()
+        if sys.platform == 'win32':
+            self.backend = FileSystemBackend(self.path[0] + ':' + os.sep)
+        else:
+            self.backend = FileSystemBackend()
 
     def test_nonexistant(self):
         self.assertRaises(NotGitRepository,

+ 126 - 0
dulwich/tests/test_walk.py

@@ -25,6 +25,7 @@ from itertools import (
     )
 
 from dulwich.diff_tree import (
+    CHANGE_ADD,
     CHANGE_MODIFY,
     CHANGE_RENAME,
     TreeChange,
@@ -415,3 +416,128 @@ class WalkerTest(TestCase):
     def test_empty_walk(self):
         c1, c2, c3 = self.make_linear_commits(3)
         self.assertWalkYields([], [c3.id], exclude=[c3.id])
+
+
+class WalkEntryTest(TestCase):
+
+    def setUp(self):
+        super(WalkEntryTest, self).setUp()
+        self.store = MemoryObjectStore()
+
+    def make_commits(self, commit_spec, **kwargs):
+        times = kwargs.pop('times', [])
+        attrs = kwargs.pop('attrs', {})
+        for i, t in enumerate(times):
+            attrs.setdefault(i + 1, {})['commit_time'] = t
+        return build_commit_graph(self.store, commit_spec, attrs=attrs,
+                                  **kwargs)
+
+    def make_linear_commits(self, num_commits, **kwargs):
+        commit_spec = []
+        for i in range(1, num_commits + 1):
+            c = [i]
+            if i > 1:
+                c.append(i - 1)
+            commit_spec.append(c)
+        return self.make_commits(commit_spec, **kwargs)
+
+    def test_all_changes(self):
+        # Construct a commit with 2 files in different subdirectories.
+        blob_a = make_object(Blob, data=b'a')
+        blob_b = make_object(Blob, data=b'b')
+        c1 = self.make_linear_commits(
+            1,
+            trees={1: [(b'x/a', blob_a), (b'y/b', blob_b)]},
+        )[0]
+
+        # Get the WalkEntry for the commit.
+        walker = Walker(self.store, c1.id)
+        walker_entry = list(walker)[0]
+        changes = walker_entry.changes()
+
+        # Compare the changes with the expected values.
+        entry_a = (b'x/a', F, blob_a.id)
+        entry_b = (b'y/b', F, blob_b.id)
+        self.assertEqual(
+            [TreeChange.add(entry_a),
+             TreeChange.add(entry_b)],
+            changes,
+        )
+
+    def test_all_with_merge(self):
+        blob_a = make_object(Blob, data=b'a')
+        blob_a2 = make_object(Blob, data=b'a2')
+        blob_b = make_object(Blob, data=b'b')
+        blob_b2 = make_object(Blob, data=b'b2')
+        x1, y2, m3 = self.make_commits(
+            [[1], [2], [3, 1, 2]],
+            trees={1: [(b'x/a', blob_a)],
+                   2: [(b'y/b', blob_b)],
+                   3: [(b'x/a', blob_a2), (b'y/b', blob_b2)]})
+
+        # Get the WalkEntry for the merge commit.
+        walker = Walker(self.store, m3.id)
+        entries = list(walker)
+        walker_entry = entries[0]
+        self.assertEqual(walker_entry.commit.id, m3.id)
+        changes = walker_entry.changes()
+        self.assertEqual(2, len(changes))
+
+        entry_a = (b'x/a', F, blob_a.id)
+        entry_a2 = (b'x/a', F, blob_a2.id)
+        entry_b = (b'y/b', F, blob_b.id)
+        entry_b2 = (b'y/b', F, blob_b2.id)
+        self.assertEqual(
+            [[TreeChange(CHANGE_MODIFY, entry_a, entry_a2),
+             TreeChange.add(entry_a2)],
+            [TreeChange.add(entry_b2),
+             TreeChange(CHANGE_MODIFY, entry_b, entry_b2)]],
+            changes,
+        )
+
+    def test_filter_changes(self):
+        # Construct a commit with 2 files in different subdirectories.
+        blob_a = make_object(Blob, data=b'a')
+        blob_b = make_object(Blob, data=b'b')
+        c1 = self.make_linear_commits(
+            1,
+            trees={1: [(b'x/a', blob_a), (b'y/b', blob_b)]},
+        )[0]
+
+        # Get the WalkEntry for the commit.
+        walker = Walker(self.store, c1.id)
+        walker_entry = list(walker)[0]
+        changes = walker_entry.changes(path_prefix=b'x')
+
+        # Compare the changes with the expected values.
+        entry_a = (b'a', F, blob_a.id)
+        self.assertEqual(
+            [TreeChange.add(entry_a)],
+            changes,
+        )
+
+    def test_filter_with_merge(self):
+        blob_a = make_object(Blob, data=b'a')
+        blob_a2 = make_object(Blob, data=b'a2')
+        blob_b = make_object(Blob, data=b'b')
+        blob_b2 = make_object(Blob, data=b'b2')
+        x1, y2, m3 = self.make_commits(
+            [[1], [2], [3, 1, 2]],
+            trees={1: [(b'x/a', blob_a)],
+                   2: [(b'y/b', blob_b)],
+                   3: [(b'x/a', blob_a2), (b'y/b', blob_b2)]})
+
+        # Get the WalkEntry for the merge commit.
+        walker = Walker(self.store, m3.id)
+        entries = list(walker)
+        walker_entry = entries[0]
+        self.assertEqual(walker_entry.commit.id, m3.id)
+        changes = walker_entry.changes(b'x')
+        self.assertEqual(1, len(changes))
+
+        entry_a = (b'a', F, blob_a.id)
+        entry_a2 = (b'a', F, blob_a2.id)
+        self.assertEqual(
+            [[TreeChange(CHANGE_MODIFY, entry_a, entry_a2)]],
+            changes,
+        )

+ 37 - 6
dulwich/walk.py

@@ -53,18 +53,22 @@ class WalkEntry(object):
         self.commit = commit
         self._store = walker.store
         self._get_parents = walker.get_parents
-        self._changes = None
+        self._changes = {}
         self._rename_detector = walker.rename_detector
 
-    def changes(self):
+    def changes(self, path_prefix=None):
         """Get the tree changes for this entry.
 
+        :param path_prefix: Portion of the path in the repository to
+            use to filter changes. Must be a directory name. Must be
+            a full, valid, path reference (no partial names or wildcards).
         :return: For commits with up to one parent, a list of TreeChange
             objects; if the commit has no parents, these will be relative to the
             empty tree. For merge commits, a list of lists of TreeChange
             objects; see dulwich.diff.tree_changes_for_merge.
         """
-        if self._changes is None:
+        cached = self._changes.get(path_prefix)
+        if cached is None:
             commit = self.commit
             if not self._get_parents(commit):
                 changes_func = tree_changes
@@ -72,13 +76,40 @@ class WalkEntry(object):
             elif len(self._get_parents(commit)) == 1:
                 changes_func = tree_changes
                 parent = self._store[self._get_parents(commit)[0]].tree
+                if path_prefix:
+                    mode, subtree_sha = parent.lookup_path(
+                        self._store.__getitem__,
+                        path_prefix,
+                    )
+                    parent = self._store[subtree_sha]
             else:
                 changes_func = tree_changes_for_merge
                 parent = [self._store[p].tree for p in self._get_parents(commit)]
-            self._changes = list(changes_func(
-              self._store, parent, commit.tree,
+                if path_prefix:
+                    parent_trees = [self._store[p] for p in parent]
+                    parent = []
+                    for p in parent_trees:
+                        try:
+                            mode, st = p.lookup_path(
+                                self._store.__getitem__,
+                                path_prefix,
+                            )
+                        except KeyError:
+                            pass
+                        else:
+                            parent.append(st)
+            commit_tree_sha = commit.tree
+            if path_prefix:
+                commit_tree = self._store[commit_tree_sha]
+                mode, commit_tree_sha = commit_tree.lookup_path(
+                    self._store.__getitem__,
+                    path_prefix,
+                )
+            cached = list(changes_func(
+              self._store, parent, commit_tree_sha,
               rename_detector=self._rename_detector))
-        return self._changes
+            self._changes[path_prefix] = cached
+        return self._changes[path_prefix]
 
     def __repr__(self):
         return '<WalkEntry commit=%s, changes=%r>' % (

+ 1 - 1
setup.py

@@ -9,7 +9,7 @@ except ImportError:
     from distutils.core import setup, Extension
 from distutils.core import Distribution
 
-dulwich_version_string = '0.16.1'
+dulwich_version_string = '0.16.2'
 
 include_dirs = []
 # Windows MSVC support