소스 검색

New upstream release.

Jelmer Vernooij 13 년 전
부모
커밋
f2be48a507

+ 3 - 0
Makefile

@@ -25,6 +25,9 @@ install::
 check:: build
 	$(RUNTEST) dulwich.tests.test_suite
 
+check-tutorial:: build
+	$(RUNTEST) dulwich.tests.tutorial_test_suite 
+
 check-nocompat:: build
 	$(RUNTEST) dulwich.tests.nocompat_test_suite
 

+ 17 - 0
NEWS

@@ -1,3 +1,20 @@
+0.8.3	2012-01-21
+
+ FEATURES
+
+  * The config parser now supports the git-config file format as
+    described in git-config(1) and can write git config files.
+    (Jelmer Vernooij, #531092, #768687)
+
+  * ``Repo.do_commit`` will now use the user identity from
+    .git/config or ~/.gitconfig if none was explicitly specified.
+   (Jelmer Vernooij)
+
+ BUG FIXES
+
+  * Allow ``determine_wants`` methods to include the zero sha in their
+    return value. (Jelmer Vernooij)
+
 0.8.2	2011-12-18
 
  BUG FIXES

+ 12 - 2
bin/dulwich

@@ -54,8 +54,17 @@ def cmd_fetch_pack(args):
         determine_wants = r.object_store.determine_wants_all
     else:
         determine_wants = lambda x: [y for y in args if not y in r.object_store]
-    graphwalker = r.get_graph_walker()
-    client.fetch(path, r.object_store, determine_wants)
+    client.fetch(path, r, determine_wants)
+
+
+def cmd_fetch(args):
+    opts, args = getopt(args, "", [])
+    opts = dict(opts)
+    client, path = get_transport_and_path(args.pop(0))
+    r = Repo(".")
+    if "--all" in opts:
+        determine_wants = r.object_store.determine_wants_all
+    refs = client.fetch(path, r, progress=sys.stdout.write)
 
 
 def cmd_log(args):
@@ -183,6 +192,7 @@ def cmd_update_server_info(args):
 commands = {
     "commit": cmd_commit,
     "fetch-pack": cmd_fetch_pack,
+    "fetch": cmd_fetch,
     "dump-pack": cmd_dump_pack,
     "dump-index": cmd_dump_index,
     "init": cmd_init,

+ 6 - 0
debian/changelog

@@ -1,3 +1,9 @@
+dulwich (0.8.3-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Jelmer Vernooij <jelmer@debian.org>  Sat, 21 Jan 2012 22:24:12 +0100
+
 dulwich (0.8.2-1) unstable; urgency=low
 
   * Fix Vcs URL.

+ 10 - 9
debian/copyright

@@ -1,21 +1,22 @@
-Format-Specification: http://wiki.debian.org/Proposals/CopyrightFormat?action=recall&rev=143
+Format: http://dep.debian.net/deps/dep5
 Upstream-Name: dulwich
+Upstream-Contact: Jelmer Vernooij <jelmer@samba.org>
+Source: http://samba.org/~jelmer/dulwich
 Debianized-By: Jelmer Vernooij <jelmer@debian.org>
 Debianized-Date: Tue, 13 Jan 2009 16:56:47 +0100
 
-It was downloaded from http://launchpad.net/dulwich.
-
 Files: *
-Copyright 2005 Linus Torvalds
-Copyright 2007 James Westby <jw+debian@jameswestby.net>
-Copyright 2007-2009 Jelmer Vernooij <jelmer@samba.org>
-Copyright 2008 John Carr <john.carr@unrouted.co.uk>
-License: GPL-2
+Copyright: 2005 Linus Torvalds
+Copyright: 2007 James Westby <jw+debian@jameswestby.net>
+Copyright: 2007-2012 Jelmer Vernooij <jelmer@samba.org>
+Copyright: 2008 John Carr <john.carr@unrouted.co.uk>
+Copyright: 2010 Google, Inc. <dborowitz@google.com>
+License: GPL-2+
  On Debian systems the full text of the GNU General Public License version 2 
  can be found in the `/usr/share/common-licenses/GPL-2' file.
 
 Files: debian/*
-Copyright 2009 Jelmer Vernooij <jelmer@debian.org>
+Copyright: 2009-2012 Jelmer Vernooij <jelmer@debian.org>
 License: GPL-2+
  On Debian systems the full text of the GNU General Public License version 2 
  can be found in the `/usr/share/common-licenses/GPL-2' file.

+ 1 - 0
docs/tutorial/index.txt

@@ -10,5 +10,6 @@ Tutorial
    introduction
    repo
    object-store
+   remote
    conclusion
 

+ 83 - 0
docs/tutorial/remote.txt

@@ -0,0 +1,83 @@
+.. _tutorial-remote:
+
+Most of the tests in this file require a Dulwich server, so let's start one:
+
+    >>> from dulwich.repo import Repo
+    >>> from dulwich.server import DictBackend, TCPGitServer
+    >>> import threading
+    >>> repo = Repo.init("remote", mkdir=True)
+    >>> cid = repo.do_commit("message", committer="Jelmer <jelmer@samba.org>")
+    >>> backend = DictBackend({'/': repo})
+    >>> dul_server = TCPGitServer(backend, 'localhost', 0)
+    >>> threading.Thread(target=dul_server.serve).start()
+    >>> server_address, server_port = dul_server.socket.getsockname()
+
+Remote repositories
+===================
+
+The interface for remote Git repositories is different from that
+for local repositories.
+
+The Git smart server protocol provides three basic operations:
+
+ * upload-pack - provides a pack with objects requested by the client
+ * receive-pack - imports a pack with objects provided by the client
+ * upload-archive - provides a tarball with the contents of a specific revision
+
+The smart server protocol can be accessed over either plain TCP (git://),
+SSH (git+ssh://) or tunneled over HTTP (http://).
+
+Dulwich provides support for accessing remote repositories in
+``dulwich.client``. To create a new client, you can either construct
+one manually::
+
+   >>> from dulwich.client import TCPGitClient
+   >>> client = TCPGitClient(server_address, server_port)
+
+Retrieving raw pack files
+-------------------------
+
+The client object can then be used to retrieve a pack. The ``fetch_pack``
+method takes a ``determine_wants`` callback argument, which allows the
+client to determine which objects it wants to end up with::
+
+   >>> def determine_wants(refs):
+   ...    # retrieve all objects
+   ...    return refs.values()
+
+Another required object is a "graph walker", which is used to determine
+which objects that the client already has should not be sent again
+by the server. Here in the tutorial we'll just use a dummy graph walker
+which claims that the client doesn't have any objects::
+
+   >>> class DummyGraphWalker(object):
+   ...     def ack(self, sha): pass
+   ...     def next(self): pass
+
+With the determine_wants function in place, we can now fetch a pack,
+which we will write to a ``StringIO`` object::
+
+   >>> from cStringIO import StringIO
+   >>> f = StringIO()
+   >>> remote_refs = client.fetch_pack("/", determine_wants,
+   ...    DummyGraphWalker(), pack_data=f.write)
+
+``f`` will now contain a full pack file::
+
+   >>> f.getvalue()[:4]
+   'PACK'
+
+Fetching objects into a local repository
+----------------------------------------
+
+It also possible to fetch from a remote repository into a local repository,
+in which case dulwich takes care of providing the right graph walker, and
+importing the received pack file into the local repository::
+
+   >>> from dulwich.repo import Repo
+   >>> local = Repo.init("local", mkdir=True)
+   >>> remote_refs = client.fetch("/", local)
+
+Let's show down the server now that all tests have been run::
+
+   >>> dul_server.shutdown()

+ 71 - 1
docs/tutorial/repo.txt

@@ -1,6 +1,6 @@
 .. _tutorial-repo:
 
-The Repository
+The repository
 ==============
 
 After this introduction, let's start directly with code::
@@ -18,6 +18,9 @@ repositories:
   contains itself the "branches", "hooks"... folders. These are used for
   published repositories (mirrors). They do not have a working tree.
 
+Creating a repository
+---------------------
+
 Let's create a folder and turn it into a repository, like ``git init`` would::
 
   >>> from os import mkdir
@@ -28,3 +31,70 @@ Let's create a folder and turn it into a repository, like ``git init`` would::
 
 You can already look a the structure of the "myrepo/.git" folder, though it
 is mostly empty for now.
+
+Opening an existing repository
+------------------------------
+
+To reopen an existing repository, simply pass its path to the constructor
+of ``Repo``::
+
+    >>> repo = Repo("myrepo")
+    >>> repo
+    <Repo at 'myrepo'>
+
+Opening the index
+-----------------
+
+The index is used as a staging area. Once you do a commit,
+the files tracked in the index will be recorded as the contents of the new
+commit. As mentioned earlier, only non-bare repositories have a working tree,
+so only non-bare repositories will have an index, too. To open the index, simply
+call::
+
+    >>> index = repo.open_index()
+    >>> repr(index).replace('\\\\', '/')
+    "Index('myrepo/.git/index')"
+
+Since the repository was just created, the index will be empty::
+
+    >>> list(index)
+    []
+
+Staging new files
+-----------------
+
+The repository allows "staging" files. Only files can be staged - directories
+aren't tracked explicitly by git. Let's create a simple text file and stage it::
+
+    >>> f = open('myrepo/foo', 'w')
+    >>> f.write("monty")
+    >>> f.close()
+
+    >>> repo.stage(["foo"])
+
+It will now show up in the index::
+
+    >>> list(repo.open_index())
+    ['foo']
+
+
+Creating new commits
+--------------------
+
+Now that we have staged a change, we can commit it. The easiest way to
+do this is by using ``Repo.do_commit``. It is also possible to manipulate
+the lower-level objects involved in this, but we'll leave that for a
+separate chapter of the tutorial.
+
+To create a simple commit on the current branch, it is only necessary
+to specify the message. The committer and author will be retrieved from the
+repository configuration or global configuration if they are not specified::
+
+    >>> commit_id = repo.do_commit(
+    ...     "The first commit", committer="Jelmer Vernooij <jelmer@samba.org>")
+
+``do_commit`` returns the SHA1 of the commit. Since the commit was to the 
+default branch, the repository's head will now be set to that commit::
+
+    >>> repo.head() == commit_id
+    True

+ 1 - 1
dulwich/__init__.py

@@ -23,4 +23,4 @@
 
 from dulwich import (client, protocol, repo, server)
 
-__version__ = (0, 8, 2)
+__version__ = (0, 8, 3)

+ 33 - 6
dulwich/client.py

@@ -17,7 +17,24 @@
 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
 # MA  02110-1301, USA.
 
-"""Client side support for the Git protocol."""
+"""Client side support for the Git protocol.
+
+The Dulwich client supports the following capabilities:
+
+ * thin-pack
+ * multi_ack_detailed
+ * multi_ack
+ * side-band-64k
+ * ofs-delta
+ * report-status
+ * delete-refs
+
+Known capabilities that are not supported:
+
+ * shallow
+ * no-progress
+ * include-tag
+"""
 
 __docformat__ = 'restructuredText'
 
@@ -177,7 +194,7 @@ class GitClient(object):
         :param determine_wants: Optional function to determine what refs
             to fetch
         :param progress: Optional progress function
-        :return: remote refs
+        :return: remote refs as dictionary
         """
         if determine_wants is None:
             determine_wants = target.object_store.determine_wants_all
@@ -189,7 +206,7 @@ class GitClient(object):
             commit()
 
     def fetch_pack(self, path, determine_wants, graph_walker, pack_data,
-                   progress):
+                   progress=None):
         """Retrieve a pack from a git smart server.
 
         :param determine_wants: Callback that returns list of commits to fetch
@@ -286,7 +303,7 @@ class GitClient(object):
         proto.write_pkt_line(None)
         return (have, want)
 
-    def _handle_receive_pack_tail(self, proto, capabilities, progress):
+    def _handle_receive_pack_tail(self, proto, capabilities, progress=None):
         """Handle the tail of a 'git-receive-pack' request.
 
         :param proto: Protocol object to read from
@@ -298,6 +315,8 @@ class GitClient(object):
         else:
             report_status_parser = None
         if "side-band-64k" in capabilities:
+            if progress is None:
+                progress = lambda x: None
             channel_callbacks = { 2: progress }
             if 'report-status' in capabilities:
                 channel_callbacks[1] = PktLineParser(
@@ -351,7 +370,7 @@ class GitClient(object):
         proto.write_pkt_line('done\n')
 
     def _handle_upload_pack_tail(self, proto, capabilities, graph_walker,
-                                 pack_data, progress, rbufsize=_RBUFSIZE):
+                                 pack_data, progress=None, rbufsize=_RBUFSIZE):
         """Handle the tail of a 'git-upload-pack' request.
 
         :param proto: Protocol object to read from
@@ -371,6 +390,9 @@ class GitClient(object):
                 break
             pkt = proto.read_pkt_line()
         if "side-band-64k" in capabilities:
+            if progress is None:
+                # Just ignore progress data
+                progress = lambda x: None
             self._read_side_band64k_data(proto, {1: pack_data, 2: progress})
             # wait for EOF before returning
             data = proto.read()
@@ -455,6 +477,8 @@ class TraditionalGitClient(GitClient):
         except:
             proto.write_pkt_line(None)
             raise
+        if wants is not None:
+            wants = [cid for cid in wants if cid != ZERO_SHA]
         if not wants:
             proto.write_pkt_line(None)
             return refs
@@ -707,19 +731,22 @@ class HttpGitClient(GitClient):
         return new_refs
 
     def fetch_pack(self, path, determine_wants, graph_walker, pack_data,
-                   progress):
+                   progress=None):
         """Retrieve a pack from a git smart server.
 
         :param determine_wants: Callback that returns list of commits to fetch
         :param graph_walker: Object with next() and ack().
         :param pack_data: Callback called for each bit of data in the pack
         :param progress: Callback for progress reports (strings)
+        :return: Dictionary with the refs of the remote repository
         """
         url = self._get_url(path)
         refs, server_capabilities = self._discover_references(
             "git-upload-pack", url)
         negotiated_capabilities = list(server_capabilities)
         wants = determine_wants(refs)
+        if wants is not None:
+            wants = [cid for cid in wants if cid != ZERO_SHA]
         if not wants:
             return refs
         if self.dumb:

+ 343 - 0
dulwich/config.py

@@ -0,0 +1,343 @@
+# config.py - Reading and writing Git config files
+# Copyright (C) 2011 Jelmer Vernooij <jelmer@samba.org>
+#
+# 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; version 2
+# of the License or (at your option) a 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+# MA  02110-1301, USA.
+
+"""Reading and writing Git configuration files.
+
+TODO:
+ * preserve formatting when updating configuration files
+ * treat subsection names as case-insensitive for [branch.foo] style
+   subsections
+"""
+
+import errno
+import os
+import re
+
+from dulwich.file import GitFile
+
+
+class Config(object):
+    """A Git configuration."""
+
+    def get(self, section, name):
+        """Retrieve the contents of a configuration setting.
+        
+        :param section: Tuple with section name and optional subsection namee
+        :param subsection: Subsection name
+        :return: Contents of the setting
+        :raise KeyError: if the value is not set
+        """
+        raise NotImplementedError(self.get)
+
+    def get_boolean(self, section, name, default=None):
+        """Retrieve a configuration setting as boolean.
+
+        :param section: Tuple with section name and optional subsection namee
+        :param name: Name of the setting, including section and possible
+            subsection.
+        :return: Contents of the setting
+        :raise KeyError: if the value is not set
+        """
+        try:
+            value = self.get(section, name)
+        except KeyError:
+            return default
+        if value.lower() == "true":
+            return True
+        elif value.lower() == "false":
+            return False
+        raise ValueError("not a valid boolean string: %r" % value)
+
+    def set(self, section, name, value):
+        """Set a configuration value.
+        
+        :param name: Name of the configuration value, including section
+            and optional subsection
+        :param: Value of the setting
+        """
+        raise NotImplementedError(self.set)
+
+
+class ConfigDict(Config):
+    """Git configuration stored in a dictionary."""
+
+    def __init__(self, values=None):
+        """Create a new ConfigDict."""
+        if values is None:
+            values = {}
+        self._values = values
+
+    def __repr__(self):
+        return "%s(%r)" % (self.__class__.__name__, self._values)
+
+    def __eq__(self, other):
+        return (
+            isinstance(other, self.__class__) and
+            other._values == self._values)
+
+    @classmethod
+    def _parse_setting(cls, name):
+        parts = name.split(".")
+        if len(parts) == 3:
+            return (parts[0], parts[1], parts[2])
+        else:
+            return (parts[0], None, parts[1])
+
+    def get(self, section, name):
+        if isinstance(section, basestring):
+            section = (section, )
+        if len(section) > 1:
+            try:
+                return self._values[section][name]
+            except KeyError:
+                pass
+        return self._values[(section[0],)][name]
+
+    def set(self, section, name, value):
+        if isinstance(section, basestring):
+            section = (section, )
+        self._values.setdefault(section, {})[name] = value
+
+
+def _format_string(value):
+    if (value.startswith(" ") or
+        value.startswith("\t") or
+        value.endswith(" ") or
+        value.endswith("\t")):
+        return '"%s"' % _escape_value(value)
+    return _escape_value(value)
+
+
+def _parse_string(value):
+    value = value.strip()
+    ret = []
+    block = []
+    in_quotes  = False
+    for c in value:
+        if c == "\"":
+            in_quotes = (not in_quotes)
+            ret.append(_unescape_value("".join(block)))
+            block = []
+        elif c in ("#", ";") and not in_quotes:
+            # the rest of the line is a comment
+            break
+        else:
+            block.append(c)
+
+    if in_quotes:
+        raise ValueError("value starts with quote but lacks end quote")
+
+    ret.append(_unescape_value("".join(block)).rstrip())
+
+    return "".join(ret)
+
+
+def _unescape_value(value):
+    """Unescape a value."""
+    def unescape(c):
+        return {
+            "\\\\": "\\",
+            "\\\"": "\"",
+            "\\n": "\n",
+            "\\t": "\t",
+            "\\b": "\b",
+            }[c.group(0)]
+    return re.sub(r"(\\.)", unescape, value)
+
+
+def _escape_value(value):
+    """Escape a value."""
+    return value.replace("\\", "\\\\").replace("\n", "\\n").replace("\t", "\\t").replace("\"", "\\\"")
+
+
+def _check_variable_name(name):
+    for c in name:
+        if not c.isalnum() and c != '-':
+            return False
+    return True
+
+
+def _check_section_name(name):
+    for c in name:
+        if not c.isalnum() and c not in ('-', '.'):
+            return False
+    return True
+
+
+def _strip_comments(line):
+    line = line.split("#")[0]
+    line = line.split(";")[0]
+    return line
+
+
+class ConfigFile(ConfigDict):
+    """A Git configuration file, like .git/config or ~/.gitconfig.
+    """
+
+    @classmethod
+    def from_file(cls, f):
+        """Read configuration from a file-like object."""
+        ret = cls()
+        section = None
+        setting = None
+        for lineno, line in enumerate(f.readlines()):
+            line = line.lstrip()
+            if setting is None:
+                if _strip_comments(line).strip() == "":
+                    continue
+                if line[0] == "[":
+                    line = _strip_comments(line).rstrip()
+                    if line[-1] != "]":
+                        raise ValueError("expected trailing ]")
+                    key = line.strip()
+                    pts = key[1:-1].split(" ", 1)
+                    pts[0] = pts[0].lower()
+                    if len(pts) == 2:
+                        if pts[1][0] != "\"" or pts[1][-1] != "\"":
+                            raise ValueError(
+                                "Invalid subsection " + pts[1])
+                        else:
+                            pts[1] = pts[1][1:-1]
+                        if not _check_section_name(pts[0]):
+                            raise ValueError("invalid section name %s" %
+                                             pts[0])
+                        section = (pts[0], pts[1])
+                    else:
+                        if not _check_section_name(pts[0]):
+                            raise ValueError("invalid section name %s" %
+                                    pts[0])
+                        pts = pts[0].split(".", 1)
+                        if len(pts) == 2:
+                            section = (pts[0], pts[1])
+                        else:
+                            section = (pts[0], )
+                    ret._values[section] = {}
+                else:
+                    if section is None:
+                        raise ValueError("setting %r without section" % line)
+                    try:
+                        setting, value = line.split("=", 1)
+                    except ValueError:
+                        setting = line
+                        value = "true"
+                    setting = setting.strip().lower()
+                    if not _check_variable_name(setting):
+                        raise ValueError("invalid variable name %s" % setting)
+                    if value.endswith("\\\n"):
+                        value = value[:-2]
+                        continuation = True
+                    else:
+                        continuation = False
+                    value = _parse_string(value)
+                    ret._values[section][setting] = value
+                    if not continuation:
+                        setting = None
+            else: # continuation line
+                if line.endswith("\\\n"):
+                    line = line[:-2]
+                    continuation = True
+                else:
+                    continuation = False
+                value = _parse_string(line)
+                ret._values[section][setting] += value
+                if not continuation:
+                    setting = None
+        return ret
+
+    @classmethod
+    def from_path(cls, path):
+        """Read configuration from a file on disk."""
+        f = GitFile(path, 'rb')
+        try:
+            ret = cls.from_file(f)
+            ret.path = path
+            return ret
+        finally:
+            f.close()
+
+    def write_to_path(self, path=None):
+        """Write configuration to a file on disk."""
+        if path is None:
+            path = self.path
+        f = GitFile(path, 'wb')
+        try:
+            self.write_to_file(f)
+        finally:
+            f.close()
+
+    def write_to_file(self, f):
+        """Write configuration to a file-like object."""
+        for section, values in self._values.iteritems():
+            try:
+                section_name, subsection_name = section
+            except ValueError:
+                (section_name, ) = section
+                subsection_name = None
+            if subsection_name is None:
+                f.write("[%s]\n" % section_name)
+            else:
+                f.write("[%s \"%s\"]\n" % (section_name, subsection_name))
+            for key, value in values.iteritems():
+                f.write("%s = %s\n" % (key, _escape_value(value)))
+
+
+class StackedConfig(Config):
+    """Configuration which reads from multiple config files.."""
+
+    def __init__(self, backends, writable=None):
+        self.backends = backends
+        self.writable = writable
+
+    def __repr__(self):
+        return "<%s for %r>" % (self.__class__.__name__, self.backends)
+
+    @classmethod
+    def default_backends(cls):
+        """Retrieve the default configuration.
+
+        This will look in the repository configuration (if for_path is
+        specified), the users' home directory and the system
+        configuration.
+        """
+        paths = []
+        paths.append(os.path.expanduser("~/.gitconfig"))
+        paths.append("/etc/gitconfig")
+        backends = []
+        for path in paths:
+            try:
+                cf = ConfigFile.from_path(path)
+            except (IOError, OSError), e:
+                if e.errno != errno.ENOENT:
+                    raise
+                else:
+                    continue
+            backends.append(cf)
+        return backends
+
+    def get(self, section, name):
+        for backend in self.backends:
+            try:
+                return backend.get(section, name)
+            except KeyError:
+                pass
+        raise KeyError(name)
+
+    def set(self, section, name, value):
+        if self.writable is None:
+            raise NotImplementedError(self.set)
+        return self.writable.set(section, name, value)

+ 7 - 2
dulwich/index.py

@@ -193,6 +193,9 @@ class Index(object):
         self.clear()
         self.read()
 
+    def __repr__(self):
+        return "%s(%r)" % (self.__class__.__name__, self._filename)
+
     def write(self):
         """Write current contents of index to disk."""
         f = GitFile(self._filename, 'wb')
@@ -372,13 +375,15 @@ def changes_from_tree(names, lookup_entry, object_store, tree,
         yield ((None, name), (None, other_mode), (None, other_sha))
 
 
-def index_entry_from_stat(stat_val, hex_sha, flags):
+def index_entry_from_stat(stat_val, hex_sha, flags, mode=None):
     """Create a new index entry from a stat value.
 
     :param stat_val: POSIX stat_result instance
     :param hex_sha: Hex sha of the object
     :param flags: Index flags
     """
+    if mode is None:
+        mode = cleanup_mode(stat_val.st_mode)
     return (stat_val.st_ctime, stat_val.st_mtime, stat_val.st_dev,
-            stat_val.st_ino, stat_val.st_mode, stat_val.st_uid,
+            stat_val.st_ino, mode, stat_val.st_uid,
             stat_val.st_gid, stat_val.st_size, hex_sha, flags)

+ 8 - 2
dulwich/object_store.py

@@ -132,8 +132,8 @@ class BaseObjectStore(object):
     def tree_changes(self, source, target, want_unchanged=False):
         """Find the differences between the contents of two trees
 
-        :param object_store: Object store to use for retrieving tree contents
-        :param tree: SHA1 of the root tree
+        :param source: SHA1 of the source tree
+        :param target: SHA1 of the target tree
         :param want_unchanged: Whether unchanged files should be reported
         :return: Iterator over tuples with
             (oldpath, newpath), (oldmode, newmode), (oldsha, newsha)
@@ -471,9 +471,15 @@ class DiskObjectStore(PackBasedObjectStore):
         f.seek(0)
         write_pack_header(f, len(entries) + len(indexer.ext_refs()))
 
+        # Must flush before reading (http://bugs.python.org/issue3207)
+        f.flush()
+
         # Rescan the rest of the pack, computing the SHA with the new header.
         new_sha = compute_file_sha(f, end_ofs=-20)
 
+        # Must reposition before writing (http://bugs.python.org/issue3207)
+        f.seek(0, os.SEEK_CUR)
+
         # Complete the pack.
         for ext_sha in indexer.ext_refs():
             assert len(ext_sha) == 20

+ 39 - 1
dulwich/objects.py

@@ -19,7 +19,6 @@
 
 """Access to base git objects."""
 
-
 import binascii
 from cStringIO import (
     StringIO,
@@ -64,6 +63,11 @@ _TAGGER_HEADER = "tagger"
 S_IFGITLINK = 0160000
 
 def S_ISGITLINK(m):
+    """Check if a mode indicates a submodule.
+
+    :param m: Mode to check
+    :return: a `boolean`
+    """
     return (stat.S_IFMT(m) == S_IFGITLINK)
 
 
@@ -114,6 +118,8 @@ def object_header(num_type, length):
 
 
 def serializable_property(name, docstring=None):
+    """A property that helps tracking whether serialization is necessary.
+    """
     def set(obj, value):
         obj._ensure_parsed()
         setattr(obj, "_"+name, value)
@@ -135,6 +141,12 @@ def object_class(type):
 
 
 def check_hexsha(hex, error_msg):
+    """Check if a string is a valid hex sha string.
+
+    :param hex: Hex string to check
+    :param error_msg: Error message to use in exception
+    :raise ObjectFormatException: Raised when the string is not valid
+    """
     try:
         hex_to_sha(hex)
     except (TypeError, AssertionError):
@@ -168,9 +180,11 @@ class FixedSha(object):
         self._sha = hex_to_sha(hexsha)
 
     def digest(self):
+        """Return the raw SHA digest."""
         return self._sha
 
     def hexdigest(self):
+        """Return the hex SHA digest."""
         return self._hexsha
 
 
@@ -213,6 +227,10 @@ class ShaFile(object):
         self.set_raw_string(text[header_end+1:])
 
     def as_legacy_object_chunks(self):
+        """Return chunks representing the object in the experimental format.
+
+        :return: List of strings
+        """
         compobj = zlib.compressobj()
         yield compobj.compress(self._header())
         for chunk in self.as_raw_chunks():
@@ -220,9 +238,15 @@ class ShaFile(object):
         yield compobj.flush()
 
     def as_legacy_object(self):
+        """Return string representing the object in the experimental format.
+        """
         return "".join(self.as_legacy_object_chunks())
 
     def as_raw_chunks(self):
+        """Return chunks with serialization of the object.
+
+        :return: List of strings, not necessarily one per line
+        """
         if self._needs_parsing:
             self._ensure_parsed()
         elif self._needs_serialization:
@@ -230,15 +254,22 @@ class ShaFile(object):
         return self._chunked_text
 
     def as_raw_string(self):
+        """Return raw string with serialization of the object.
+
+        :return: String object
+        """
         return "".join(self.as_raw_chunks())
 
     def __str__(self):
+        """Return raw string serialization of this object."""
         return self.as_raw_string()
 
     def __hash__(self):
+        """Return unique hash for this object."""
         return hash(self.id)
 
     def as_pretty_string(self):
+        """Return a string representing this object, fit for display."""
         return self.as_raw_string()
 
     def _ensure_parsed(self):
@@ -256,11 +287,13 @@ class ShaFile(object):
             self._needs_parsing = False
 
     def set_raw_string(self, text):
+        """Set the contents of this object from a serialized string."""
         if type(text) != str:
             raise TypeError(text)
         self.set_raw_chunks([text])
 
     def set_raw_chunks(self, chunks):
+        """Set the contents of this object from a list of chunks."""
         self._chunked_text = chunks
         self._deserialize(chunks)
         self._sha = None
@@ -339,6 +372,7 @@ class ShaFile(object):
 
     @classmethod
     def from_path(cls, path):
+        """Open a SHA file from disk."""
         f = GitFile(path, 'rb')
         try:
             obj = cls.from_file(f)
@@ -454,12 +488,15 @@ class ShaFile(object):
 
     @property
     def id(self):
+        """The hex SHA of this object."""
         return self.sha().hexdigest()
 
     def get_type(self):
+        """Return the type number for this object class."""
         return self.type_num
 
     def set_type(self, type):
+        """Set the type number for this object class."""
         self.type_num = type
 
     # DEPRECATED: use type_num or type_name as needed.
@@ -557,6 +594,7 @@ def _parse_tag_or_commit(text):
 
 
 def parse_tag(text):
+    """Parse a tag object."""
     return _parse_tag_or_commit(text)
 
 

+ 134 - 27
dulwich/repo.py

@@ -19,7 +19,13 @@
 # MA  02110-1301, USA.
 
 
-"""Repository access."""
+"""Repository access.
+
+This module contains the base class for git repositories
+(BaseRepo) and an implementation which uses a repository on 
+local disk (Repo).
+
+"""
 
 from cStringIO import StringIO
 import errno
@@ -791,21 +797,34 @@ class BaseRepo(object):
 
     :ivar object_store: Dictionary-like object for accessing
         the objects
-    :ivar refs: Dictionary-like object with the refs in this repository
+    :ivar refs: Dictionary-like object with the refs in this
+        repository
     """
 
     def __init__(self, object_store, refs):
+        """Open a repository.
+
+        This shouldn't be called directly, but rather through one of the
+        base classes, such as MemoryRepo or Repo.
+
+        :param object_store: Object store to use
+        :param refs: Refs container to use
+        """
         self.object_store = object_store
         self.refs = refs
 
     def _init_files(self, bare):
         """Initialize a default set of named files."""
+        from dulwich.config import ConfigFile
         self._put_named_file('description', "Unnamed repository")
-        self._put_named_file('config', ('[core]\n'
-                                        'repositoryformatversion = 0\n'
-                                        'filemode = true\n'
-                                        'bare = ' + str(bare).lower() + '\n'
-                                        'logallrefupdates = true\n'))
+        f = StringIO()
+        cf = ConfigFile()
+        cf.set("core", "repositoryformatversion", "0")
+        cf.set("core", "filemode", "true")
+        cf.set("core", "bare", str(bare).lower())
+        cf.set("core", "logallrefupdates", "true")
+        cf.write_to_file(f)
+        self._put_named_file('config', f.getvalue())
         self._put_named_file(os.path.join('info', 'exclude'), '')
 
     def get_named_file(self, path):
@@ -877,6 +896,14 @@ class BaseRepo(object):
                                                  get_tagged))
 
     def get_graph_walker(self, heads=None):
+        """Retrieve a graph walker.
+
+        A graph walker is used by a remote repository (or proxy)
+        to find out which objects are present in this repository.
+
+        :param heads: Repository heads to use (optional)
+        :return: A graph walker object
+        """
         if heads is None:
             heads = self.refs.as_dict('refs/heads').values()
         return self.object_store.get_graph_walker(heads)
@@ -911,17 +938,50 @@ class BaseRepo(object):
         return ret
 
     def get_object(self, sha):
+        """Retrieve the object with the specified SHA.
+
+        :param sha: SHA to retrieve
+        :return: A ShaFile object
+        :raise KeyError: when the object can not be found
+        """
         return self.object_store[sha]
 
     def get_parents(self, sha):
+        """Retrieve the parents of a specific commit.
+
+        :param sha: SHA of the commit for which to retrieve the parents
+        :return: List of parents
+        """
         return self.commit(sha).parents
 
     def get_config(self):
-        import ConfigParser
-        p = ConfigParser.RawConfigParser()
-        p.read(os.path.join(self._controldir, 'config'))
-        return dict((section, dict(p.items(section)))
-                    for section in p.sections())
+        """Retrieve the config object.
+
+        :return: `ConfigFile` object for the ``.git/config`` file.
+        """
+        from dulwich.config import ConfigFile
+        path = os.path.join(self._controldir, 'config')
+        try:
+            return ConfigFile.from_path(path)
+        except (IOError, OSError), e:
+            if e.errno != errno.ENOENT:
+                raise
+            ret = ConfigFile()
+            ret.path = path
+            return ret
+
+    def get_config_stack(self):
+        """Return a config stack for this repository.
+
+        This stack accesses the configuration for both this repository
+        itself (.git/config) and the global configuration, which usually
+        lives in ~/.gitconfig.
+
+        :return: `Config` instance for this repository
+        """
+        from dulwich.config import StackedConfig
+        backends = [self.get_config()] + StackedConfig.default_backends()
+        return StackedConfig(backends, writable=backends[0])
 
     def commit(self, sha):
         """Retrieve the commit with a particular SHA.
@@ -1028,6 +1088,12 @@ class BaseRepo(object):
         return [e.commit for e in self.get_walker(include=[head])]
 
     def __getitem__(self, name):
+        """Retrieve a Git object by SHA1 or ref.
+
+        :param name: A Git object SHA1 or a ref name
+        :return: A `ShaFile` object, such as a Commit or Blob
+        :raise KeyError: when the specified ref or object does not exist
+        """
         if len(name) in (20, 40):
             try:
                 return self.object_store[name]
@@ -1038,16 +1104,22 @@ class BaseRepo(object):
         except RefFormatError:
             raise KeyError(name)
 
-    def __iter__(self):
-        raise NotImplementedError(self.__iter__)
-
     def __contains__(self, name):
+        """Check if a specific Git object or ref is present.
+
+        :param name: Git object SHA1 or ref name
+        """
         if len(name) in (20, 40):
             return name in self.object_store or name in self.refs
         else:
             return name in self.refs
 
     def __setitem__(self, name, value):
+        """Set a ref.
+
+        :param name: ref name
+        :param value: Ref value - either a ShaFile object, or a hex sha
+        """
         if name.startswith("refs/") or name == "HEAD":
             if isinstance(value, ShaFile):
                 self.refs[name] = value.id
@@ -1059,11 +1131,21 @@ class BaseRepo(object):
             raise ValueError(name)
 
     def __delitem__(self, name):
-        if name.startswith("refs") or name == "HEAD":
+        """Remove a ref.
+
+        :param name: Name of the ref to remove
+        """
+        if name.startswith("refs/") or name == "HEAD":
             del self.refs[name]
         else:
             raise ValueError(name)
 
+    def _get_user_identity(self):
+        config = self.get_config_stack()
+        return "%s <%s>" % (
+            config.get(("user", ), "name"),
+            config.get(("user", ), "email"))
+
     def do_commit(self, message=None, committer=None,
                   author=None, commit_timestamp=None,
                   commit_timezone=None, author_timestamp=None,
@@ -1098,9 +1180,8 @@ class BaseRepo(object):
         if merge_heads is None:
             # FIXME: Read merge heads from .git/MERGE_HEADS
             merge_heads = []
-        # TODO: Allow username to be missing, and get it from .git/config
         if committer is None:
-            raise ValueError("committer not set")
+            committer = self._get_user_identity()
         c.committer = committer
         if commit_timestamp is None:
             commit_timestamp = time.time()
@@ -1142,7 +1223,13 @@ class BaseRepo(object):
 
 
 class Repo(BaseRepo):
-    """A git repository backed by local disk."""
+    """A git repository backed by local disk.
+
+    To open an existing repository, call the contructor with
+    the path of the repository.
+
+    To create a new repository, use the Repo.init class method.
+    """
 
     def __init__(self, root):
         if os.path.isdir(os.path.join(root, ".git", OBJECTDIR)):
@@ -1219,11 +1306,12 @@ class Repo(BaseRepo):
 
         :param paths: List of paths, relative to the repository path
         """
-        from dulwich.index import cleanup_mode
+        if isinstance(paths, basestring):
+            paths = [paths]
+        from dulwich.index import index_entry_from_stat
         index = self.open_index()
         for path in paths:
             full_path = os.path.join(self.path, path)
-            blob = Blob()
             try:
                 st = os.stat(full_path)
             except OSError:
@@ -1231,21 +1319,20 @@ class Repo(BaseRepo):
                 try:
                     del index[path]
                 except KeyError:
-                    pass  # Doesn't exist in the index either
+                    pass # already removed
             else:
+                blob = Blob()
                 f = open(full_path, 'rb')
                 try:
                     blob.data = f.read()
                 finally:
                     f.close()
                 self.object_store.add_object(blob)
-                # XXX: Cleanup some of the other file properties as well?
-                index[path] = (st.st_ctime, st.st_mtime, st.st_dev, st.st_ino,
-                    cleanup_mode(st.st_mode), st.st_uid, st.st_gid, st.st_size,
-                    blob.id, 0)
+                index[path] = index_entry_from_stat(st, blob.id, 0)
         index.write()
 
-    def clone(self, target_path, mkdir=True, bare=False, origin="origin"):
+    def clone(self, target_path, mkdir=True, bare=False,
+            origin="origin"):
         """Clone this repository.
 
         :param target_path: Target path
@@ -1285,6 +1372,12 @@ class Repo(BaseRepo):
 
     @classmethod
     def init(cls, path, mkdir=False):
+        """Create a new repository.
+
+        :param path: Path in which to create the repository
+        :param mkdir: Whether to create the directory
+        :return: `Repo` instance
+        """
         if mkdir:
             os.mkdir(path)
         controldir = os.path.join(path, ".git")
@@ -1294,6 +1387,13 @@ class Repo(BaseRepo):
 
     @classmethod
     def init_bare(cls, path):
+        """Create a new bare repository.
+
+        ``path`` should already exist and be an emty directory.
+
+        :param path: Path to create bare repository in
+        :return: a `Repo` instance
+        """
         return cls._init_maybe_bare(path, True)
 
     create = init_bare
@@ -1340,6 +1440,13 @@ class MemoryRepo(BaseRepo):
 
     @classmethod
     def init_bare(cls, objects, refs):
+        """Create a new bare repository in memory.
+
+        :param objects: Objects for the new repository,
+            as iterable
+        :param refs: Refs as dictionary, mapping names
+            to object SHA1s
+        """
         ret = cls()
         for obj in objects:
             ret.object_store.add_object(obj)

+ 15 - 1
dulwich/server.py

@@ -23,8 +23,22 @@ Documentation/technical directory in the cgit distribution, and in particular:
 
 * Documentation/technical/protocol-capabilities.txt
 * Documentation/technical/pack-protocol.txt
-"""
 
+Currently supported capabilities:
+
+ * include-tag
+ * thin-pack
+ * multi_ack_detailed
+ * multi_ack
+ * side-band-64k
+ * ofs-delta
+ * no-progress
+ * report-status
+ * delete-refs
+
+Known capabilities that are not supported:
+ * shallow (http://pad.lv/909524)
+"""
 
 import collections
 import os

+ 4 - 1
dulwich/tests/__init__.py

@@ -68,6 +68,7 @@ class BlackboxTestCase(TestCase):
         return subprocess.Popen(argv,
             stdout=subprocess.PIPE,
             stdin=subprocess.PIPE, stderr=subprocess.PIPE,
+            universal_newlines=True,
             env=env)
 
 
@@ -75,6 +76,7 @@ def self_test_suite():
     names = [
         'blackbox',
         'client',
+        'config',
         'diff_tree',
         'fastexport',
         'file',
@@ -100,6 +102,7 @@ def tutorial_test_suite():
         'introduction',
         'repo',
         'object-store',
+        'remote',
         'conclusion',
         ]
     tutorial_files = ["../../docs/tutorial/%s.txt" % name for name in tutorial]
@@ -108,8 +111,8 @@ def tutorial_test_suite():
         test.__dulwich_tempdir = tempfile.mkdtemp()
         os.chdir(test.__dulwich_tempdir)
     def teardown(test):
-        shutil.rmtree(test.__dulwich_tempdir)
         os.chdir(test.__old_cwd)
+        shutil.rmtree(test.__dulwich_tempdir)
     return doctest.DocFileSuite(setUp=setup, tearDown=teardown,
         *tutorial_files)
 

+ 9 - 0
dulwich/tests/compat/test_client.py

@@ -191,6 +191,15 @@ class DulwichClientTestBase(object):
         map(lambda r: dest.refs.set_if_equals(r[0], None, r[1]), refs.items())
         self.assertDestEqualsSrc()
 
+    def test_fetch_pack_zero_sha(self):
+        # zero sha1s are already present on the client, and should
+        # be ignored
+        c = self._client()
+        dest = repo.Repo(os.path.join(self.gitroot, 'dest'))
+        refs = c.fetch(self._build_path('/server_new.export'), dest,
+            lambda refs: [protocol.ZERO_SHA])
+        map(lambda r: dest.refs.set_if_equals(r[0], None, r[1]), refs.items())
+
     def test_send_remove_branch(self):
         dest = repo.Repo(os.path.join(self.gitroot, 'dest'))
         dummy_commit = self.make_dummy_commit(dest)

+ 248 - 0
dulwich/tests/test_config.py

@@ -0,0 +1,248 @@
+# test_config.py -- Tests for reading and writing configuration files
+# Copyright (C) 2011 Jelmer Vernooij <jelmer@samba.org>
+#
+# 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 2
+# or (at your option) a later version of the License.
+#
+# 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, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+# MA  02110-1301, USA.
+
+"""Tests for reading and writing configuraiton files."""
+
+from cStringIO import StringIO
+from dulwich.config import (
+    ConfigDict,
+    ConfigFile,
+    StackedConfig,
+    _check_section_name,
+    _check_variable_name,
+    _format_string,
+    _escape_value,
+    _parse_string,
+    _unescape_value,
+    )
+from dulwich.tests import TestCase
+
+
+class ConfigFileTests(TestCase):
+
+    def from_file(self, text):
+        return ConfigFile.from_file(StringIO(text))
+
+    def test_empty(self):
+        ConfigFile()
+
+    def test_eq(self):
+        self.assertEquals(ConfigFile(), ConfigFile())
+
+    def test_default_config(self):
+        cf = self.from_file("""[core]
+	repositoryformatversion = 0
+	filemode = true
+	bare = false
+	logallrefupdates = true
+""")
+        self.assertEquals(ConfigFile({("core", ): {
+            "repositoryformatversion": "0",
+            "filemode": "true",
+            "bare": "false",
+            "logallrefupdates": "true"}}), cf)
+
+    def test_from_file_empty(self):
+        cf = self.from_file("")
+        self.assertEquals(ConfigFile(), cf)
+
+    def test_empty_line_before_section(self):
+        cf = self.from_file("\n[section]\n")
+        self.assertEquals(ConfigFile({("section", ): {}}), cf)
+
+    def test_comment_before_section(self):
+        cf = self.from_file("# foo\n[section]\n")
+        self.assertEquals(ConfigFile({("section", ): {}}), cf)
+
+    def test_comment_after_section(self):
+        cf = self.from_file("[section] # foo\n")
+        self.assertEquals(ConfigFile({("section", ): {}}), cf)
+
+    def test_comment_after_variable(self):
+        cf = self.from_file("[section]\nbar= foo # a comment\n")
+        self.assertEquals(ConfigFile({("section", ): {"bar": "foo"}}), cf)
+
+    def test_from_file_section(self):
+        cf = self.from_file("[core]\nfoo = bar\n")
+        self.assertEquals("bar", cf.get(("core", ), "foo"))
+        self.assertEquals("bar", cf.get(("core", "foo"), "foo"))
+
+    def test_from_file_section_case_insensitive(self):
+        cf = self.from_file("[cOre]\nfOo = bar\n")
+        self.assertEquals("bar", cf.get(("core", ), "foo"))
+        self.assertEquals("bar", cf.get(("core", "foo"), "foo"))
+
+    def test_from_file_with_mixed_quoted(self):
+        cf = self.from_file("[core]\nfoo = \"bar\"la\n")
+        self.assertEquals("barla", cf.get(("core", ), "foo"))
+
+    def test_from_file_with_open_quoted(self):
+        self.assertRaises(ValueError,
+            self.from_file, "[core]\nfoo = \"bar\n")
+
+    def test_from_file_with_quotes(self):
+        cf = self.from_file(
+            "[core]\n"
+            'foo = " bar"\n')
+        self.assertEquals(" bar", cf.get(("core", ), "foo"))
+
+    def test_from_file_with_interrupted_line(self):
+        cf = self.from_file(
+            "[core]\n"
+            'foo = bar\\\n'
+            ' la\n')
+        self.assertEquals("barla", cf.get(("core", ), "foo"))
+
+    def test_from_file_with_boolean_setting(self):
+        cf = self.from_file(
+            "[core]\n"
+            'foo\n')
+        self.assertEquals("true", cf.get(("core", ), "foo"))
+
+    def test_from_file_subsection(self):
+        cf = self.from_file("[branch \"foo\"]\nfoo = bar\n")
+        self.assertEquals("bar", cf.get(("branch", "foo"), "foo"))
+
+    def test_from_file_subsection_invalid(self):
+        self.assertRaises(ValueError,
+            self.from_file, "[branch \"foo]\nfoo = bar\n")
+
+    def test_from_file_subsection_not_quoted(self):
+        cf = self.from_file("[branch.foo]\nfoo = bar\n")
+        self.assertEquals("bar", cf.get(("branch", "foo"), "foo"))
+
+    def test_write_to_file_empty(self):
+        c = ConfigFile()
+        f = StringIO()
+        c.write_to_file(f)
+        self.assertEquals("", f.getvalue())
+
+    def test_write_to_file_section(self):
+        c = ConfigFile()
+        c.set(("core", ), "foo", "bar")
+        f = StringIO()
+        c.write_to_file(f)
+        self.assertEquals("[core]\nfoo = bar\n", f.getvalue())
+
+    def test_write_to_file_subsection(self):
+        c = ConfigFile()
+        c.set(("branch", "blie"), "foo", "bar")
+        f = StringIO()
+        c.write_to_file(f)
+        self.assertEquals("[branch \"blie\"]\nfoo = bar\n", f.getvalue())
+
+
+class ConfigDictTests(TestCase):
+
+    def test_get_set(self):
+        cd = ConfigDict()
+        self.assertRaises(KeyError, cd.get, "foo", "core")
+        cd.set(("core", ), "foo", "bla")
+        self.assertEquals("bla", cd.get(("core", ), "foo"))
+        cd.set(("core", ), "foo", "bloe")
+        self.assertEquals("bloe", cd.get(("core", ), "foo"))
+
+    def test_get_boolean(self):
+        cd = ConfigDict()
+        cd.set(("core", ), "foo", "true")
+        self.assertTrue(cd.get_boolean(("core", ), "foo"))
+        cd.set(("core", ), "foo", "false")
+        self.assertFalse(cd.get_boolean(("core", ), "foo"))
+        cd.set(("core", ), "foo", "invalid")
+        self.assertRaises(ValueError, cd.get_boolean, ("core", ), "foo")
+
+
+class StackedConfigTests(TestCase):
+
+    def test_default_backends(self):
+        StackedConfig.default_backends()
+
+
+class UnescapeTests(TestCase):
+
+    def test_nothing(self):
+        self.assertEquals("", _unescape_value(""))
+
+    def test_tab(self):
+        self.assertEquals("\tbar\t", _unescape_value("\\tbar\\t"))
+
+    def test_newline(self):
+        self.assertEquals("\nbar\t", _unescape_value("\\nbar\\t"))
+
+    def test_quote(self):
+        self.assertEquals("\"foo\"", _unescape_value("\\\"foo\\\""))
+
+
+class EscapeValueTests(TestCase):
+
+    def test_nothing(self):
+        self.assertEquals("foo", _escape_value("foo"))
+
+    def test_backslash(self):
+        self.assertEquals("foo\\\\", _escape_value("foo\\"))
+
+    def test_newline(self):
+        self.assertEquals("foo\\n", _escape_value("foo\n"))
+
+
+class FormatStringTests(TestCase):
+
+    def test_quoted(self):
+        self.assertEquals('" foo"', _format_string(" foo"))
+        self.assertEquals('"\\tfoo"', _format_string("\tfoo"))
+
+    def test_not_quoted(self):
+        self.assertEquals('foo', _format_string("foo"))
+        self.assertEquals('foo bar', _format_string("foo bar"))
+
+
+class ParseStringTests(TestCase):
+
+    def test_quoted(self):
+        self.assertEquals(' foo', _parse_string('" foo"'))
+        self.assertEquals('\tfoo', _parse_string('"\\tfoo"'))
+
+    def test_not_quoted(self):
+        self.assertEquals('foo', _parse_string("foo"))
+        self.assertEquals('foo bar', _parse_string("foo bar"))
+
+
+class CheckVariableNameTests(TestCase):
+
+    def test_invalid(self):
+        self.assertFalse(_check_variable_name("foo "))
+        self.assertFalse(_check_variable_name("bar,bar"))
+        self.assertFalse(_check_variable_name("bar.bar"))
+
+    def test_valid(self):
+        self.assertTrue(_check_variable_name("FOO"))
+        self.assertTrue(_check_variable_name("foo"))
+        self.assertTrue(_check_variable_name("foo-bar"))
+
+
+class CheckSectionNameTests(TestCase):
+
+    def test_invalid(self):
+        self.assertFalse(_check_section_name("foo "))
+        self.assertFalse(_check_section_name("bar,bar"))
+
+    def test_valid(self):
+        self.assertTrue(_check_section_name("FOO"))
+        self.assertTrue(_check_section_name("foo"))
+        self.assertTrue(_check_section_name("foo-bar"))
+        self.assertTrue(_check_section_name("bar.bar"))

+ 20 - 3
dulwich/tests/test_index.py

@@ -23,7 +23,6 @@ from cStringIO import (
     StringIO,
     )
 import os
-import posix
 import shutil
 import stat
 import struct
@@ -176,7 +175,7 @@ class WriteCacheTimeTests(TestCase):
 class IndexEntryFromStatTests(TestCase):
 
     def test_simple(self):
-        st = posix.stat_result((16877, 131078, 64769L,
+        st = os.stat_result((16877, 131078, 64769L,
                 154, 1000, 1000, 12288,
                 1323629595, 1324180496, 1324180496))
         entry = index_entry_from_stat(st, "22" * 20, 0)
@@ -185,7 +184,25 @@ class IndexEntryFromStatTests(TestCase):
             1324180496,
             64769L,
             131078,
-            16877,
+            16384,
+            1000,
+            1000,
+            12288,
+            '2222222222222222222222222222222222222222',
+            0))
+
+    def test_override_mode(self):
+        st = os.stat_result((stat.S_IFREG + 0644, 131078, 64769L,
+                154, 1000, 1000, 12288,
+                1323629595, 1324180496, 1324180496))
+        entry = index_entry_from_stat(st, "22" * 20, 0,
+                mode=stat.S_IFREG + 0755)
+        self.assertEquals(entry, (
+            1324180496,
+            1324180496,
+            64769L,
+            131078,
+            33261,
             1000,
             1000,
             12288,

+ 13 - 8
dulwich/tests/test_object_store.py

@@ -270,15 +270,20 @@ class DiskObjectStoreTests(PackBasedObjectStoreTests, TestCase):
           (REF_DELTA, (blob.id, 'more yummy data')),
           ], store=o)
         pack = o.add_thin_pack(f.read, None)
+        try:
+            packed_blob_sha = sha_to_hex(entries[0][3])
+            pack.check_length_and_checksum()
+            self.assertEqual(sorted([blob.id, packed_blob_sha]), list(pack))
+            self.assertTrue(o.contains_packed(packed_blob_sha))
+            self.assertTrue(o.contains_packed(blob.id))
+            self.assertEqual((Blob.type_num, 'more yummy data'),
+                             o.get_raw(packed_blob_sha))
+        finally:
+            # FIXME: DiskObjectStore should have close() which do the following:
+            for p in o._pack_cache or []:
+                p.close()
 
-        packed_blob_sha = sha_to_hex(entries[0][3])
-        pack.check_length_and_checksum()
-        self.assertEqual(sorted([blob.id, packed_blob_sha]), list(pack))
-        self.assertTrue(o.contains_packed(packed_blob_sha))
-        self.assertTrue(o.contains_packed(blob.id))
-        self.assertEqual((Blob.type_num, 'more yummy data'),
-                         o.get_raw(packed_blob_sha))
-
+            pack.close()
 
 class TreeLookupPathTests(TestCase):
 

+ 23 - 6
dulwich/tests/test_repository.py

@@ -33,6 +33,7 @@ from dulwich.object_store import (
     tree_lookup_path,
     )
 from dulwich import objects
+from dulwich.config import Config
 from dulwich.repo import (
     check_ref_format,
     DictRefsContainer,
@@ -73,7 +74,8 @@ class CreateRepositoryTests(TestCase):
         self.assertFileContentsEqual('', repo, os.path.join('info', 'exclude'))
         self.assertFileContentsEqual(None, repo, 'nonexistent file')
         barestr = 'bare = %s' % str(expect_bare).lower()
-        self.assertTrue(barestr in repo.get_named_file('config').read())
+        config_text = repo.get_named_file('config').read()
+        self.assertTrue(barestr in config_text, "%r" % config_text)
 
     def test_create_disk_bare(self):
         tmp_dir = tempfile.mkdtemp()
@@ -114,10 +116,6 @@ class RepositoryTests(TestCase):
         self.assertEqual(r.ref('refs/heads/master'),
                          'a90fa2d900a17e99b433217e988c4eb4a2e9a097')
 
-    def test_iter(self):
-        r = self._repo = open_repo('a.git')
-        self.assertRaises(NotImplementedError, r.__iter__)
-
     def test_setitem(self):
         r = self._repo = open_repo('a.git')
         r["refs/tags/foo"] = 'a90fa2d900a17e99b433217e988c4eb4a2e9a097'
@@ -319,7 +317,11 @@ class RepositoryTests(TestCase):
 
     def test_get_config(self):
         r = self._repo = open_repo('ooo_merge.git')
-        self.assertEquals({}, r.get_config())
+        self.assertIsInstance(r.get_config(), Config)
+
+    def test_get_config_stack(self):
+        r = self._repo = open_repo('ooo_merge.git')
+        self.assertIsInstance(r.get_config_stack(), Config)
 
     def test_common_revisions(self):
         """
@@ -455,6 +457,21 @@ class BuildRepoTests(TestCase):
              encoding="iso8859-1")
         self.assertEquals("iso8859-1", r[commit_sha].encoding)
 
+    def test_commit_config_identity(self):
+        # commit falls back to the users' identity if it wasn't specified
+        r = self._repo
+        c = r.get_config()
+        c.set(("user", ), "name", "Jelmer")
+        c.set(("user", ), "email", "jelmer@apache.org")
+        c.write_to_path()
+        commit_sha = r.do_commit('message')
+        self.assertEquals(
+            "Jelmer <jelmer@apache.org>",
+            r[commit_sha].author)
+        self.assertEquals(
+            "Jelmer <jelmer@apache.org>",
+            r[commit_sha].committer)
+
     def test_commit_fail_ref(self):
         r = self._repo
 

+ 3 - 2
dulwich/tests/test_web.py

@@ -20,6 +20,7 @@
 
 from cStringIO import StringIO
 import re
+import os
 
 from dulwich.object_store import (
     MemoryObjectStore,
@@ -198,7 +199,7 @@ class DumbHandlersTestCase(WebTestCase):
         self.assertEquals(HTTP_ERROR, self._status)
 
     def test_get_pack_file(self):
-        pack_name = 'objects/pack/pack-%s.pack' % ('1' * 40)
+        pack_name = os.path.join('objects', 'pack', 'pack-%s.pack' % ('1' * 40))
         backend = _test_backend([], named_files={pack_name: 'pack contents'})
         mat = re.search('.*', pack_name)
         output = ''.join(get_pack_file(self._req, backend, mat))
@@ -208,7 +209,7 @@ class DumbHandlersTestCase(WebTestCase):
         self.assertTrue(self._req.cached)
 
     def test_get_idx_file(self):
-        idx_name = 'objects/pack/pack-%s.idx' % ('1' * 40)
+        idx_name = os.path.join('objects', 'pack', 'pack-%s.idx' % ('1' * 40))
         backend = _test_backend([], named_files={idx_name: 'idx contents'})
         mat = re.search('.*', idx_name)
         output = ''.join(get_idx_file(self._req, backend, mat))

+ 1 - 1
setup.py

@@ -10,7 +10,7 @@ except ImportError:
     has_setuptools = False
 from distutils.core import Distribution
 
-dulwich_version_string = '0.8.2'
+dulwich_version_string = '0.8.3'
 
 include_dirs = []
 # Windows MSVC support