Browse Source

Imported Upstream version 0.9.4

Jelmer Vernooij 11 năm trước cách đây
mục cha
commit
c68d4ce6e2

+ 1 - 0
MANIFEST.in

@@ -2,6 +2,7 @@ include NEWS
 include README
 include Makefile
 include COPYING
+include dulwich/stdint.h
 recursive-include docs conf.py *.txt Makefile make.bat
 recursive-include examples *.py
 graft dulwich/tests/data

+ 58 - 0
NEWS

@@ -1,3 +1,61 @@
+0.9.4	2013-11-30
+
+ IMPROVEMENTS
+
+ * Add ssh_kwargs attribute to ParamikoSSHVendor. (milki)
+
+ * Add Repo.set_description(). (Víðir Valberg Guðmundsson)
+
+ * Add a basic `dulwich.porcelain` module. (Jelmer Vernooij, Marcin Kuzminski)
+
+ * Various performance improvements for object access.
+   (Jelmer Vernooij)
+
+ * New function `get_transport_and_path_from_url`,
+   similar to `get_transport_and_path` but only
+   supports URLs.
+   (Jelmer Vernooij)
+
+ * Add support for file:// URLs in `get_transport_and_path_from_url`.
+   (Jelmer Vernooij)
+
+ * Add LocalGitClient implementation.
+   (Jelmer Vernooij)
+
+ BUG FIXES
+
+  * Support filesystems with 64bit inode and device numbers.
+    (André Roth)
+
+ CHANGES
+
+  * Ref handling has been moved to dulwich.refs.
+    (Jelmer Vernooij)
+
+ API CHANGES
+
+  * Remove long deprecated RefsContainer.set_ref().
+    (Jelmer Vernooij)
+
+  * Repo.ref() is now deprecated in favour of Repo.refs[].
+    (Jelmer Vernooij)
+
+FEATURES
+
+  * Add support for graftpoints. (milki)
+
+0.9.3	2013-09-27
+
+ BUG FIXES
+
+  * Fix path for stdint.h in MANIFEST.in. (Jelmer Vernooij)
+
+0.9.2	2013-09-26
+
+ BUG FIXES
+
+  * Include stdint.h in MANIFEST.in (Mark Mikofski)
+
 0.9.1	2013-09-22
 
  BUG FIXES

+ 7 - 5
PKG-INFO

@@ -1,6 +1,6 @@
 Metadata-Version: 1.0
 Name: dulwich
-Version: 0.9.1
+Version: 0.9.4
 Summary: Python Git Library
 Home-page: http://samba.org/~jelmer/dulwich
 Author: Jelmer Vernooij
@@ -8,11 +8,13 @@ Author-email: jelmer@samba.org
 License: GPLv2 or later
 Description: 
               Simple Python implementation of the Git file formats and
-              protocols. Dulwich is the place where Mr. and Mrs. Git live
-              in one of the Monty Python sketches.
+              protocols.
         
-              All functionality is available in pure Python, but (optional)
-              C extensions are also available for better performance.
+              All functionality is available in pure Python. Optional
+              C extensions can be built for improved performance.
+        
+              Dulwich takes its name from the area in London where the friendly
+              Mr. and Mrs. Git once attended a cocktail party.
               
 Keywords: git
 Platform: UNKNOWN

+ 69 - 48
bin/dulwich

@@ -30,20 +30,34 @@ import os
 import sys
 from getopt import getopt
 
+from dulwich import porcelain
 from dulwich.client import get_transport_and_path
 from dulwich.errors import ApplyDeltaError
 from dulwich.index import Index
 from dulwich.pack import Pack, sha_to_hex
 from dulwich.patch import write_tree_diff
 from dulwich.repo import Repo
-from dulwich.server import update_server_info
 
 
 def cmd_archive(args):
     opts, args = getopt(args, "", [])
     client, path = get_transport_and_path(args.pop(0))
+    location = args.pop(0)
     committish = args.pop(0)
-    client.archive(path, committish, sys.stdout.write, sys.stderr.write)
+    porcelain.archive(location, committish, outstream=sys.stdout,
+        errstream=sys.stderr)
+
+
+def cmd_add(args):
+    opts, args = getopt(args, "", [])
+
+    porcelain.add(".", paths=args)
+
+
+def cmd_rm(args):
+    opts, args = getopt(args, "", [])
+
+    porcelain.rm(".", paths=args)
 
 
 def cmd_fetch_pack(args):
@@ -77,26 +91,7 @@ def cmd_log(args):
         path = args.pop(0)
     else:
         path = "."
-    r = Repo(path)
-    todo = [r.head()]
-    done = set()
-    while todo:
-        sha = todo.pop()
-        assert isinstance(sha, str)
-        if sha in done:
-            continue
-        done.add(sha)
-        commit = r[sha]
-        print "-" * 50
-        print "commit: %s" % sha
-        if len(commit.parents) > 1:
-            print "merge: %s" % "...".join(commit.parents[1:])
-        print "author: %s" % commit.author
-        print "committer: %s" % commit.committer
-        print ""
-        print commit.message
-        print ""
-        todo.extend([p for p in commit.parents if p not in done])
+    porcelain.log(repo=path, outstream=sys.stdout)
 
 
 def cmd_diff(args):
@@ -159,56 +154,77 @@ def cmd_init(args):
     else:
         path = args[0]
 
-    if not os.path.exists(path):
-        os.mkdir(path)
-
-    if "--bare" in opts:
-        Repo.init_bare(path)
-    else:
-        Repo.init(path)
+    porcelain.init(path, bare=("--bare" in opts))
 
 
 def cmd_clone(args):
-    opts, args = getopt(args, "", [])
+    opts, args = getopt(args, "", ["bare"])
     opts = dict(opts)
 
     if args == []:
         print "usage: dulwich clone host:path [PATH]"
         sys.exit(1)
-    client, host_path = get_transport_and_path(args.pop(0))
 
+    source = args.pop(0)
     if len(args) > 0:
-        path = args.pop(0)
+        target = args.pop(0)
     else:
-        path = host_path.split("/")[-1]
+        target = None
 
-    if not os.path.exists(path):
-        os.mkdir(path)
-    r = Repo.init(path)
-    remote_refs = client.fetch(host_path, r,
-        determine_wants=r.object_store.determine_wants_all,
-        progress=sys.stdout.write)
-    r["HEAD"] = remote_refs["HEAD"]
+    porcelain.clone(source, target, bare=("--bare" in opts))
 
 
 def cmd_commit(args):
     opts, args = getopt(args, "", ["message"])
     opts = dict(opts)
-    r = Repo(".")
-    committer = "%s <%s>" % (os.getenv("GIT_COMMITTER_NAME"), 
-                             os.getenv("GIT_COMMITTER_EMAIL"))
-    author = "%s <%s>" % (os.getenv("GIT_AUTHOR_NAME"), 
-                          os.getenv("GIT_AUTHOR_EMAIL"))
-    r.do_commit(committer=committer, author=author, message=opts["--message"])
+    porcelain.commit(".", message=opts["--message"])
+
+
+def cmd_commit_tree(args):
+    opts, args = getopt(args, "", ["message"])
+    if args == []:
+        print "usage: dulwich commit-tree tree"
+        sys.exit(1)
+    opts = dict(opts)
+    porcelain.commit_tree(".", tree=args[0], message=opts["--message"])
 
 
 def cmd_update_server_info(args):
-    r = Repo(".")
-    update_server_info(r)
+    porcelain.update_server_info(".")
+
+
+def cmd_symbolic_ref(args):
+    opts, args = getopt(args, "", ["ref-name", "force"])
+    if not args:
+        print "Usage: dulwich symbolic-ref REF_NAME [--force]"
+        sys.exit(1)
+
+    ref_name = args.pop(0)
+    porcelain.symbolic_ref(".", ref_name=ref_name, force='--force' in args)
+
+
+def cmd_show(args):
+    opts, args = getopt(args, "", [])
+    porcelain.show(".")
+
+
+def cmd_diff_tree(args):
+    opts, args = getopt(args, "", [])
+    if len(args) < 2:
+        print "Usage: dulwich diff-tree OLD-TREE NEW-TREE"
+        sys.exit(1)
+    porcelain.diff_tree(".", args[0], args[1])
+
+
+def cmd_rev_list(args):
+    opts, args = getopt(args, "", [])
+    porcelain.rev_list(".", args)
 
 
 commands = {
     "commit": cmd_commit,
+    "commit-tree": cmd_commit_tree,
+    "diff-tree": cmd_diff_tree,
     "fetch-pack": cmd_fetch_pack,
     "fetch": cmd_fetch,
     "dump-pack": cmd_dump_pack,
@@ -218,7 +234,12 @@ commands = {
     "clone": cmd_clone,
     "archive": cmd_archive,
     "update-server-info": cmd_update_server_info,
+    "symbolic-ref": cmd_symbolic_ref,
     "diff": cmd_diff,
+    "add": cmd_add,
+    "rm": cmd_rm,
+    "show": cmd_show,
+    "rev-list": cmd_rev_list,
     }
 
 if len(sys.argv) < 2:

+ 0 - 2
docs/tutorial/.gitignore

@@ -1,2 +0,0 @@
-*.html
-myrepo

+ 7 - 5
dulwich.egg-info/PKG-INFO

@@ -1,6 +1,6 @@
 Metadata-Version: 1.0
 Name: dulwich
-Version: 0.9.1
+Version: 0.9.4
 Summary: Python Git Library
 Home-page: http://samba.org/~jelmer/dulwich
 Author: Jelmer Vernooij
@@ -8,11 +8,13 @@ Author-email: jelmer@samba.org
 License: GPLv2 or later
 Description: 
               Simple Python implementation of the Git file formats and
-              protocols. Dulwich is the place where Mr. and Mrs. Git live
-              in one of the Monty Python sketches.
+              protocols.
         
-              All functionality is available in pure Python, but (optional)
-              C extensions are also available for better performance.
+              All functionality is available in pure Python. Optional
+              C extensions can be built for improved performance.
+        
+              Dulwich takes its name from the area in London where the friendly
+              Mr. and Mrs. Git once attended a cocktail party.
               
 Keywords: git
 Platform: UNKNOWN

+ 6 - 1
dulwich.egg-info/SOURCES.txt

@@ -15,7 +15,6 @@ docs/index.txt
 docs/make.bat
 docs/performance.txt
 docs/protocol.txt
-docs/tutorial/.gitignore
 docs/tutorial/Makefile
 docs/tutorial/conclusion.txt
 docs/tutorial/index.txt
@@ -43,9 +42,12 @@ dulwich/object_store.py
 dulwich/objects.py
 dulwich/pack.py
 dulwich/patch.py
+dulwich/porcelain.py
 dulwich/protocol.py
+dulwich/refs.py
 dulwich/repo.py
 dulwich/server.py
+dulwich/stdint.h
 dulwich/walk.py
 dulwich/web.py
 dulwich.egg-info/PKG-INFO
@@ -59,6 +61,7 @@ dulwich/tests/test_config.py
 dulwich/tests/test_diff_tree.py
 dulwich/tests/test_fastexport.py
 dulwich/tests/test_file.py
+dulwich/tests/test_grafts.py
 dulwich/tests/test_hooks.py
 dulwich/tests/test_index.py
 dulwich/tests/test_lru_cache.py
@@ -67,6 +70,7 @@ dulwich/tests/test_object_store.py
 dulwich/tests/test_objects.py
 dulwich/tests/test_pack.py
 dulwich/tests/test_patch.py
+dulwich/tests/test_porcelain.py
 dulwich/tests/test_protocol.py
 dulwich/tests/test_repository.py
 dulwich/tests/test_server.py
@@ -159,4 +163,5 @@ dulwich/tests/data/repos/submodule/dotgit
 dulwich/tests/data/tags/71/033db03a03c6a36721efcf1968dd8f8e0cf023
 dulwich/tests/data/trees/70/c190eb48fa8bbb50ddc692a17b44cb781af7f6
 examples/clone.py
+examples/config.py
 examples/diff.py

+ 1 - 1
dulwich/__init__.py

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

+ 1 - 2
dulwich/_compat.py

@@ -411,13 +411,12 @@ class OrderedDict(dict):
             yield (k, self[k])
 
     def update(*args, **kwds):
-        """od.update(E, **F) -> None.  Update od from dict/iterable E and F.
+        """od.update(E, F) -> None.  Update od from dict/iterable E and F.
 
         If E is a dict instance, does:           for k in E: od[k] = E[k]
         If E has a .keys() method, does:         for k in E.keys(): od[k] = E[k]
         Or if E is an iterable of items, does:   for k, v in E: od[k] = v
         In either case, this is followed by:     for k, v in F.items(): od[k] = v
-
         """
         if len(args) > 2:
             raise TypeError('update() takes at most 2 positional '

+ 11 - 16
dulwich/_diff_tree.c

@@ -69,7 +69,7 @@ static PyObject **tree_entries(char *path, Py_ssize_t path_len, PyObject *tree,
 		*n = 0;
 		result = PyMem_New(PyObject*, 0);
 		if (!result) {
-			PyErr_SetNone(PyExc_MemoryError);
+			PyErr_NoMemory();
 			return NULL;
 		}
 		return result;
@@ -80,7 +80,7 @@ static PyObject **tree_entries(char *path, Py_ssize_t path_len, PyObject *tree,
 		return NULL;
 	items = PyObject_CallFunctionObjArgs(iteritems, Py_True, NULL);
 	Py_DECREF(iteritems);
-	if (!items) {
+	if (items == NULL) {
 		return NULL;
 	}
 	/* The C implementation of iteritems returns a list, so depend on that. */
@@ -93,7 +93,7 @@ static PyObject **tree_entries(char *path, Py_ssize_t path_len, PyObject *tree,
 	*n = PyList_Size(items);
 	result = PyMem_New(PyObject*, *n);
 	if (!result) {
-		PyErr_SetNone(PyExc_MemoryError);
+		PyErr_NoMemory();
 		goto error;
 	}
 	for (i = 0; i < *n; i++) {
@@ -113,7 +113,7 @@ static PyObject **tree_entries(char *path, Py_ssize_t path_len, PyObject *tree,
 			new_path_len += path_len + 1;
 		new_path = PyMem_Malloc(new_path_len);
 		if (!new_path) {
-			PyErr_SetNone(PyExc_MemoryError);
+			PyErr_NoMemory();
 			goto error;
 		}
 		if (path_len) {
@@ -175,25 +175,20 @@ done:
 
 static PyObject *py_merge_entries(PyObject *self, PyObject *args)
 {
-	PyObject *path, *tree1, *tree2, **entries1 = NULL, **entries2 = NULL;
+	PyObject *tree1, *tree2, **entries1 = NULL, **entries2 = NULL;
 	PyObject *e1, *e2, *pair, *result = NULL;
-	Py_ssize_t path_len, n1 = 0, n2 = 0, i1 = 0, i2 = 0;
+	Py_ssize_t n1 = 0, n2 = 0, i1 = 0, i2 = 0;
+	int path_len;
 	char *path_str;
 	int cmp;
 
-	if (!PyArg_ParseTuple(args, "OOO", &path, &tree1, &tree2))
+	if (!PyArg_ParseTuple(args, "s#OO", &path_str, &path_len, &tree1, &tree2))
 		return NULL;
 
-	path_str = PyString_AsString(path);
-	if (!path_str) {
-		PyErr_SetString(PyExc_TypeError, "path is not a string");
-		return NULL;
-	}
-	path_len = PyString_GET_SIZE(path);
-
 	entries1 = tree_entries(path_str, path_len, tree1, &n1);
 	if (!entries1)
 		goto error;
+
 	entries2 = tree_entries(path_str, path_len, tree2, &n2);
 	if (!entries2)
 		goto error;
@@ -265,6 +260,7 @@ static PyObject *py_is_tree(PyObject *self, PyObject *args)
 
 	if (mode == Py_None) {
 		result = Py_False;
+		Py_INCREF(result);
 	} else {
 		lmode = PyInt_AsLong(mode);
 		if (lmode == -1 && PyErr_Occurred()) {
@@ -273,7 +269,6 @@ static PyObject *py_is_tree(PyObject *self, PyObject *args)
 		}
 		result = PyBool_FromLong(S_ISDIR((mode_t)lmode));
 	}
-	Py_INCREF(result);
 	Py_DECREF(mode);
 	return result;
 }
@@ -347,7 +342,7 @@ static PyObject *py_count_blocks(PyObject *self, PyObject *args)
 	num_chunks = PyList_GET_SIZE(chunks);
 	block = PyMem_New(char, block_size);
 	if (!block) {
-		PyErr_SetNone(PyExc_MemoryError);
+		PyErr_NoMemory();
 		goto error;
 	}
 

+ 104 - 23
dulwich/client.py

@@ -1,5 +1,5 @@
 # client.py -- Implementation of the server side git protocols
-# Copyright (C) 2008-2009 Jelmer Vernooij <jelmer@samba.org>
+# Copyright (C) 2008-2013 Jelmer Vernooij <jelmer@samba.org>
 # Copyright (C) 2008 John Carr
 #
 # This program is free software; you can redistribute it and/or
@@ -62,6 +62,9 @@ from dulwich.protocol import (
 from dulwich.pack import (
     write_pack_objects,
     )
+from dulwich.refs import (
+    read_info_refs,
+    )
 
 
 # Python 2.6.6 included these in urlparse.uses_netloc upstream. Do
@@ -154,14 +157,6 @@ def read_pkt_refs(proto):
     return refs, set(server_capabilities)
 
 
-def read_info_refs(f):
-    ret = {}
-    for l in f.readlines():
-        (sha, name) = l.rstrip("\r\n").split("\t", 1)
-        ret[name] = sha
-    return ret
-
-
 # TODO(durin42): this doesn't correctly degrade if the server doesn't
 # support some capabilities. This should work properly with servers
 # that don't support multi_ack.
@@ -650,6 +645,64 @@ class SubprocessGitClient(TraditionalGitClient):
                         report_activity=self._report_activity), p.can_read
 
 
+class LocalGitClient(GitClient):
+    """Git Client that just uses a local Repo."""
+
+    def __init__(self, thin_packs=True, report_activity=None):
+        """Create a new LocalGitClient instance.
+
+        :param path: Path to the local repository
+        :param thin_packs: Whether or not thin packs should be retrieved
+        :param report_activity: Optional callback for reporting transport
+            activity.
+        """
+        self._report_activity = report_activity
+        # Ignore the thin_packs argument
+
+    def send_pack(self, path, determine_wants, generate_pack_contents,
+                  progress=None):
+        """Upload a pack to a remote repository.
+
+        :param path: Repository path
+        :param generate_pack_contents: Function that can return a sequence of the
+            shas of the objects to upload.
+        :param progress: Optional progress function
+
+        :raises SendPackError: if server rejects the pack data
+        :raises UpdateRefsError: if the server supports report-status
+                                 and rejects ref updates
+        """
+        raise NotImplementedError(self.send_pack)
+
+    def fetch(self, path, target, determine_wants=None, progress=None):
+        """Fetch into a target repository.
+
+        :param path: Path to fetch from
+        :param target: Target repository to fetch into
+        :param determine_wants: Optional function to determine what refs
+            to fetch
+        :param progress: Optional progress function
+        :return: remote refs as dictionary
+        """
+        from dulwich.repo import Repo
+        r = Repo(path)
+        return r.fetch(target, determine_wants=determine_wants, progress=progress)
+
+    def fetch_pack(self, path, determine_wants, graph_walker, pack_data,
+                   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)
+        """
+        raise NotImplementedError(self.fetch_pack)
+
+
+# What Git client to use for local access
+default_local_git_client_cls = SubprocessGitClient
+
 class SSHVendor(object):
     """A client side SSH implementation."""
 
@@ -777,13 +830,22 @@ else:
 
     class ParamikoSSHVendor(object):
 
+        def __init__(self):
+            self.ssh_kwargs = {}
+
         def run_command(self, host, command, username=None, port=None,
-                progress_stderr=None, **kwargs):
+                progress_stderr=None):
+
+            # Paramiko needs an explicit port. None is not valid
+            if port is None:
+                port = 22
+
             client = paramiko.SSHClient()
 
             policy = paramiko.client.MissingHostKeyPolicy()
             client.set_missing_host_key_policy(policy)
-            client.connect(host, username=username, port=port, **kwargs)
+            client.connect(host, username=username, port=port,
+                           **self.ssh_kwargs)
 
             # Open SSH session
             channel = client.get_transport().open_session()
@@ -835,7 +897,7 @@ class HttpGitClient(GitClient):
         req = urllib2.Request(url, headers=headers, data=data)
         try:
             resp = self._perform(req)
-        except urllib2.HTTPError as e:
+        except urllib2.HTTPError, e:
             if e.code == 404:
                 raise NotGitRepository()
             if e.code != 200:
@@ -958,16 +1020,16 @@ class HttpGitClient(GitClient):
         return refs
 
 
-def get_transport_and_path(uri, **kwargs):
-    """Obtain a git client from a URI or path.
+def get_transport_and_path_from_url(url, **kwargs):
+    """Obtain a git client from a URL.
 
-    :param uri: URI or path
+    :param url: URL to open
     :param thin_packs: Whether or not thin packs should be retrieved
     :param report_activity: Optional callback for reporting transport
         activity.
     :return: Tuple with client instance and relative path.
     """
-    parsed = urlparse.urlparse(uri)
+    parsed = urlparse.urlparse(url)
     if parsed.scheme == 'git':
         return (TCPGitClient(parsed.hostname, port=parsed.port, **kwargs),
                 parsed.path)
@@ -979,17 +1041,36 @@ def get_transport_and_path(uri, **kwargs):
                             username=parsed.username, **kwargs), path
     elif parsed.scheme in ('http', 'https'):
         return HttpGitClient(urlparse.urlunparse(parsed), **kwargs), parsed.path
+    elif parsed.scheme == 'file':
+        return default_local_git_client_cls(**kwargs), parsed.path
+
+    raise ValueError("unknown scheme '%s'" % parsed.scheme)
+
+
+def get_transport_and_path(location, **kwargs):
+    """Obtain a git client from a URL.
+
+    :param location: URL or path
+    :param thin_packs: Whether or not thin packs should be retrieved
+    :param report_activity: Optional callback for reporting transport
+        activity.
+    :return: Tuple with client instance and relative path.
+    """
+    # First, try to parse it as a URL
+    try:
+        return get_transport_and_path_from_url(location, **kwargs)
+    except ValueError:
+        pass
 
-    if parsed.scheme and not parsed.netloc:
+    if ':' in location and not '@' in location:
         # SSH with no user@, zero or one leading slash.
-        return SSHGitClient(parsed.scheme, **kwargs), parsed.path
-    elif parsed.scheme:
-        raise ValueError('Unknown git protocol scheme: %s' % parsed.scheme)
-    elif '@' in parsed.path and ':' in parsed.path:
+        (hostname, path) = location.split(':')
+        return SSHGitClient(hostname, **kwargs), path
+    elif '@' in location and ':' in location:
         # SSH with user@host:foo.
-        user_host, path = parsed.path.split(':')
+        user_host, path = location.split(':')
         user, host = user_host.rsplit('@')
         return SSHGitClient(host, username=user, **kwargs), path
 
     # Otherwise, assume it's a local path.
-    return SubprocessGitClient(**kwargs), uri
+    return default_local_git_client_cls(**kwargs), location

+ 25 - 3
dulwich/config.py

@@ -1,5 +1,5 @@
 # config.py - Reading and writing Git config files
-# Copyright (C) 2011 Jelmer Vernooij <jelmer@samba.org>
+# Copyright (C) 2011-2013 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
@@ -73,12 +73,29 @@ class Config(object):
     def set(self, section, name, value):
         """Set a configuration value.
 
+        :param section: Tuple with section name and optional subsection namee
         :param name: Name of the configuration value, including section
             and optional subsection
         :param: Value of the setting
         """
         raise NotImplementedError(self.set)
 
+    def iteritems(self, section):
+        """Iterate over the configuration pairs for a specific section.
+
+        :param section: Tuple with section name and optional subsection namee
+        :return: Iterator over (name, value) pairs
+        """
+        raise NotImplementedError(self.iteritems)
+
+    def itersections(self):
+        """Iterate over the sections.
+
+        :return: Iterator over section tuples
+        """
+        raise NotImplementedError(self.itersections)
+
+
 
 class ConfigDict(Config, DictMixin):
     """Git configuration stored in a dictionary."""
@@ -129,6 +146,12 @@ class ConfigDict(Config, DictMixin):
             section = (section, )
         self._values.setdefault(section, OrderedDict())[name] = value
 
+    def iteritems(self, section):
+        return self._values.get(section, OrderedDict()).iteritems()
+
+    def itersections(self):
+        return self._values.keys()
+
 
 def _format_string(value):
     if (value.startswith(" ") or
@@ -326,8 +349,7 @@ class StackedConfig(Config):
     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
+        This will look in the users' home directory and the system
         configuration.
         """
         paths = []

+ 1 - 1
dulwich/errors.py

@@ -1,6 +1,6 @@
 # errors.py -- errors for dulwich
 # Copyright (C) 2007 James Westby <jw+debian@jameswestby.net>
-# Copyright (C) 2009 Jelmer Vernooij <jelmer@samba.org>
+# Copyright (C) 2009-2012 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

+ 1 - 1
dulwich/fastexport.py

@@ -1,5 +1,5 @@
 # __init__.py -- Fast export/import functionality
-# Copyright (C) 2010 Jelmer Vernooij <jelmer@samba.org>
+# Copyright (C) 2010-2013 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

+ 6 - 3
dulwich/hooks.py

@@ -1,4 +1,5 @@
 # hooks.py -- for dealing with git hooks
+# Copyright (C) 2012-2013 Jelmer Vernooij and others.
 #
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
@@ -20,7 +21,6 @@
 import os
 import subprocess
 import tempfile
-import warnings
 
 from dulwich.errors import (
     HookError,
@@ -30,7 +30,7 @@ from dulwich.errors import (
 class Hook(object):
     """Generic hook object."""
 
-    def execute(elf, *args):
+    def execute(self, *args):
         """Execute the hook with the given args
 
         :param args: argument list to hook
@@ -137,8 +137,11 @@ class CommitMsgShellHook(ShellHook):
 
         def clean_msg(success, *args):
             if success:
-                with open(args[0], 'rb') as f:
+                f = open(args[0], 'rb')
+                try:
                     new_msg = f.read()
+                finally:
+                    f.close()
                 os.unlink(args[0])
                 return new_msg
             os.unlink(args[0])

+ 2 - 2
dulwich/index.py

@@ -1,5 +1,5 @@
 # index.py -- File parser/writer for the git index file
-# Copyright (C) 2008-2009 Jelmer Vernooij <jelmer@samba.org>
+# Copyright (C) 2008-2013 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
@@ -114,7 +114,7 @@ def write_cache_entry(f, entry):
     write_cache_time(f, ctime)
     write_cache_time(f, mtime)
     flags = len(name) | (flags &~ 0x0fff)
-    f.write(struct.pack(">LLLLLL20sH", dev, ino, mode, uid, gid, size, hex_to_sha(sha), flags))
+    f.write(struct.pack(">LLLLLL20sH", dev & 0xFFFFFFFF, ino & 0xFFFFFFFF, mode, uid, gid, size, hex_to_sha(sha), flags))
     f.write(name)
     real_size = ((f.tell() - beginoffset + 8) & ~7)
     f.write("\0" * ((beginoffset + real_size) - f.tell()))

+ 20 - 18
dulwich/object_store.py

@@ -1,5 +1,5 @@
 # object_store.py -- Object store for git objects
-# Copyright (C) 2008-2012 Jelmer Vernooij <jelmer@samba.org>
+# Copyright (C) 2008-2013 Jelmer Vernooij <jelmer@samba.org>
 #                         and others
 #
 # This program is free software; you can redistribute it and/or
@@ -113,7 +113,7 @@ class BaseObjectStore(object):
     def __getitem__(self, sha):
         """Obtain an object by SHA1."""
         type_num, uncomp = self.get_raw(sha)
-        return ShaFile.from_raw_string(type_num, uncomp)
+        return ShaFile.from_raw_string(type_num, uncomp, sha=sha)
 
     def __iter__(self):
         """Iterate over the SHAs that are present in this store."""
@@ -191,14 +191,6 @@ class BaseObjectStore(object):
             sha = graphwalker.next()
         return haves
 
-    def get_graph_walker(self, heads):
-        """Obtain a graph walker for this object store.
-
-        :param heads: Local heads to start search with
-        :return: GraphWalker object
-        """
-        return ObjectStoreGraphWalker(heads, lambda sha: self[sha].parents)
-
     def generate_pack_contents(self, have, want, progress=None):
         """Iterate over the contents of a pack file.
 
@@ -492,7 +484,15 @@ class DiskObjectStore(PackBasedObjectStore):
             raise
         pack_files.sort(reverse=True)
         suffix_len = len(".pack")
-        return [Pack(f[:-suffix_len]) for _, f in pack_files]
+        result = []
+        try:
+            for _, f in pack_files:
+                result.append(Pack(f[:-suffix_len]))
+        except:
+            for p in result:
+                p.close()
+            raise
+        return result
 
     def _pack_cache_stale(self):
         try:
@@ -617,15 +617,17 @@ class DiskObjectStore(PackBasedObjectStore):
         :param path: Path to the pack file.
         """
         p = PackData(path)
-        entries = p.sorted_entries()
-        basename = os.path.join(self.pack_dir,
-            "pack-%s" % iter_sha1(entry[0] for entry in entries))
-        f = GitFile(basename+".idx", "wb")
         try:
-            write_pack_index_v2(f, entries, p.get_stored_checksum())
+            entries = p.sorted_entries()
+            basename = os.path.join(self.pack_dir,
+                "pack-%s" % iter_sha1(entry[0] for entry in entries))
+            f = GitFile(basename+".idx", "wb")
+            try:
+                write_pack_index_v2(f, entries, p.get_stored_checksum())
+            finally:
+                f.close()
         finally:
-            f.close()
-        p.close()
+            p.close()
         os.rename(path, basename + ".pack")
         final_pack = Pack(basename)
         self._add_known_pack(final_pack)

+ 67 - 49
dulwich/objects.py

@@ -1,6 +1,6 @@
 # objects.py -- Access to base git objects
 # Copyright (C) 2007 James Westby <jw+debian@jameswestby.net>
-# Copyright (C) 2008-2009 Jelmer Vernooij <jelmer@samba.org>
+# Copyright (C) 2008-2013 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
@@ -291,17 +291,20 @@ class ShaFile(object):
             self._deserialize(self._chunked_text)
             self._needs_parsing = False
 
-    def set_raw_string(self, text):
+    def set_raw_string(self, text, sha=None):
         """Set the contents of this object from a serialized string."""
         if type(text) != str:
             raise TypeError(text)
-        self.set_raw_chunks([text])
+        self.set_raw_chunks([text], sha)
 
-    def set_raw_chunks(self, chunks):
+    def set_raw_chunks(self, chunks, sha=None):
         """Set the contents of this object from a list of chunks."""
         self._chunked_text = chunks
         self._deserialize(chunks)
-        self._sha = None
+        if sha is None:
+            self._sha = None
+        else:
+            self._sha = FixedSha(sha)
         self._needs_parsing = False
         self._needs_serialization = False
 
@@ -403,25 +406,27 @@ class ShaFile(object):
             raise ObjectFormatException("invalid object header")
 
     @staticmethod
-    def from_raw_string(type_num, string):
+    def from_raw_string(type_num, string, sha=None):
         """Creates an object of the indicated type from the raw string given.
 
         :param type_num: The numeric type of the object.
         :param string: The raw uncompressed contents.
+        :param sha: Optional known sha for the object
         """
         obj = object_class(type_num)()
-        obj.set_raw_string(string)
+        obj.set_raw_string(string, sha)
         return obj
 
     @staticmethod
-    def from_raw_chunks(type_num, chunks):
+    def from_raw_chunks(type_num, chunks, sha=None):
         """Creates an object of the indicated type from the raw chunks given.
 
         :param type_num: The numeric type of the object.
         :param chunks: An iterable of the raw uncompressed contents.
+        :param sha: Optional known sha for the object
         """
         obj = object_class(type_num)()
-        obj.set_raw_chunks(chunks)
+        obj.set_raw_chunks(chunks, sha)
         return obj
 
     @classmethod
@@ -579,15 +584,15 @@ class Blob(ShaFile):
         super(Blob, self).check()
 
 
-def _parse_tag_or_commit(text):
-    """Parse tag or commit text.
+def _parse_message(chunks):
+    """Parse a message with a list of fields and a body.
 
-    :param text: the raw text of the tag or commit object.
+    :param chunks: the raw chunks of the tag or commit object.
     :return: iterator of tuples of (field, value), one per header line, in the
         order read from the text, possibly including duplicates. Includes a
         field named None for the freeform tag/commit text.
     """
-    f = StringIO(text)
+    f = StringIO("".join(chunks))
     k = None
     v = ""
     for l in f:
@@ -604,11 +609,6 @@ def _parse_tag_or_commit(text):
     f.close()
 
 
-def parse_tag(text):
-    """Parse a tag object."""
-    return _parse_tag_or_commit(text)
-
-
 class Tag(ShaFile):
     """A Git Tag object."""
 
@@ -649,7 +649,7 @@ class Tag(ShaFile):
             check_identity(self._tagger, "invalid tagger")
 
         last = None
-        for field, _ in parse_tag("".join(self._chunked_text)):
+        for field, _ in _parse_message(self._chunked_text):
             if field == _OBJECT_HEADER and last is not None:
                 raise ObjectFormatException("unexpected object")
             elif field == _TYPE_HEADER and last != _OBJECT_HEADER:
@@ -680,7 +680,7 @@ class Tag(ShaFile):
     def _deserialize(self, chunks):
         """Grab the metadata attached to the tag"""
         self._tagger = None
-        for field, value in parse_tag("".join(chunks)):
+        for field, value in _parse_message(chunks):
             if field == _OBJECT_HEADER:
                 self._object_sha = value
             elif field == _TYPE_HEADER:
@@ -1035,8 +1035,45 @@ def format_timezone(offset, unnecessary_negative_timezone=False):
     return '%c%02d%02d' % (sign, offset / 3600, (offset / 60) % 60)
 
 
-def parse_commit(text):
-    return _parse_tag_or_commit(text)
+def parse_commit(chunks):
+    """Parse a commit object from chunks.
+
+    :param chunks: Chunks to parse
+    :return: Tuple of (tree, parents, author_info, commit_info,
+        encoding, mergetag, message, extra)
+    """
+    parents = []
+    extra = []
+    tree = None
+    author_info = (None, None, (None, None))
+    commit_info = (None, None, (None, None))
+    encoding = None
+    mergetag = []
+    message = None
+
+    for field, value in _parse_message(chunks):
+        if field == _TREE_HEADER:
+            tree = value
+        elif field == _PARENT_HEADER:
+            parents.append(value)
+        elif field == _AUTHOR_HEADER:
+            author, timetext, timezonetext = value.rsplit(" ", 2)
+            author_time = int(timetext)
+            author_info = (author, author_time, parse_timezone(timezonetext))
+        elif field == _COMMITTER_HEADER:
+            committer, timetext, timezonetext = value.rsplit(" ", 2)
+            commit_time = int(timetext)
+            commit_info = (committer, commit_time, parse_timezone(timezonetext))
+        elif field == _ENCODING_HEADER:
+            encoding = value
+        elif field == _MERGETAG_HEADER:
+            mergetag.append(Tag.from_string(value + "\n"))
+        elif field is None:
+            message = value
+        else:
+            extra.append((field, value))
+    return (tree, parents, author_info, commit_info, encoding, mergetag,
+            message, extra)
 
 
 class Commit(ShaFile):
@@ -1068,32 +1105,13 @@ class Commit(ShaFile):
         return commit
 
     def _deserialize(self, chunks):
-        self._parents = []
-        self._extra = []
-        self._author = None
-        for field, value in parse_commit(''.join(chunks)):
-            if field == _TREE_HEADER:
-                self._tree = value
-            elif field == _PARENT_HEADER:
-                self._parents.append(value)
-            elif field == _AUTHOR_HEADER:
-                self._author, timetext, timezonetext = value.rsplit(" ", 2)
-                self._author_time = int(timetext)
-                self._author_timezone, self._author_timezone_neg_utc =\
-                    parse_timezone(timezonetext)
-            elif field == _COMMITTER_HEADER:
-                self._committer, timetext, timezonetext = value.rsplit(" ", 2)
-                self._commit_time = int(timetext)
-                self._commit_timezone, self._commit_timezone_neg_utc =\
-                    parse_timezone(timezonetext)
-            elif field == _ENCODING_HEADER:
-                self._encoding = value
-            elif field is None:
-                self._message = value
-            elif field == _MERGETAG_HEADER:
-                self._mergetag.append(Tag.from_string(value + "\n"))
-            else:
-                self._extra.append((field, value))
+        (self._tree, self._parents, author_info, commit_info, self._encoding,
+                self._mergetag, self._message, self._extra) = \
+                        parse_commit(chunks)
+        (self._author, self._author_time, (self._author_timezone,
+            self._author_timezone_neg_utc)) = author_info
+        (self._committer, self._commit_time, (self._commit_timezone,
+            self._commit_timezone_neg_utc)) = commit_info
 
     def check(self):
         """Check this object for internal consistency.
@@ -1114,7 +1132,7 @@ class Commit(ShaFile):
         check_identity(self._committer, "invalid committer")
 
         last = None
-        for field, _ in parse_commit("".join(self._chunked_text)):
+        for field, _ in _parse_message(self._chunked_text):
             if field == _TREE_HEADER and last is not None:
                 raise ObjectFormatException("unexpected tree")
             elif field == _PARENT_HEADER and last not in (_PARENT_HEADER,

+ 7 - 4
dulwich/pack.py

@@ -1,6 +1,6 @@
 # pack.py -- For dealing with packed git objects.
 # Copyright (C) 2007 James Westby <jw+debian@jameswestby.net>
-# Copyright (C) 2008-2009 Jelmer Vernooij <jelmer@samba.org>
+# Copyright (C) 2008-2013 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
@@ -1182,8 +1182,10 @@ class PackData(object):
         and then the packfile can be asked directly for that object using this
         function.
         """
-        if offset in self._offset_cache:
+        try:
             return self._offset_cache[offset]
+        except KeyError:
+            pass
         assert isinstance(offset, long) or isinstance(offset, int),\
                 'offset was %r' % offset
         assert offset >= self._header_size
@@ -1802,7 +1804,8 @@ class Pack(object):
     def close(self):
         if self._data is not None:
             self._data.close()
-        self.index.close()
+        if self._idx is not None:
+            self._idx.close()
 
     def __eq__(self, other):
         return type(self) == type(other) and self.index == other.index
@@ -1858,7 +1861,7 @@ class Pack(object):
     def __getitem__(self, sha1):
         """Retrieve the specified SHA1."""
         type, uncomp = self.get_raw(sha1)
-        return ShaFile.from_raw_string(type, uncomp)
+        return ShaFile.from_raw_string(type, uncomp, sha=sha1)
 
     def iterobjects(self):
         """Iterate over the objects in this pack."""

+ 1 - 1
dulwich/patch.py

@@ -1,5 +1,5 @@
 # patch.py -- For dealing with packed-style patches.
-# Copyright (C) 2009 Jelmer Vernooij <jelmer@samba.org>
+# Copyright (C) 2009-2013 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

+ 267 - 0
dulwich/porcelain.py

@@ -0,0 +1,267 @@
+# porcelain.py -- Porcelain-like layer on top of Dulwich
+# Copyright (C) 2013 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.
+
+import os
+import sys
+
+from dulwich import index
+from dulwich.client import get_transport_and_path
+from dulwich.patch import write_tree_diff
+from dulwich.repo import (BaseRepo, Repo)
+from dulwich.server import update_server_info as server_update_server_info
+
+"""Simple wrapper that provides porcelain-like functions on top of Dulwich.
+
+Currently implemented:
+ * archive
+ * add
+ * clone
+ * commit
+ * commit-tree
+ * diff-tree
+ * init
+ * remove
+ * rev-list
+ * update-server-info
+ * symbolic-ref
+
+These functions are meant to behave similarly to the git subcommands.
+Differences in behaviour are considered bugs.
+"""
+
+__docformat__ = 'restructuredText'
+
+
+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):
+        return path_or_repo
+    return Repo(path_or_repo)
+
+
+def archive(location, committish=None, outstream=sys.stdout,
+            errstream=sys.stderr):
+    """Create an archive.
+
+    :param location: Location of repository for which to generate an archive.
+    :param committish: Commit SHA1 or ref to use
+    :param outstream: Output stream (defaults to stdout)
+    :param errstream: Error stream (defaults to stderr)
+    """
+
+    client, path = get_transport_and_path(location)
+    if committish is None:
+        committish = "HEAD"
+    client.archive(path, committish, outstream.write, errstream.write)
+
+
+def update_server_info(repo="."):
+    """Update server info files for a repository.
+
+    :param repo: path to the repository
+    """
+    r = open_repo(repo)
+    server_update_server_info(r)
+
+
+def symbolic_ref(repo, ref_name, force=False):
+    """Set git symbolic ref into HEAD.
+
+    :param repo: path to the repository
+    :param ref_name: short name of the new ref
+    :param force: force settings without checking if it exists in refs/heads
+    """
+    repo_obj = open_repo(repo)
+    ref_path = 'refs/heads/%s' % ref_name
+    if not force and ref_path not in repo_obj.refs.keys():
+        raise ValueError('fatal: ref `%s` is not a ref' % ref_name)
+    repo_obj.refs.set_symbolic_ref('HEAD', ref_path)
+
+
+def commit(repo=".", message=None, author=None, committer=None):
+    """Create a new commit.
+
+    :param repo: Path to repository
+    :param message: Optional commit message
+    :param author: Optional author name and email
+    :param committer: Optional committer name and email
+    :return: SHA1 of the new commit
+    """
+    # FIXME: Support --all argument
+    # FIXME: Support --signoff argument
+    r = open_repo(repo)
+    return r.do_commit(message=message, author=author,
+        committer=committer)
+
+
+def commit_tree(repo, tree, message=None, author=None, committer=None):
+    """Create a new commit object.
+
+    :param repo: Path to repository
+    :param tree: An existing tree object
+    :param author: Optional author name and email
+    :param committer: Optional committer name and email
+    """
+    r = open_repo(repo)
+    return r.do_commit(message=message, tree=tree, committer=committer,
+            author=author)
+
+
+def init(path=".", bare=False):
+    """Create a new git repository.
+
+    :param path: Path to repository.
+    :param bare: Whether to create a bare repository.
+    :return: A Repo instance
+    """
+    if not os.path.exists(path):
+        os.mkdir(path)
+
+    if bare:
+        return Repo.init_bare(path)
+    else:
+        return Repo.init(path)
+
+
+def clone(source, target=None, bare=False, checkout=None, outstream=sys.stdout):
+    """Clone a local or remote git repository.
+
+    :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 outstream: Optional stream to write progress to
+    :return: The new repository
+    """
+    if checkout is None:
+        checkout = (not bare)
+    if checkout and bare:
+        raise ValueError("checkout and bare are incompatible")
+    client, host_path = get_transport_and_path(source)
+
+    if target is None:
+        target = host_path.split("/")[-1]
+
+    if not os.path.exists(target):
+        os.mkdir(target)
+    if bare:
+        r = Repo.init_bare(target)
+    else:
+        r = Repo.init(target)
+    remote_refs = client.fetch(host_path, r,
+        determine_wants=r.object_store.determine_wants_all,
+        progress=outstream.write)
+    r["HEAD"] = remote_refs["HEAD"]
+    if checkout:
+        outstream.write('Checking out HEAD')
+        index.build_index_from_tree(r.path, r.index_path(),
+                                    r.object_store, r["HEAD"].tree)
+
+    return r
+
+
+def add(repo=".", paths=None):
+    """Add files to the staging area.
+
+    :param repo: Repository for the files
+    :param paths: Paths to add
+    """
+    # FIXME: Support patterns, directories, no argument.
+    r = open_repo(repo)
+    r.stage(paths)
+
+
+def rm(repo=".", paths=None):
+    """Remove files from the staging area.
+
+    :param repo: Repository for the files
+    :param paths: Paths to remove
+    """
+    r = open_repo(repo)
+    index = r.open_index()
+    for p in paths:
+        del index[p]
+    index.write()
+
+
+def print_commit(commit, outstream):
+    """Write a human-readable commit log entry.
+
+    :param commit: A `Commit` object
+    :param outstream: A stream file to write to
+    """
+    outstream.write("-" * 50 + "\n")
+    outstream.write("commit: %s\n" % commit.id)
+    if len(commit.parents) > 1:
+        outstream.write("merge: %s\n" % "...".join(commit.parents[1:]))
+    outstream.write("author: %s\n" % commit.author)
+    outstream.write("committer: %s\n" % commit.committer)
+    outstream.write("\n")
+    outstream.write(commit.message + "\n")
+    outstream.write("\n")
+
+
+def log(repo=".", outstream=sys.stdout):
+    """Write commit logs.
+
+    :param repo: Path to repository
+    :param outstream: Stream to write log output to
+    """
+    r = open_repo(repo)
+    walker = r.get_walker()
+    for entry in walker:
+        print_commit(entry.commit, outstream)
+
+
+def show(repo=".", committish=None, outstream=sys.stdout):
+    """Print the changes in a commit.
+
+    :param repo: Path to repository
+    :param committish: Commit to write
+    :param outstream: Stream to write to
+    """
+    if committish is None:
+        committish = "HEAD"
+    r = open_repo(repo)
+    commit = r[committish]
+    parent_commit = r[commit.parents[0]]
+    print_commit(commit, outstream)
+    write_tree_diff(outstream, r.object_store, parent_commit.tree, commit.tree)
+
+
+def diff_tree(repo, old_tree, new_tree, outstream=sys.stdout):
+    """Compares the content and mode of blobs found via two tree objects.
+
+    :param repo: Path to repository
+    :param old_tree: Id of old tree
+    :param new_tree: Id of new tree
+    :param outstream: Stream to write to
+    """
+    r = open_repo(repo)
+    write_tree_diff(outstream, r.object_store, old_tree, new_tree)
+
+
+def rev_list(repo, commits, outstream=sys.stdout):
+    """Lists commit objects in reverse chronological order.
+
+    :param repo: Path to repository
+    :param commits: Commits over which to iterate
+    :param outstream: Stream to write to
+    """
+    r = open_repo(repo)
+    for entry in r.get_walker(include=[r[c].id for c in commits]):
+        outstream.write("%s\n" % entry.commit.id)

+ 1 - 1
dulwich/protocol.py

@@ -1,6 +1,6 @@
 # protocol.py -- Shared parts of the git protocols
 # Copyright (C) 2008 John Carr <john.carr@unrouted.co.uk>
-# Copyright (C) 2008 Jelmer Vernooij <jelmer@samba.org>
+# Copyright (C) 2008-2012 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

+ 765 - 0
dulwich/refs.py

@@ -0,0 +1,765 @@
+# refs.py -- For dealing with git refs
+# Copyright (C) 2008-2013 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) any 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.
+
+
+"""Ref handling.
+
+"""
+import errno
+import os
+
+from dulwich.errors import (
+    PackedRefsException,
+    RefFormatError,
+    )
+from dulwich.objects import (
+    hex_to_sha,
+    )
+from dulwich.file import (
+    GitFile,
+    ensure_dir_exists,
+    )
+
+
+SYMREF = 'ref: '
+
+
+def check_ref_format(refname):
+    """Check if a refname is correctly formatted.
+
+    Implements all the same rules as git-check-ref-format[1].
+
+    [1] http://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
+
+    :param refname: The refname to check
+    :return: True if refname is valid, False otherwise
+    """
+    # These could be combined into one big expression, but are listed separately
+    # to parallel [1].
+    if '/.' in refname or refname.startswith('.'):
+        return False
+    if '/' not in refname:
+        return False
+    if '..' in refname:
+        return False
+    for c in refname:
+        if ord(c) < 040 or c in '\177 ~^:?*[':
+            return False
+    if refname[-1] in '/.':
+        return False
+    if refname.endswith('.lock'):
+        return False
+    if '@{' in refname:
+        return False
+    if '\\' in refname:
+        return False
+    return True
+
+
+class RefsContainer(object):
+    """A container for refs."""
+
+    def set_symbolic_ref(self, name, other):
+        """Make a ref point at another ref.
+
+        :param name: Name of the ref to set
+        :param other: Name of the ref to point at
+        """
+        raise NotImplementedError(self.set_symbolic_ref)
+
+    def get_packed_refs(self):
+        """Get contents of the packed-refs file.
+
+        :return: Dictionary mapping ref names to SHA1s
+
+        :note: Will return an empty dictionary when no packed-refs file is
+            present.
+        """
+        raise NotImplementedError(self.get_packed_refs)
+
+    def get_peeled(self, name):
+        """Return the cached peeled value of a ref, if available.
+
+        :param name: Name of the ref to peel
+        :return: The peeled value of the ref. If the ref is known not point to a
+            tag, this will be the SHA the ref refers to. If the ref may point to
+            a tag, but no cached information is available, None is returned.
+        """
+        return None
+
+    def import_refs(self, base, other):
+        for name, value in other.iteritems():
+            self["%s/%s" % (base, name)] = value
+
+    def allkeys(self):
+        """All refs present in this container."""
+        raise NotImplementedError(self.allkeys)
+
+    def keys(self, base=None):
+        """Refs present in this container.
+
+        :param base: An optional base to return refs under.
+        :return: An unsorted set of valid refs in this container, including
+            packed refs.
+        """
+        if base is not None:
+            return self.subkeys(base)
+        else:
+            return self.allkeys()
+
+    def subkeys(self, base):
+        """Refs present in this container under a base.
+
+        :param base: The base to return refs under.
+        :return: A set of valid refs in this container under the base; the base
+            prefix is stripped from the ref names returned.
+        """
+        keys = set()
+        base_len = len(base) + 1
+        for refname in self.allkeys():
+            if refname.startswith(base):
+                keys.add(refname[base_len:])
+        return keys
+
+    def as_dict(self, base=None):
+        """Return the contents of this container as a dictionary.
+
+        """
+        ret = {}
+        keys = self.keys(base)
+        if base is None:
+            base = ""
+        for key in keys:
+            try:
+                ret[key] = self[("%s/%s" % (base, key)).strip("/")]
+            except KeyError:
+                continue  # Unable to resolve
+
+        return ret
+
+    def _check_refname(self, name):
+        """Ensure a refname is valid and lives in refs or is HEAD.
+
+        HEAD is not a valid refname according to git-check-ref-format, but this
+        class needs to be able to touch HEAD. Also, check_ref_format expects
+        refnames without the leading 'refs/', but this class requires that
+        so it cannot touch anything outside the refs dir (or HEAD).
+
+        :param name: The name of the reference.
+        :raises KeyError: if a refname is not HEAD or is otherwise not valid.
+        """
+        if name in ('HEAD', 'refs/stash'):
+            return
+        if not name.startswith('refs/') or not check_ref_format(name[5:]):
+            raise RefFormatError(name)
+
+    def read_ref(self, refname):
+        """Read a reference without following any references.
+
+        :param refname: The name of the reference
+        :return: The contents of the ref file, or None if it does
+            not exist.
+        """
+        contents = self.read_loose_ref(refname)
+        if not contents:
+            contents = self.get_packed_refs().get(refname, None)
+        return contents
+
+    def read_loose_ref(self, name):
+        """Read a loose reference and return its contents.
+
+        :param name: the refname to read
+        :return: The contents of the ref file, or None if it does
+            not exist.
+        """
+        raise NotImplementedError(self.read_loose_ref)
+
+    def _follow(self, name):
+        """Follow a reference name.
+
+        :return: a tuple of (refname, sha), where refname is the name of the
+            last reference in the symbolic reference chain
+        """
+        contents = SYMREF + name
+        depth = 0
+        while contents.startswith(SYMREF):
+            refname = contents[len(SYMREF):]
+            contents = self.read_ref(refname)
+            if not contents:
+                break
+            depth += 1
+            if depth > 5:
+                raise KeyError(name)
+        return refname, contents
+
+    def __contains__(self, refname):
+        if self.read_ref(refname):
+            return True
+        return False
+
+    def __getitem__(self, name):
+        """Get the SHA1 for a reference name.
+
+        This method follows all symbolic references.
+        """
+        _, sha = self._follow(name)
+        if sha is None:
+            raise KeyError(name)
+        return sha
+
+    def set_if_equals(self, name, old_ref, new_ref):
+        """Set a refname to new_ref only if it currently equals old_ref.
+
+        This method follows all symbolic references if applicable for the
+        subclass, and can be used to perform an atomic compare-and-swap
+        operation.
+
+        :param name: The refname to set.
+        :param old_ref: The old sha the refname must refer to, or None to set
+            unconditionally.
+        :param new_ref: The new sha the refname will refer to.
+        :return: True if the set was successful, False otherwise.
+        """
+        raise NotImplementedError(self.set_if_equals)
+
+    def add_if_new(self, name, ref):
+        """Add a new reference only if it does not already exist."""
+        raise NotImplementedError(self.add_if_new)
+
+    def __setitem__(self, name, ref):
+        """Set a reference name to point to the given SHA1.
+
+        This method follows all symbolic references if applicable for the
+        subclass.
+
+        :note: This method unconditionally overwrites the contents of a
+            reference. To update atomically only if the reference has not
+            changed, use set_if_equals().
+        :param name: The refname to set.
+        :param ref: The new sha the refname will refer to.
+        """
+        self.set_if_equals(name, None, ref)
+
+    def remove_if_equals(self, name, old_ref):
+        """Remove a refname only if it currently equals old_ref.
+
+        This method does not follow symbolic references, even if applicable for
+        the subclass. It can be used to perform an atomic compare-and-delete
+        operation.
+
+        :param name: The refname to delete.
+        :param old_ref: The old sha the refname must refer to, or None to delete
+            unconditionally.
+        :return: True if the delete was successful, False otherwise.
+        """
+        raise NotImplementedError(self.remove_if_equals)
+
+    def __delitem__(self, name):
+        """Remove a refname.
+
+        This method does not follow symbolic references, even if applicable for
+        the subclass.
+
+        :note: This method unconditionally deletes the contents of a reference.
+            To delete atomically only if the reference has not changed, use
+            remove_if_equals().
+
+        :param name: The refname to delete.
+        """
+        self.remove_if_equals(name, None)
+
+
+class DictRefsContainer(RefsContainer):
+    """RefsContainer backed by a simple dict.
+
+    This container does not support symbolic or packed references and is not
+    threadsafe.
+    """
+
+    def __init__(self, refs):
+        self._refs = refs
+        self._peeled = {}
+
+    def allkeys(self):
+        return self._refs.keys()
+
+    def read_loose_ref(self, name):
+        return self._refs.get(name, None)
+
+    def get_packed_refs(self):
+        return {}
+
+    def set_symbolic_ref(self, name, other):
+        self._refs[name] = SYMREF + other
+
+    def set_if_equals(self, name, old_ref, new_ref):
+        if old_ref is not None and self._refs.get(name, None) != old_ref:
+            return False
+        realname, _ = self._follow(name)
+        self._check_refname(realname)
+        self._refs[realname] = new_ref
+        return True
+
+    def add_if_new(self, name, ref):
+        if name in self._refs:
+            return False
+        self._refs[name] = ref
+        return True
+
+    def remove_if_equals(self, name, old_ref):
+        if old_ref is not None and self._refs.get(name, None) != old_ref:
+            return False
+        del self._refs[name]
+        return True
+
+    def get_peeled(self, name):
+        return self._peeled.get(name)
+
+    def _update(self, refs):
+        """Update multiple refs; intended only for testing."""
+        # TODO(dborowitz): replace this with a public function that uses
+        # set_if_equal.
+        self._refs.update(refs)
+
+    def _update_peeled(self, peeled):
+        """Update cached peeled refs; intended only for testing."""
+        self._peeled.update(peeled)
+
+
+class InfoRefsContainer(RefsContainer):
+    """Refs container that reads refs from a info/refs file."""
+
+    def __init__(self, f):
+        self._refs = {}
+        self._peeled = {}
+        for l in f.readlines():
+            sha, name = l.rstrip("\n").split("\t")
+            if name.endswith("^{}"):
+                name = name[:-3]
+                if not check_ref_format(name):
+                    raise ValueError("invalid ref name '%s'" % name)
+                self._peeled[name] = sha
+            else:
+                if not check_ref_format(name):
+                    raise ValueError("invalid ref name '%s'" % name)
+                self._refs[name] = sha
+
+    def allkeys(self):
+        return self._refs.keys()
+
+    def read_loose_ref(self, name):
+        return self._refs.get(name, None)
+
+    def get_packed_refs(self):
+        return {}
+
+    def get_peeled(self, name):
+        try:
+            return self._peeled[name]
+        except KeyError:
+            return self._refs[name]
+
+
+class DiskRefsContainer(RefsContainer):
+    """Refs container that reads refs from disk."""
+
+    def __init__(self, path):
+        self.path = path
+        self._packed_refs = None
+        self._peeled_refs = None
+
+    def __repr__(self):
+        return "%s(%r)" % (self.__class__.__name__, self.path)
+
+    def subkeys(self, base):
+        keys = set()
+        path = self.refpath(base)
+        for root, dirs, files in os.walk(path):
+            dir = root[len(path):].strip(os.path.sep).replace(os.path.sep, "/")
+            for filename in files:
+                refname = ("%s/%s" % (dir, filename)).strip("/")
+                # check_ref_format requires at least one /, so we prepend the
+                # base before calling it.
+                if check_ref_format("%s/%s" % (base, refname)):
+                    keys.add(refname)
+        for key in self.get_packed_refs():
+            if key.startswith(base):
+                keys.add(key[len(base):].strip("/"))
+        return keys
+
+    def allkeys(self):
+        keys = set()
+        if os.path.exists(self.refpath("HEAD")):
+            keys.add("HEAD")
+        path = self.refpath("")
+        for root, dirs, files in os.walk(self.refpath("refs")):
+            dir = root[len(path):].strip(os.path.sep).replace(os.path.sep, "/")
+            for filename in files:
+                refname = ("%s/%s" % (dir, filename)).strip("/")
+                if check_ref_format(refname):
+                    keys.add(refname)
+        keys.update(self.get_packed_refs())
+        return keys
+
+    def refpath(self, name):
+        """Return the disk path of a ref.
+
+        """
+        if os.path.sep != "/":
+            name = name.replace("/", os.path.sep)
+        return os.path.join(self.path, name)
+
+    def get_packed_refs(self):
+        """Get contents of the packed-refs file.
+
+        :return: Dictionary mapping ref names to SHA1s
+
+        :note: Will return an empty dictionary when no packed-refs file is
+            present.
+        """
+        # TODO: invalidate the cache on repacking
+        if self._packed_refs is None:
+            # set both to empty because we want _peeled_refs to be
+            # None if and only if _packed_refs is also None.
+            self._packed_refs = {}
+            self._peeled_refs = {}
+            path = os.path.join(self.path, 'packed-refs')
+            try:
+                f = GitFile(path, 'rb')
+            except IOError, e:
+                if e.errno == errno.ENOENT:
+                    return {}
+                raise
+            try:
+                first_line = iter(f).next().rstrip()
+                if (first_line.startswith("# pack-refs") and " peeled" in
+                        first_line):
+                    for sha, name, peeled in read_packed_refs_with_peeled(f):
+                        self._packed_refs[name] = sha
+                        if peeled:
+                            self._peeled_refs[name] = peeled
+                else:
+                    f.seek(0)
+                    for sha, name in read_packed_refs(f):
+                        self._packed_refs[name] = sha
+            finally:
+                f.close()
+        return self._packed_refs
+
+    def get_peeled(self, name):
+        """Return the cached peeled value of a ref, if available.
+
+        :param name: Name of the ref to peel
+        :return: The peeled value of the ref. If the ref is known not point to a
+            tag, this will be the SHA the ref refers to. If the ref may point to
+            a tag, but no cached information is available, None is returned.
+        """
+        self.get_packed_refs()
+        if self._peeled_refs is None or name not in self._packed_refs:
+            # No cache: no peeled refs were read, or this ref is loose
+            return None
+        if name in self._peeled_refs:
+            return self._peeled_refs[name]
+        else:
+            # Known not peelable
+            return self[name]
+
+    def read_loose_ref(self, name):
+        """Read a reference file and return its contents.
+
+        If the reference file a symbolic reference, only read the first line of
+        the file. Otherwise, only read the first 40 bytes.
+
+        :param name: the refname to read, relative to refpath
+        :return: The contents of the ref file, or None if the file does not
+            exist.
+        :raises IOError: if any other error occurs
+        """
+        filename = self.refpath(name)
+        try:
+            f = GitFile(filename, 'rb')
+            try:
+                header = f.read(len(SYMREF))
+                if header == SYMREF:
+                    # Read only the first line
+                    return header + iter(f).next().rstrip("\r\n")
+                else:
+                    # Read only the first 40 bytes
+                    return header + f.read(40 - len(SYMREF))
+            finally:
+                f.close()
+        except IOError, e:
+            if e.errno == errno.ENOENT:
+                return None
+            raise
+
+    def _remove_packed_ref(self, name):
+        if self._packed_refs is None:
+            return
+        filename = os.path.join(self.path, 'packed-refs')
+        # reread cached refs from disk, while holding the lock
+        f = GitFile(filename, 'wb')
+        try:
+            self._packed_refs = None
+            self.get_packed_refs()
+
+            if name not in self._packed_refs:
+                return
+
+            del self._packed_refs[name]
+            if name in self._peeled_refs:
+                del self._peeled_refs[name]
+            write_packed_refs(f, self._packed_refs, self._peeled_refs)
+            f.close()
+        finally:
+            f.abort()
+
+    def set_symbolic_ref(self, name, other):
+        """Make a ref point at another ref.
+
+        :param name: Name of the ref to set
+        :param other: Name of the ref to point at
+        """
+        self._check_refname(name)
+        self._check_refname(other)
+        filename = self.refpath(name)
+        try:
+            f = GitFile(filename, 'wb')
+            try:
+                f.write(SYMREF + other + '\n')
+            except (IOError, OSError):
+                f.abort()
+                raise
+        finally:
+            f.close()
+
+    def set_if_equals(self, name, old_ref, new_ref):
+        """Set a refname to new_ref only if it currently equals old_ref.
+
+        This method follows all symbolic references, and can be used to perform
+        an atomic compare-and-swap operation.
+
+        :param name: The refname to set.
+        :param old_ref: The old sha the refname must refer to, or None to set
+            unconditionally.
+        :param new_ref: The new sha the refname will refer to.
+        :return: True if the set was successful, False otherwise.
+        """
+        self._check_refname(name)
+        try:
+            realname, _ = self._follow(name)
+        except KeyError:
+            realname = name
+        filename = self.refpath(realname)
+        ensure_dir_exists(os.path.dirname(filename))
+        f = GitFile(filename, 'wb')
+        try:
+            if old_ref is not None:
+                try:
+                    # read again while holding the lock
+                    orig_ref = self.read_loose_ref(realname)
+                    if orig_ref is None:
+                        orig_ref = self.get_packed_refs().get(realname, None)
+                    if orig_ref != old_ref:
+                        f.abort()
+                        return False
+                except (OSError, IOError):
+                    f.abort()
+                    raise
+            try:
+                f.write(new_ref + "\n")
+            except (OSError, IOError):
+                f.abort()
+                raise
+        finally:
+            f.close()
+        return True
+
+    def add_if_new(self, name, ref):
+        """Add a new reference only if it does not already exist.
+
+        This method follows symrefs, and only ensures that the last ref in the
+        chain does not exist.
+
+        :param name: The refname to set.
+        :param ref: The new sha the refname will refer to.
+        :return: True if the add was successful, False otherwise.
+        """
+        try:
+            realname, contents = self._follow(name)
+            if contents is not None:
+                return False
+        except KeyError:
+            realname = name
+        self._check_refname(realname)
+        filename = self.refpath(realname)
+        ensure_dir_exists(os.path.dirname(filename))
+        f = GitFile(filename, 'wb')
+        try:
+            if os.path.exists(filename) or name in self.get_packed_refs():
+                f.abort()
+                return False
+            try:
+                f.write(ref + "\n")
+            except (OSError, IOError):
+                f.abort()
+                raise
+        finally:
+            f.close()
+        return True
+
+    def remove_if_equals(self, name, old_ref):
+        """Remove a refname only if it currently equals old_ref.
+
+        This method does not follow symbolic references. It can be used to
+        perform an atomic compare-and-delete operation.
+
+        :param name: The refname to delete.
+        :param old_ref: The old sha the refname must refer to, or None to delete
+            unconditionally.
+        :return: True if the delete was successful, False otherwise.
+        """
+        self._check_refname(name)
+        filename = self.refpath(name)
+        ensure_dir_exists(os.path.dirname(filename))
+        f = GitFile(filename, 'wb')
+        try:
+            if old_ref is not None:
+                orig_ref = self.read_loose_ref(name)
+                if orig_ref is None:
+                    orig_ref = self.get_packed_refs().get(name, None)
+                if orig_ref != old_ref:
+                    return False
+            # may only be packed
+            try:
+                os.remove(filename)
+            except OSError, e:
+                if e.errno != errno.ENOENT:
+                    raise
+            self._remove_packed_ref(name)
+        finally:
+            # never write, we just wanted the lock
+            f.abort()
+        return True
+
+
+def _split_ref_line(line):
+    """Split a single ref line into a tuple of SHA1 and name."""
+    fields = line.rstrip("\n").split(" ")
+    if len(fields) != 2:
+        raise PackedRefsException("invalid ref line '%s'" % line)
+    sha, name = fields
+    try:
+        hex_to_sha(sha)
+    except (AssertionError, TypeError), e:
+        raise PackedRefsException(e)
+    if not check_ref_format(name):
+        raise PackedRefsException("invalid ref name '%s'" % name)
+    return (sha, name)
+
+
+def read_packed_refs(f):
+    """Read a packed refs file.
+
+    :param f: file-like object to read from
+    :return: Iterator over tuples with SHA1s and ref names.
+    """
+    for l in f:
+        if l[0] == "#":
+            # Comment
+            continue
+        if l[0] == "^":
+            raise PackedRefsException(
+              "found peeled ref in packed-refs without peeled")
+        yield _split_ref_line(l)
+
+
+def read_packed_refs_with_peeled(f):
+    """Read a packed refs file including peeled refs.
+
+    Assumes the "# pack-refs with: peeled" line was already read. Yields tuples
+    with ref names, SHA1s, and peeled SHA1s (or None).
+
+    :param f: file-like object to read from, seek'ed to the second line
+    """
+    last = None
+    for l in f:
+        if l[0] == "#":
+            continue
+        l = l.rstrip("\r\n")
+        if l[0] == "^":
+            if not last:
+                raise PackedRefsException("unexpected peeled ref line")
+            try:
+                hex_to_sha(l[1:])
+            except (AssertionError, TypeError), e:
+                raise PackedRefsException(e)
+            sha, name = _split_ref_line(last)
+            last = None
+            yield (sha, name, l[1:])
+        else:
+            if last:
+                sha, name = _split_ref_line(last)
+                yield (sha, name, None)
+            last = l
+    if last:
+        sha, name = _split_ref_line(last)
+        yield (sha, name, None)
+
+
+def write_packed_refs(f, packed_refs, peeled_refs=None):
+    """Write a packed refs file.
+
+    :param f: empty file-like object to write to
+    :param packed_refs: dict of refname to sha of packed refs to write
+    :param peeled_refs: dict of refname to peeled value of sha
+    """
+    if peeled_refs is None:
+        peeled_refs = {}
+    else:
+        f.write('# pack-refs with: peeled\n')
+    for refname in sorted(packed_refs.iterkeys()):
+        f.write('%s %s\n' % (packed_refs[refname], refname))
+        if refname in peeled_refs:
+            f.write('^%s\n' % peeled_refs[refname])
+
+
+def read_info_refs(f):
+    ret = {}
+    for l in f.readlines():
+        (sha, name) = l.rstrip("\r\n").split("\t", 1)
+        ret[name] = sha
+    return ret
+
+
+def write_info_refs(refs, store):
+    """Generate info refs."""
+    for name, sha in sorted(refs.items()):
+        # get_refs() includes HEAD as a special case, but we don't want to
+        # advertise it
+        if name == 'HEAD':
+            continue
+        try:
+            o = store[sha]
+        except KeyError:
+            continue
+        peeled = store.peel_sha(sha)
+        yield '%s\t%s\n' % (o.id, name)
+        if o.id != peeled.id:
+            yield '%s\t%s^{}\n' % (peeled.id, name)

+ 122 - 697
dulwich/repo.py

@@ -1,6 +1,6 @@
 # repo.py -- For dealing with git repositories.
 # Copyright (C) 2007 James Westby <jw+debian@jameswestby.net>
-# Copyright (C) 2008-2009 Jelmer Vernooij <jelmer@samba.org>
+# Copyright (C) 2008-2013 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
@@ -38,39 +38,50 @@ from dulwich.errors import (
     NotGitRepository,
     NotTreeError,
     NotTagError,
-    PackedRefsException,
     CommitError,
     RefFormatError,
     HookError,
     )
 from dulwich.file import (
-    ensure_dir_exists,
     GitFile,
     )
 from dulwich.object_store import (
     DiskObjectStore,
     MemoryObjectStore,
+    ObjectStoreGraphWalker,
     )
 from dulwich.objects import (
+    check_hexsha,
     Blob,
     Commit,
     ShaFile,
     Tag,
     Tree,
-    hex_to_sha,
     )
 
 from dulwich.hooks import (
     PreCommitShellHook,
     PostCommitShellHook,
     CommitMsgShellHook,
-)
+    )
+
+from dulwich.refs import (
+    check_ref_format,
+    RefsContainer,
+    DictRefsContainer,
+    InfoRefsContainer,
+    DiskRefsContainer,
+    read_packed_refs,
+    read_packed_refs_with_peeled,
+    write_packed_refs,
+    SYMREF,
+    )
+
 
 import warnings
 
 
 OBJECTDIR = 'objects'
-SYMREF = 'ref: '
 REFSDIR = 'refs'
 REFSDIR_TAGS = 'tags'
 REFSDIR_HEADS = 'heads'
@@ -86,710 +97,55 @@ BASE_DIRECTORIES = [
     ]
 
 
-def check_ref_format(refname):
-    """Check if a refname is correctly formatted.
-
-    Implements all the same rules as git-check-ref-format[1].
-
-    [1] http://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html
-
-    :param refname: The refname to check
-    :return: True if refname is valid, False otherwise
-    """
-    # These could be combined into one big expression, but are listed separately
-    # to parallel [1].
-    if '/.' in refname or refname.startswith('.'):
-        return False
-    if '/' not in refname:
-        return False
-    if '..' in refname:
-        return False
-    for c in refname:
-        if ord(c) < 040 or c in '\177 ~^:?*[':
-            return False
-    if refname[-1] in '/.':
-        return False
-    if refname.endswith('.lock'):
-        return False
-    if '@{' in refname:
-        return False
-    if '\\' in refname:
-        return False
-    return True
-
-
-class RefsContainer(object):
-    """A container for refs."""
-
-    def set_ref(self, name, other):
-        warnings.warn("RefsContainer.set_ref() is deprecated."
-            "Use set_symblic_ref instead.",
-            category=DeprecationWarning, stacklevel=2)
-        return self.set_symbolic_ref(name, other)
-
-    def set_symbolic_ref(self, name, other):
-        """Make a ref point at another ref.
-
-        :param name: Name of the ref to set
-        :param other: Name of the ref to point at
-        """
-        raise NotImplementedError(self.set_symbolic_ref)
-
-    def get_packed_refs(self):
-        """Get contents of the packed-refs file.
-
-        :return: Dictionary mapping ref names to SHA1s
-
-        :note: Will return an empty dictionary when no packed-refs file is
-            present.
-        """
-        raise NotImplementedError(self.get_packed_refs)
-
-    def get_peeled(self, name):
-        """Return the cached peeled value of a ref, if available.
-
-        :param name: Name of the ref to peel
-        :return: The peeled value of the ref. If the ref is known not point to a
-            tag, this will be the SHA the ref refers to. If the ref may point to
-            a tag, but no cached information is available, None is returned.
-        """
-        return None
-
-    def import_refs(self, base, other):
-        for name, value in other.iteritems():
-            self["%s/%s" % (base, name)] = value
-
-    def allkeys(self):
-        """All refs present in this container."""
-        raise NotImplementedError(self.allkeys)
-
-    def keys(self, base=None):
-        """Refs present in this container.
+def parse_graftpoints(graftpoints):
+    """Convert a list of graftpoints into a dict
 
-        :param base: An optional base to return refs under.
-        :return: An unsorted set of valid refs in this container, including
-            packed refs.
-        """
-        if base is not None:
-            return self.subkeys(base)
-        else:
-            return self.allkeys()
-
-    def subkeys(self, base):
-        """Refs present in this container under a base.
-
-        :param base: The base to return refs under.
-        :return: A set of valid refs in this container under the base; the base
-            prefix is stripped from the ref names returned.
-        """
-        keys = set()
-        base_len = len(base) + 1
-        for refname in self.allkeys():
-            if refname.startswith(base):
-                keys.add(refname[base_len:])
-        return keys
-
-    def as_dict(self, base=None):
-        """Return the contents of this container as a dictionary.
+    :param graftpoints: Iterator of graftpoint lines
 
-        """
-        ret = {}
-        keys = self.keys(base)
-        if base is None:
-            base = ""
-        for key in keys:
-            try:
-                ret[key] = self[("%s/%s" % (base, key)).strip("/")]
-            except KeyError:
-                continue  # Unable to resolve
-
-        return ret
-
-    def _check_refname(self, name):
-        """Ensure a refname is valid and lives in refs or is HEAD.
+    Each line is formatted as:
+        <commit sha1> <parent sha1> [<parent sha1>]*
 
-        HEAD is not a valid refname according to git-check-ref-format, but this
-        class needs to be able to touch HEAD. Also, check_ref_format expects
-        refnames without the leading 'refs/', but this class requires that
-        so it cannot touch anything outside the refs dir (or HEAD).
+    Resulting dictionary is:
+        <commit sha1>: [<parent sha1>*]
 
-        :param name: The name of the reference.
-        :raises KeyError: if a refname is not HEAD or is otherwise not valid.
-        """
-        if name in ('HEAD', 'refs/stash'):
-            return
-        if not name.startswith('refs/') or not check_ref_format(name[5:]):
-            raise RefFormatError(name)
-
-    def read_ref(self, refname):
-        """Read a reference without following any references.
-
-        :param refname: The name of the reference
-        :return: The contents of the ref file, or None if it does
-            not exist.
-        """
-        contents = self.read_loose_ref(refname)
-        if not contents:
-            contents = self.get_packed_refs().get(refname, None)
-        return contents
-
-    def read_loose_ref(self, name):
-        """Read a loose reference and return its contents.
-
-        :param name: the refname to read
-        :return: The contents of the ref file, or None if it does
-            not exist.
-        """
-        raise NotImplementedError(self.read_loose_ref)
-
-    def _follow(self, name):
-        """Follow a reference name.
-
-        :return: a tuple of (refname, sha), where refname is the name of the
-            last reference in the symbolic reference chain
-        """
-        contents = SYMREF + name
-        depth = 0
-        while contents.startswith(SYMREF):
-            refname = contents[len(SYMREF):]
-            contents = self.read_ref(refname)
-            if not contents:
-                break
-            depth += 1
-            if depth > 5:
-                raise KeyError(name)
-        return refname, contents
-
-    def __contains__(self, refname):
-        if self.read_ref(refname):
-            return True
-        return False
-
-    def __getitem__(self, name):
-        """Get the SHA1 for a reference name.
-
-        This method follows all symbolic references.
-        """
-        _, sha = self._follow(name)
-        if sha is None:
-            raise KeyError(name)
-        return sha
-
-    def set_if_equals(self, name, old_ref, new_ref):
-        """Set a refname to new_ref only if it currently equals old_ref.
-
-        This method follows all symbolic references if applicable for the
-        subclass, and can be used to perform an atomic compare-and-swap
-        operation.
-
-        :param name: The refname to set.
-        :param old_ref: The old sha the refname must refer to, or None to set
-            unconditionally.
-        :param new_ref: The new sha the refname will refer to.
-        :return: True if the set was successful, False otherwise.
-        """
-        raise NotImplementedError(self.set_if_equals)
-
-    def add_if_new(self, name, ref):
-        """Add a new reference only if it does not already exist."""
-        raise NotImplementedError(self.add_if_new)
-
-    def __setitem__(self, name, ref):
-        """Set a reference name to point to the given SHA1.
-
-        This method follows all symbolic references if applicable for the
-        subclass.
-
-        :note: This method unconditionally overwrites the contents of a
-            reference. To update atomically only if the reference has not
-            changed, use set_if_equals().
-        :param name: The refname to set.
-        :param ref: The new sha the refname will refer to.
-        """
-        self.set_if_equals(name, None, ref)
-
-    def remove_if_equals(self, name, old_ref):
-        """Remove a refname only if it currently equals old_ref.
-
-        This method does not follow symbolic references, even if applicable for
-        the subclass. It can be used to perform an atomic compare-and-delete
-        operation.
-
-        :param name: The refname to delete.
-        :param old_ref: The old sha the refname must refer to, or None to delete
-            unconditionally.
-        :return: True if the delete was successful, False otherwise.
-        """
-        raise NotImplementedError(self.remove_if_equals)
-
-    def __delitem__(self, name):
-        """Remove a refname.
-
-        This method does not follow symbolic references, even if applicable for
-        the subclass.
-
-        :note: This method unconditionally deletes the contents of a reference.
-            To delete atomically only if the reference has not changed, use
-            remove_if_equals().
-
-        :param name: The refname to delete.
-        """
-        self.remove_if_equals(name, None)
-
-
-class DictRefsContainer(RefsContainer):
-    """RefsContainer backed by a simple dict.
-
-    This container does not support symbolic or packed references and is not
-    threadsafe.
+    https://git.wiki.kernel.org/index.php/GraftPoint
     """
+    grafts = {}
+    for l in graftpoints:
+        raw_graft = l.split(None, 1)
 
-    def __init__(self, refs):
-        self._refs = refs
-        self._peeled = {}
-
-    def allkeys(self):
-        return self._refs.keys()
-
-    def read_loose_ref(self, name):
-        return self._refs.get(name, None)
-
-    def get_packed_refs(self):
-        return {}
-
-    def set_symbolic_ref(self, name, other):
-        self._refs[name] = SYMREF + other
-
-    def set_if_equals(self, name, old_ref, new_ref):
-        if old_ref is not None and self._refs.get(name, None) != old_ref:
-            return False
-        realname, _ = self._follow(name)
-        self._check_refname(realname)
-        self._refs[realname] = new_ref
-        return True
-
-    def add_if_new(self, name, ref):
-        if name in self._refs:
-            return False
-        self._refs[name] = ref
-        return True
-
-    def remove_if_equals(self, name, old_ref):
-        if old_ref is not None and self._refs.get(name, None) != old_ref:
-            return False
-        del self._refs[name]
-        return True
-
-    def get_peeled(self, name):
-        return self._peeled.get(name)
-
-    def _update(self, refs):
-        """Update multiple refs; intended only for testing."""
-        # TODO(dborowitz): replace this with a public function that uses
-        # set_if_equal.
-        self._refs.update(refs)
-
-    def _update_peeled(self, peeled):
-        """Update cached peeled refs; intended only for testing."""
-        self._peeled.update(peeled)
-
-
-class InfoRefsContainer(RefsContainer):
-    """Refs container that reads refs from a info/refs file."""
-
-    def __init__(self, f):
-        self._refs = {}
-        self._peeled = {}
-        for l in f.readlines():
-            sha, name = l.rstrip("\n").split("\t")
-            if name.endswith("^{}"):
-                name = name[:-3]
-                if not check_ref_format(name):
-                    raise ValueError("invalid ref name '%s'" % name)
-                self._peeled[name] = sha
-            else:
-                if not check_ref_format(name):
-                    raise ValueError("invalid ref name '%s'" % name)
-                self._refs[name] = sha
-
-    def allkeys(self):
-        return self._refs.keys()
-
-    def read_loose_ref(self, name):
-        return self._refs.get(name, None)
-
-    def get_packed_refs(self):
-        return {}
-
-    def get_peeled(self, name):
-        try:
-            return self._peeled[name]
-        except KeyError:
-            return self._refs[name]
-
-
-class DiskRefsContainer(RefsContainer):
-    """Refs container that reads refs from disk."""
-
-    def __init__(self, path):
-        self.path = path
-        self._packed_refs = None
-        self._peeled_refs = None
-
-    def __repr__(self):
-        return "%s(%r)" % (self.__class__.__name__, self.path)
-
-    def subkeys(self, base):
-        keys = set()
-        path = self.refpath(base)
-        for root, dirs, files in os.walk(path):
-            dir = root[len(path):].strip(os.path.sep).replace(os.path.sep, "/")
-            for filename in files:
-                refname = ("%s/%s" % (dir, filename)).strip("/")
-                # check_ref_format requires at least one /, so we prepend the
-                # base before calling it.
-                if check_ref_format("%s/%s" % (base, refname)):
-                    keys.add(refname)
-        for key in self.get_packed_refs():
-            if key.startswith(base):
-                keys.add(key[len(base):].strip("/"))
-        return keys
-
-    def allkeys(self):
-        keys = set()
-        if os.path.exists(self.refpath("HEAD")):
-            keys.add("HEAD")
-        path = self.refpath("")
-        for root, dirs, files in os.walk(self.refpath("refs")):
-            dir = root[len(path):].strip(os.path.sep).replace(os.path.sep, "/")
-            for filename in files:
-                refname = ("%s/%s" % (dir, filename)).strip("/")
-                if check_ref_format(refname):
-                    keys.add(refname)
-        keys.update(self.get_packed_refs())
-        return keys
-
-    def refpath(self, name):
-        """Return the disk path of a ref.
-
-        """
-        if os.path.sep != "/":
-            name = name.replace("/", os.path.sep)
-        return os.path.join(self.path, name)
-
-    def get_packed_refs(self):
-        """Get contents of the packed-refs file.
-
-        :return: Dictionary mapping ref names to SHA1s
-
-        :note: Will return an empty dictionary when no packed-refs file is
-            present.
-        """
-        # TODO: invalidate the cache on repacking
-        if self._packed_refs is None:
-            # set both to empty because we want _peeled_refs to be
-            # None if and only if _packed_refs is also None.
-            self._packed_refs = {}
-            self._peeled_refs = {}
-            path = os.path.join(self.path, 'packed-refs')
-            try:
-                f = GitFile(path, 'rb')
-            except IOError, e:
-                if e.errno == errno.ENOENT:
-                    return {}
-                raise
-            try:
-                first_line = iter(f).next().rstrip()
-                if (first_line.startswith("# pack-refs") and " peeled" in
-                        first_line):
-                    for sha, name, peeled in read_packed_refs_with_peeled(f):
-                        self._packed_refs[name] = sha
-                        if peeled:
-                            self._peeled_refs[name] = peeled
-                else:
-                    f.seek(0)
-                    for sha, name in read_packed_refs(f):
-                        self._packed_refs[name] = sha
-            finally:
-                f.close()
-        return self._packed_refs
-
-    def get_peeled(self, name):
-        """Return the cached peeled value of a ref, if available.
-
-        :param name: Name of the ref to peel
-        :return: The peeled value of the ref. If the ref is known not point to a
-            tag, this will be the SHA the ref refers to. If the ref may point to
-            a tag, but no cached information is available, None is returned.
-        """
-        self.get_packed_refs()
-        if self._peeled_refs is None or name not in self._packed_refs:
-            # No cache: no peeled refs were read, or this ref is loose
-            return None
-        if name in self._peeled_refs:
-            return self._peeled_refs[name]
+        commit = raw_graft[0]
+        if len(raw_graft) == 2:
+            parents = raw_graft[1].split()
         else:
-            # Known not peelable
-            return self[name]
-
-    def read_loose_ref(self, name):
-        """Read a reference file and return its contents.
-
-        If the reference file a symbolic reference, only read the first line of
-        the file. Otherwise, only read the first 40 bytes.
-
-        :param name: the refname to read, relative to refpath
-        :return: The contents of the ref file, or None if the file does not
-            exist.
-        :raises IOError: if any other error occurs
-        """
-        filename = self.refpath(name)
-        try:
-            f = GitFile(filename, 'rb')
-            try:
-                header = f.read(len(SYMREF))
-                if header == SYMREF:
-                    # Read only the first line
-                    return header + iter(f).next().rstrip("\r\n")
-                else:
-                    # Read only the first 40 bytes
-                    return header + f.read(40 - len(SYMREF))
-            finally:
-                f.close()
-        except IOError, e:
-            if e.errno == errno.ENOENT:
-                return None
-            raise
-
-    def _remove_packed_ref(self, name):
-        if self._packed_refs is None:
-            return
-        filename = os.path.join(self.path, 'packed-refs')
-        # reread cached refs from disk, while holding the lock
-        f = GitFile(filename, 'wb')
-        try:
-            self._packed_refs = None
-            self.get_packed_refs()
-
-            if name not in self._packed_refs:
-                return
-
-            del self._packed_refs[name]
-            if name in self._peeled_refs:
-                del self._peeled_refs[name]
-            write_packed_refs(f, self._packed_refs, self._peeled_refs)
-            f.close()
-        finally:
-            f.abort()
+            parents = []
 
-    def set_symbolic_ref(self, name, other):
-        """Make a ref point at another ref.
+        for sha in [commit] + parents:
+            check_hexsha(sha, 'Invalid graftpoint')
 
-        :param name: Name of the ref to set
-        :param other: Name of the ref to point at
-        """
-        self._check_refname(name)
-        self._check_refname(other)
-        filename = self.refpath(name)
-        try:
-            f = GitFile(filename, 'wb')
-            try:
-                f.write(SYMREF + other + '\n')
-            except (IOError, OSError):
-                f.abort()
-                raise
-        finally:
-            f.close()
+        grafts[commit] = parents
+    return grafts
 
-    def set_if_equals(self, name, old_ref, new_ref):
-        """Set a refname to new_ref only if it currently equals old_ref.
 
-        This method follows all symbolic references, and can be used to perform
-        an atomic compare-and-swap operation.
+def serialize_graftpoints(graftpoints):
+    """Convert a dictionary of grafts into string
 
-        :param name: The refname to set.
-        :param old_ref: The old sha the refname must refer to, or None to set
-            unconditionally.
-        :param new_ref: The new sha the refname will refer to.
-        :return: True if the set was successful, False otherwise.
-        """
-        self._check_refname(name)
-        try:
-            realname, _ = self._follow(name)
-        except KeyError:
-            realname = name
-        filename = self.refpath(realname)
-        ensure_dir_exists(os.path.dirname(filename))
-        f = GitFile(filename, 'wb')
-        try:
-            if old_ref is not None:
-                try:
-                    # read again while holding the lock
-                    orig_ref = self.read_loose_ref(realname)
-                    if orig_ref is None:
-                        orig_ref = self.get_packed_refs().get(realname, None)
-                    if orig_ref != old_ref:
-                        f.abort()
-                        return False
-                except (OSError, IOError):
-                    f.abort()
-                    raise
-            try:
-                f.write(new_ref + "\n")
-            except (OSError, IOError):
-                f.abort()
-                raise
-        finally:
-            f.close()
-        return True
+    The graft dictionary is:
+        <commit sha1>: [<parent sha1>*]
 
-    def add_if_new(self, name, ref):
-        """Add a new reference only if it does not already exist.
+    Each line is formatted as:
+        <commit sha1> <parent sha1> [<parent sha1>]*
 
-        This method follows symrefs, and only ensures that the last ref in the
-        chain does not exist.
+    https://git.wiki.kernel.org/index.php/GraftPoint
 
-        :param name: The refname to set.
-        :param ref: The new sha the refname will refer to.
-        :return: True if the add was successful, False otherwise.
-        """
-        try:
-            realname, contents = self._follow(name)
-            if contents is not None:
-                return False
-        except KeyError:
-            realname = name
-        self._check_refname(realname)
-        filename = self.refpath(realname)
-        ensure_dir_exists(os.path.dirname(filename))
-        f = GitFile(filename, 'wb')
-        try:
-            if os.path.exists(filename) or name in self.get_packed_refs():
-                f.abort()
-                return False
-            try:
-                f.write(ref + "\n")
-            except (OSError, IOError):
-                f.abort()
-                raise
-        finally:
-            f.close()
-        return True
-
-    def remove_if_equals(self, name, old_ref):
-        """Remove a refname only if it currently equals old_ref.
-
-        This method does not follow symbolic references. It can be used to
-        perform an atomic compare-and-delete operation.
-
-        :param name: The refname to delete.
-        :param old_ref: The old sha the refname must refer to, or None to delete
-            unconditionally.
-        :return: True if the delete was successful, False otherwise.
-        """
-        self._check_refname(name)
-        filename = self.refpath(name)
-        ensure_dir_exists(os.path.dirname(filename))
-        f = GitFile(filename, 'wb')
-        try:
-            if old_ref is not None:
-                orig_ref = self.read_loose_ref(name)
-                if orig_ref is None:
-                    orig_ref = self.get_packed_refs().get(name, None)
-                if orig_ref != old_ref:
-                    return False
-            # may only be packed
-            try:
-                os.remove(filename)
-            except OSError, e:
-                if e.errno != errno.ENOENT:
-                    raise
-            self._remove_packed_ref(name)
-        finally:
-            # never write, we just wanted the lock
-            f.abort()
-        return True
-
-
-def _split_ref_line(line):
-    """Split a single ref line into a tuple of SHA1 and name."""
-    fields = line.rstrip("\n").split(" ")
-    if len(fields) != 2:
-        raise PackedRefsException("invalid ref line '%s'" % line)
-    sha, name = fields
-    try:
-        hex_to_sha(sha)
-    except (AssertionError, TypeError), e:
-        raise PackedRefsException(e)
-    if not check_ref_format(name):
-        raise PackedRefsException("invalid ref name '%s'" % name)
-    return (sha, name)
-
-
-def read_packed_refs(f):
-    """Read a packed refs file.
-
-    :param f: file-like object to read from
-    :return: Iterator over tuples with SHA1s and ref names.
     """
-    for l in f:
-        if l[0] == "#":
-            # Comment
-            continue
-        if l[0] == "^":
-            raise PackedRefsException(
-              "found peeled ref in packed-refs without peeled")
-        yield _split_ref_line(l)
-
-
-def read_packed_refs_with_peeled(f):
-    """Read a packed refs file including peeled refs.
-
-    Assumes the "# pack-refs with: peeled" line was already read. Yields tuples
-    with ref names, SHA1s, and peeled SHA1s (or None).
-
-    :param f: file-like object to read from, seek'ed to the second line
-    """
-    last = None
-    for l in f:
-        if l[0] == "#":
-            continue
-        l = l.rstrip("\r\n")
-        if l[0] == "^":
-            if not last:
-                raise PackedRefsException("unexpected peeled ref line")
-            try:
-                hex_to_sha(l[1:])
-            except (AssertionError, TypeError), e:
-                raise PackedRefsException(e)
-            sha, name = _split_ref_line(last)
-            last = None
-            yield (sha, name, l[1:])
+    graft_lines = []
+    for commit, parents in graftpoints.iteritems():
+        if parents:
+            graft_lines.append('%s %s' % (commit, ' '.join(parents)))
         else:
-            if last:
-                sha, name = _split_ref_line(last)
-                yield (sha, name, None)
-            last = l
-    if last:
-        sha, name = _split_ref_line(last)
-        yield (sha, name, None)
-
-
-def write_packed_refs(f, packed_refs, peeled_refs=None):
-    """Write a packed refs file.
-
-    :param f: empty file-like object to write to
-    :param packed_refs: dict of refname to sha of packed refs to write
-    :param peeled_refs: dict of refname to peeled value of sha
-    """
-    if peeled_refs is None:
-        peeled_refs = {}
-    else:
-        f.write('# pack-refs with: peeled\n')
-    for refname in sorted(packed_refs.iterkeys()):
-        f.write('%s %s\n' % (packed_refs[refname], refname))
-        if refname in peeled_refs:
-            f.write('^%s\n' % peeled_refs[refname])
+            graft_lines.append(commit)
+    return '\n'.join(graft_lines)
 
 
 class BaseRepo(object):
@@ -813,6 +169,7 @@ class BaseRepo(object):
         self.object_store = object_store
         self.refs = refs
 
+        self._graftpoints = {}
         self.hooks = {}
 
     def _init_files(self, bare):
@@ -864,9 +221,10 @@ class BaseRepo(object):
         :param determine_wants: Optional function to determine what refs to
             fetch.
         :param progress: Optional progress function
+        :return: The local refs
         """
         if determine_wants is None:
-            determine_wants = lambda heads: heads.values()
+            determine_wants = target.object_store.determine_wants_all
         target.object_store.add_objects(
           self.fetch_objects(determine_wants, target.get_graph_walker(),
                              progress))
@@ -910,7 +268,7 @@ class BaseRepo(object):
         """
         if heads is None:
             heads = self.refs.as_dict('refs/heads').values()
-        return self.object_store.get_graph_walker(heads)
+        return ObjectStoreGraphWalker(heads, self.get_parents)
 
     def ref(self, name):
         """Return the SHA1 a ref is pointing to.
@@ -919,6 +277,9 @@ class BaseRepo(object):
         :raise KeyError: when the ref (or the one it points to) does not exist
         :return: SHA1 it is pointing at
         """
+        warnings.warn(
+            "Repo.ref(name) is deprecated. Use Repo.refs[name] instead.",
+            category=DeprecationWarning, stacklevel=2)
         return self.refs[name]
 
     def get_refs(self):
@@ -958,13 +319,23 @@ class BaseRepo(object):
         """
         return self.object_store[sha]
 
-    def get_parents(self, sha):
+    def get_parents(self, sha, commit=None):
         """Retrieve the parents of a specific commit.
 
+        If the specific commit is a graftpoint, the graft parents
+        will be returned instead.
+
         :param sha: SHA of the commit for which to retrieve the parents
+        :param commit: Optional commit matching the sha
         :return: List of parents
         """
-        return self.commit(sha).parents
+
+        try:
+            return self._graftpoints[sha]
+        except KeyError:
+            if commit is None:
+                commit = self[sha]
+            return commit.parents
 
     def get_config(self):
         """Retrieve the config object.
@@ -981,6 +352,13 @@ class BaseRepo(object):
         """
         raise NotImplementedError(self.get_description)
 
+    def set_description(self, description):
+        """Set the description for this repository.
+
+        :param description: Text to set as description for this repository.
+        """
+        raise NotImplementedError(self.set_description)
+
     def get_config_stack(self):
         """Return a config stack for this repository.
 
@@ -1085,6 +463,9 @@ class BaseRepo(object):
             include = [self.head()]
         if isinstance(include, str):
             include = [include]
+
+        kwargs['get_parents'] = lambda commit: self.get_parents(commit.id, commit)
+
         return Walker(self.object_store, include, *args, **kwargs)
 
     def revision_history(self, head):
@@ -1162,6 +543,27 @@ class BaseRepo(object):
             config.get(("user", ), "name"),
             config.get(("user", ), "email"))
 
+    def _add_graftpoints(self, updated_graftpoints):
+        """Add or modify graftpoints
+
+        :param updated_graftpoints: Dict of commit shas to list of parent shas
+        """
+
+        # Simple validation
+        for commit, parents in updated_graftpoints.iteritems():
+            for sha in [commit] + parents:
+                check_hexsha(sha, 'Invalid graftpoint')
+
+        self._graftpoints.update(updated_graftpoints)
+
+    def _remove_graftpoints(self, to_remove=[]):
+        """Remove graftpoints
+
+        :param to_remove: List of commit shas
+        """
+        for sha in to_remove:
+            del self._graftpoints[sha]
+
     def do_commit(self, message=None, committer=None,
                   author=None, commit_timestamp=None,
                   commit_timezone=None, author_timestamp=None,
@@ -1205,9 +607,12 @@ class BaseRepo(object):
             # FIXME: Read merge heads from .git/MERGE_HEADS
             merge_heads = []
         if committer is None:
+            # FIXME: Support GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL environment
+            # variables
             committer = self._get_user_identity()
         c.committer = committer
         if commit_timestamp is None:
+            # FIXME: Support GIT_COMMITTER_DATE environment variable
             commit_timestamp = time.time()
         c.commit_time = int(commit_timestamp)
         if commit_timezone is None:
@@ -1215,9 +620,12 @@ class BaseRepo(object):
             commit_timezone = 0
         c.commit_timezone = commit_timezone
         if author is None:
+            # FIXME: Support GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL environment
+            # variables
             author = committer
         c.author = author
         if author_timestamp is None:
+            # FIXME: Support GIT_AUTHOR_DATE environment variable
             author_timestamp = commit_timestamp
         c.author_time = int(author_timestamp)
         if author_timezone is None:
@@ -1298,6 +706,10 @@ class Repo(BaseRepo):
         refs = DiskRefsContainer(self.controldir())
         BaseRepo.__init__(self, object_store, refs)
 
+        graft_file = self.get_named_file(os.path.join("info", "grafts"))
+        if graft_file:
+            self._graftpoints = parse_graftpoints(graft_file)
+
         self.hooks['pre-commit'] = PreCommitShellHook(self.controldir())
         self.hooks['commit-msg'] = CommitMsgShellHook(self.controldir())
         self.hooks['post-commit'] = PostCommitShellHook(self.controldir())
@@ -1473,6 +885,19 @@ class Repo(BaseRepo):
     def __repr__(self):
         return "<Repo at %r>" % self.path
 
+    def set_description(self, description):
+        """Set the description for this repository.
+
+        :param description: Text to set as description for this repository.
+        """
+
+        path = os.path.join(self._controldir, 'description')
+        f = open(path, 'w')
+        try:
+            f.write(description)
+        finally:
+            f.close()
+
     @classmethod
     def _init_maybe_bare(cls, path, bare):
         for d in BASE_DIRECTORIES:

+ 7 - 15
dulwich/server.py

@@ -1,5 +1,6 @@
 # server.py -- Implementation of the server side git protocols
 # Copyright (C) 2008 John Carr <john.carr@unrouted.co.uk>
+# Coprygith (C) 2011-2012 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
@@ -76,6 +77,9 @@ from dulwich.protocol import (
     extract_capabilities,
     extract_want_line_capabilities,
     )
+from dulwich.refs import (
+    write_info_refs,
+    )
 from dulwich.repo import (
     Repo,
     )
@@ -100,8 +104,8 @@ class Backend(object):
 class BackendRepo(object):
     """Repository abstraction used by the Git server.
 
-    Please note that the methods required here are a
-    subset of those provided by dulwich.repo.Repo.
+    The methods required here are a subset of those provided by
+    dulwich.repo.Repo.
     """
 
     object_store = None
@@ -828,19 +832,7 @@ def serve_command(handler_cls, argv=sys.argv, backend=None, inf=sys.stdin,
 def generate_info_refs(repo):
     """Generate an info refs file."""
     refs = repo.get_refs()
-    for name in sorted(refs.iterkeys()):
-        # get_refs() includes HEAD as a special case, but we don't want to
-        # advertise it
-        if name == 'HEAD':
-            continue
-        sha = refs[name]
-        o = repo.object_store[sha]
-        if not o:
-            continue
-        yield '%s\t%s\n' % (sha, name)
-        peeled_sha = repo.get_peeled(name)
-        if peeled_sha != sha:
-            yield '%s\t%s^{}\n' % (peeled_sha, name)
+    return write_info_refs(repo.get_refs(), repo.object_store)
 
 
 def generate_objects_info_packs(repo):

+ 19 - 0
dulwich/stdint.h

@@ -0,0 +1,19 @@
+/**
+ * Replacement of gcc' stdint.h for MSVC
+ */
+
+#ifndef STDINT_H
+#define STDINT_H
+
+typedef signed char       int8_t;
+typedef signed short      int16_t;
+typedef signed int        int32_t;
+typedef signed long long  int64_t;
+
+typedef unsigned char       uint8_t;
+typedef unsigned short      uint16_t;
+typedef unsigned int        uint32_t;
+typedef unsigned long long  uint64_t;
+
+
+#endif

+ 2 - 0
dulwich/tests/__init__.py

@@ -117,6 +117,7 @@ def self_test_suite():
         'diff_tree',
         'fastexport',
         'file',
+        'grafts',
         'hooks',
         'index',
         'lru_cache',
@@ -125,6 +126,7 @@ def self_test_suite():
         'missing_obj_finder',
         'pack',
         'patch',
+        'porcelain',
         'protocol',
         'repository',
         'server',

+ 208 - 111
dulwich/tests/test_client.py

@@ -22,6 +22,7 @@ from dulwich import (
     client,
     )
 from dulwich.client import (
+    LocalGitClient,
     TraditionalGitClient,
     TCPGitClient,
     SubprocessGitClient,
@@ -31,6 +32,7 @@ from dulwich.client import (
     SendPackError,
     UpdateRefsError,
     get_transport_and_path,
+    get_transport_and_path_from_url,
     )
 from dulwich.tests import (
     TestCase,
@@ -46,6 +48,10 @@ from dulwich.objects import (
     Commit,
     Tree
     )
+from dulwich.repo import MemoryRepo
+from dulwich.tests.utils import (
+    open_repo,
+    )
 
 
 class DummyClient(TraditionalGitClient):
@@ -100,117 +106,6 @@ class GitClientTests(TestCase):
         self.client.fetch_pack('bla', lambda heads: [], None, None, None)
         self.assertEqual(self.rout.getvalue(), '0000')
 
-    def test_get_transport_and_path_tcp(self):
-        client, path = get_transport_and_path('git://foo.com/bar/baz')
-        self.assertTrue(isinstance(client, TCPGitClient))
-        self.assertEqual('foo.com', client._host)
-        self.assertEqual(TCP_GIT_PORT, client._port)
-        self.assertEqual('/bar/baz', path)
-
-    def test_get_transport_and_path_tcp_port(self):
-        client, path = get_transport_and_path('git://foo.com:1234/bar/baz')
-        self.assertTrue(isinstance(client, TCPGitClient))
-        self.assertEqual('foo.com', client._host)
-        self.assertEqual(1234, client._port)
-        self.assertEqual('/bar/baz', path)
-
-    def test_get_transport_and_path_ssh_explicit(self):
-        client, path = get_transport_and_path('git+ssh://foo.com/bar/baz')
-        self.assertTrue(isinstance(client, SSHGitClient))
-        self.assertEqual('foo.com', client.host)
-        self.assertEqual(None, client.port)
-        self.assertEqual(None, client.username)
-        self.assertEqual('bar/baz', path)
-
-    def test_get_transport_and_path_ssh_port_explicit(self):
-        client, path = get_transport_and_path(
-            'git+ssh://foo.com:1234/bar/baz')
-        self.assertTrue(isinstance(client, SSHGitClient))
-        self.assertEqual('foo.com', client.host)
-        self.assertEqual(1234, client.port)
-        self.assertEqual('bar/baz', path)
-
-    def test_get_transport_and_path_ssh_abspath_explicit(self):
-        client, path = get_transport_and_path('git+ssh://foo.com//bar/baz')
-        self.assertTrue(isinstance(client, SSHGitClient))
-        self.assertEqual('foo.com', client.host)
-        self.assertEqual(None, client.port)
-        self.assertEqual(None, client.username)
-        self.assertEqual('/bar/baz', path)
-
-    def test_get_transport_and_path_ssh_port_abspath_explicit(self):
-        client, path = get_transport_and_path(
-            'git+ssh://foo.com:1234//bar/baz')
-        self.assertTrue(isinstance(client, SSHGitClient))
-        self.assertEqual('foo.com', client.host)
-        self.assertEqual(1234, client.port)
-        self.assertEqual('/bar/baz', path)
-
-    def test_get_transport_and_path_ssh_implicit(self):
-        client, path = get_transport_and_path('foo:/bar/baz')
-        self.assertTrue(isinstance(client, SSHGitClient))
-        self.assertEqual('foo', client.host)
-        self.assertEqual(None, client.port)
-        self.assertEqual(None, client.username)
-        self.assertEqual('/bar/baz', path)
-
-    def test_get_transport_and_path_ssh_host(self):
-        client, path = get_transport_and_path('foo.com:/bar/baz')
-        self.assertTrue(isinstance(client, SSHGitClient))
-        self.assertEqual('foo.com', client.host)
-        self.assertEqual(None, client.port)
-        self.assertEqual(None, client.username)
-        self.assertEqual('/bar/baz', path)
-
-    def test_get_transport_and_path_ssh_user_host(self):
-        client, path = get_transport_and_path('user@foo.com:/bar/baz')
-        self.assertTrue(isinstance(client, SSHGitClient))
-        self.assertEqual('foo.com', client.host)
-        self.assertEqual(None, client.port)
-        self.assertEqual('user', client.username)
-        self.assertEqual('/bar/baz', path)
-
-    def test_get_transport_and_path_ssh_relpath(self):
-        client, path = get_transport_and_path('foo:bar/baz')
-        self.assertTrue(isinstance(client, SSHGitClient))
-        self.assertEqual('foo', client.host)
-        self.assertEqual(None, client.port)
-        self.assertEqual(None, client.username)
-        self.assertEqual('bar/baz', path)
-
-    def test_get_transport_and_path_ssh_host_relpath(self):
-        client, path = get_transport_and_path('foo.com:bar/baz')
-        self.assertTrue(isinstance(client, SSHGitClient))
-        self.assertEqual('foo.com', client.host)
-        self.assertEqual(None, client.port)
-        self.assertEqual(None, client.username)
-        self.assertEqual('bar/baz', path)
-
-    def test_get_transport_and_path_ssh_user_host_relpath(self):
-        client, path = get_transport_and_path('user@foo.com:bar/baz')
-        self.assertTrue(isinstance(client, SSHGitClient))
-        self.assertEqual('foo.com', client.host)
-        self.assertEqual(None, client.port)
-        self.assertEqual('user', client.username)
-        self.assertEqual('bar/baz', path)
-
-    def test_get_transport_and_path_subprocess(self):
-        client, path = get_transport_and_path('foo.bar/baz')
-        self.assertTrue(isinstance(client, SubprocessGitClient))
-        self.assertEqual('foo.bar/baz', path)
-
-    def test_get_transport_and_path_error(self):
-        # Need to use a known urlparse.uses_netloc URL scheme to get the
-        # expected parsing of the URL on Python versions less than 2.6.5
-        self.assertRaises(ValueError, get_transport_and_path,
-        'prospero://bar/baz')
-
-    def test_get_transport_and_path_http(self):
-        url = 'https://github.com/jelmer/dulwich'
-        client, path = get_transport_and_path(url)
-        self.assertTrue(isinstance(client, HttpGitClient))
-        self.assertEqual('/jelmer/dulwich', path)
-
     def test_send_pack_no_sideband64k_with_update_ref_error(self):
         # No side-bank-64k reported by server shouldn't try to parse
         # side band data
@@ -378,6 +273,198 @@ class GitClientTests(TestCase):
         self.assertEqual(self.rout.getvalue(), '0000')
 
 
+class TestGetTransportAndPath(TestCase):
+
+    def test_tcp(self):
+        c, path = get_transport_and_path('git://foo.com/bar/baz')
+        self.assertTrue(isinstance(c, TCPGitClient))
+        self.assertEqual('foo.com', c._host)
+        self.assertEqual(TCP_GIT_PORT, c._port)
+        self.assertEqual('/bar/baz', path)
+
+    def test_tcp_port(self):
+        c, path = get_transport_and_path('git://foo.com:1234/bar/baz')
+        self.assertTrue(isinstance(c, TCPGitClient))
+        self.assertEqual('foo.com', c._host)
+        self.assertEqual(1234, c._port)
+        self.assertEqual('/bar/baz', path)
+
+    def test_ssh_explicit(self):
+        c, path = get_transport_and_path('git+ssh://foo.com/bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(None, c.port)
+        self.assertEqual(None, c.username)
+        self.assertEqual('bar/baz', path)
+
+    def test_ssh_port_explicit(self):
+        c, path = get_transport_and_path(
+            'git+ssh://foo.com:1234/bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(1234, c.port)
+        self.assertEqual('bar/baz', path)
+
+    def test_ssh_abspath_explicit(self):
+        c, path = get_transport_and_path('git+ssh://foo.com//bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(None, c.port)
+        self.assertEqual(None, c.username)
+        self.assertEqual('/bar/baz', path)
+
+    def test_ssh_port_abspath_explicit(self):
+        c, path = get_transport_and_path(
+            'git+ssh://foo.com:1234//bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(1234, c.port)
+        self.assertEqual('/bar/baz', path)
+
+    def test_ssh_implicit(self):
+        c, path = get_transport_and_path('foo:/bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo', c.host)
+        self.assertEqual(None, c.port)
+        self.assertEqual(None, c.username)
+        self.assertEqual('/bar/baz', path)
+
+    def test_ssh_host(self):
+        c, path = get_transport_and_path('foo.com:/bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(None, c.port)
+        self.assertEqual(None, c.username)
+        self.assertEqual('/bar/baz', path)
+
+    def test_ssh_user_host(self):
+        c, path = get_transport_and_path('user@foo.com:/bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(None, c.port)
+        self.assertEqual('user', c.username)
+        self.assertEqual('/bar/baz', path)
+
+    def test_ssh_relpath(self):
+        c, path = get_transport_and_path('foo:bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo', c.host)
+        self.assertEqual(None, c.port)
+        self.assertEqual(None, c.username)
+        self.assertEqual('bar/baz', path)
+
+    def test_ssh_host_relpath(self):
+        c, path = get_transport_and_path('foo.com:bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(None, c.port)
+        self.assertEqual(None, c.username)
+        self.assertEqual('bar/baz', path)
+
+    def test_ssh_user_host_relpath(self):
+        c, path = get_transport_and_path('user@foo.com:bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(None, c.port)
+        self.assertEqual('user', c.username)
+        self.assertEqual('bar/baz', path)
+
+    def test_subprocess(self):
+        c, path = get_transport_and_path('foo.bar/baz')
+        self.assertTrue(isinstance(c, SubprocessGitClient))
+        self.assertEqual('foo.bar/baz', path)
+
+    def test_error(self):
+        # Need to use a known urlparse.uses_netloc URL scheme to get the
+        # expected parsing of the URL on Python versions less than 2.6.5
+        c, path = get_transport_and_path('prospero://bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+
+    def test_http(self):
+        url = 'https://github.com/jelmer/dulwich'
+        c, path = get_transport_and_path(url)
+        self.assertTrue(isinstance(c, HttpGitClient))
+        self.assertEqual('/jelmer/dulwich', path)
+
+
+class TestGetTransportAndPathFromUrl(TestCase):
+
+    def test_tcp(self):
+        c, path = get_transport_and_path_from_url('git://foo.com/bar/baz')
+        self.assertTrue(isinstance(c, TCPGitClient))
+        self.assertEqual('foo.com', c._host)
+        self.assertEqual(TCP_GIT_PORT, c._port)
+        self.assertEqual('/bar/baz', path)
+
+    def test_tcp_port(self):
+        c, path = get_transport_and_path_from_url('git://foo.com:1234/bar/baz')
+        self.assertTrue(isinstance(c, TCPGitClient))
+        self.assertEqual('foo.com', c._host)
+        self.assertEqual(1234, c._port)
+        self.assertEqual('/bar/baz', path)
+
+    def test_ssh_explicit(self):
+        c, path = get_transport_and_path_from_url('git+ssh://foo.com/bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(None, c.port)
+        self.assertEqual(None, c.username)
+        self.assertEqual('bar/baz', path)
+
+    def test_ssh_port_explicit(self):
+        c, path = get_transport_and_path_from_url(
+            'git+ssh://foo.com:1234/bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(1234, c.port)
+        self.assertEqual('bar/baz', path)
+
+    def test_ssh_abspath_explicit(self):
+        c, path = get_transport_and_path_from_url('git+ssh://foo.com//bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(None, c.port)
+        self.assertEqual(None, c.username)
+        self.assertEqual('/bar/baz', path)
+
+    def test_ssh_port_abspath_explicit(self):
+        c, path = get_transport_and_path_from_url(
+            'git+ssh://foo.com:1234//bar/baz')
+        self.assertTrue(isinstance(c, SSHGitClient))
+        self.assertEqual('foo.com', c.host)
+        self.assertEqual(1234, c.port)
+        self.assertEqual('/bar/baz', path)
+
+    def test_ssh_host_relpath(self):
+        self.assertRaises(ValueError, get_transport_and_path_from_url,
+            'foo.com:bar/baz')
+
+    def test_ssh_user_host_relpath(self):
+        self.assertRaises(ValueError, get_transport_and_path_from_url,
+            'user@foo.com:bar/baz')
+
+    def test_local_path(self):
+        self.assertRaises(ValueError, get_transport_and_path_from_url,
+            'foo.bar/baz')
+
+    def test_error(self):
+        # Need to use a known urlparse.uses_netloc URL scheme to get the
+        # expected parsing of the URL on Python versions less than 2.6.5
+        self.assertRaises(ValueError, get_transport_and_path_from_url,
+            'prospero://bar/baz')
+
+    def test_http(self):
+        url = 'https://github.com/jelmer/dulwich'
+        c, path = get_transport_and_path_from_url(url)
+        self.assertTrue(isinstance(c, HttpGitClient))
+        self.assertEqual('/jelmer/dulwich', path)
+
+    def test_file(self):
+        c, path = get_transport_and_path_from_url('file:///home/jelmer/foo')
+        self.assertTrue(isinstance(c, SubprocessGitClient))
+        self.assertEqual('/home/jelmer/foo', path)
+
+
 class TestSSHVendor(object):
 
     def __init__(self):
@@ -440,6 +527,7 @@ class SSHGitClientTests(TestCase):
         self.assertEquals(["git-relative-command '~/path/to/repo'"],
                           server.command)
 
+
 class ReportStatusParserTests(TestCase):
 
     def test_invalid_pack(self):
@@ -462,3 +550,12 @@ class ReportStatusParserTests(TestCase):
         parser.handle_packet("ok refs/foo/bar")
         parser.handle_packet(None)
         parser.check()
+
+
+class LocalGitClientTests(TestCase):
+
+    def test_fetch_into_empty(self):
+        c = LocalGitClient()
+        t = MemoryRepo()
+        s = open_repo('a.git')
+        self.assertEquals(s.get_refs(), c.fetch(s.path, t))

+ 25 - 0
dulwich/tests/test_config.py

@@ -22,6 +22,7 @@ from cStringIO import StringIO
 from dulwich.config import (
     ConfigDict,
     ConfigFile,
+    OrderedDict,
     StackedConfig,
     _check_section_name,
     _check_variable_name,
@@ -181,6 +182,30 @@ class ConfigDictTests(TestCase):
         cd['a'] = 'b'
         self.assertEqual(cd['a'], 'b')
 
+    def test_iteritems(self):
+        cd = ConfigDict()
+        cd.set(("core", ), "foo", "bla")
+        cd.set(("core2", ), "foo", "bloe")
+
+        self.assertEqual(
+            [('foo', 'bla')],
+            list(cd.iteritems(("core", ))))
+
+    def test_iteritems_nonexistant(self):
+        cd = ConfigDict()
+        cd.set(("core2", ), "foo", "bloe")
+
+        self.assertEqual([],
+            list(cd.iteritems(("core", ))))
+
+    def test_itersections(self):
+        cd = ConfigDict()
+        cd.set(("core2", ), "foo", "bloe")
+
+        self.assertEqual([("core2", )],
+            list(cd.itersections()))
+
+
 
 class StackedConfigTests(TestCase):
 

+ 206 - 0
dulwich/tests/test_grafts.py

@@ -0,0 +1,206 @@
+# test_grafts.py -- Tests for graftpoints
+#
+# 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 graftpoints."""
+
+import os
+import tempfile
+import shutil
+
+from dulwich.errors import ObjectFormatException
+from dulwich.tests import TestCase
+from dulwich.objects import (
+    Tree,
+    )
+from dulwich.repo import (
+    parse_graftpoints,
+    serialize_graftpoints,
+    MemoryRepo,
+    Repo,
+)
+
+
+def makesha(digit):
+    return (str(digit) * 40)[:40]
+
+
+class GraftParserTests(TestCase):
+
+    def assertParse(self, expected, graftpoints):
+        self.assertEqual(expected, parse_graftpoints(iter(graftpoints)))
+
+    def test_no_grafts(self):
+        self.assertParse({}, [])
+
+    def test_no_parents(self):
+        self.assertParse({makesha(0): []}, [makesha(0)])
+
+    def test_parents(self):
+        self.assertParse({makesha(0): [makesha(1), makesha(2)]},
+                         [' '.join([makesha(0), makesha(1), makesha(2)])])
+
+    def test_multiple_hybrid(self):
+        self.assertParse(
+            {makesha(0): [],
+             makesha(1): [makesha(2)],
+             makesha(3): [makesha(4), makesha(5)]},
+            [makesha(0),
+             ' '.join([makesha(1), makesha(2)]),
+             ' '.join([makesha(3), makesha(4), makesha(5)])])
+
+
+class GraftSerializerTests(TestCase):
+
+    def assertSerialize(self, expected, graftpoints):
+        self.assertEqual(
+            sorted(expected),
+            sorted(serialize_graftpoints(graftpoints)))
+
+    def test_no_grafts(self):
+        self.assertSerialize('', {})
+
+    def test_no_parents(self):
+        self.assertSerialize(makesha(0), {makesha(0): []})
+
+    def test_parents(self):
+        self.assertSerialize(' '.join([makesha(0), makesha(1), makesha(2)]),
+                             {makesha(0): [makesha(1), makesha(2)]})
+
+    def test_multiple_hybrid(self):
+        self.assertSerialize(
+            '\n'.join([
+                makesha(0),
+                ' '.join([makesha(1), makesha(2)]),
+                ' '.join([makesha(3), makesha(4), makesha(5)])]),
+            {makesha(0): [],
+             makesha(1): [makesha(2)],
+             makesha(3): [makesha(4), makesha(5)]})
+
+
+class GraftsInRepositoryBase(object):
+
+    def tearDown(self):
+        super(GraftsInRepositoryBase, self).tearDown()
+
+    def get_repo_with_grafts(self, grafts):
+        r = self._repo
+        r._add_graftpoints(grafts)
+        return r
+
+    def test_no_grafts(self):
+        r = self.get_repo_with_grafts({})
+
+        shas = [e.commit.id for e in r.get_walker()]
+        self.assertEqual(shas, self._shas[::-1])
+
+    def test_no_parents_graft(self):
+        r = self.get_repo_with_grafts({self._repo.head(): []})
+
+        self.assertEqual([e.commit.id for e in r.get_walker()],
+                         [r.head()])
+
+    def test_existing_parent_graft(self):
+        r = self.get_repo_with_grafts({self._shas[-1]: [self._shas[0]]})
+
+        self.assertEqual([e.commit.id for e in r.get_walker()],
+                         [self._shas[-1], self._shas[0]])
+
+    def test_remove_graft(self):
+        r = self.get_repo_with_grafts({self._repo.head(): []})
+        r._remove_graftpoints([self._repo.head()])
+
+        self.assertEqual([e.commit.id for e in r.get_walker()],
+                         self._shas[::-1])
+
+    def test_object_store_fail_invalid_parents(self):
+        r = self._repo
+
+        self.assertRaises(
+            ObjectFormatException,
+            r._add_graftpoints,
+            {self._shas[-1]: ['1']})
+
+
+class GraftsInRepoTests(GraftsInRepositoryBase, TestCase):
+
+    def setUp(self):
+        super(GraftsInRepoTests, self).setUp()
+        self._repo_dir = os.path.join(tempfile.mkdtemp())
+        r = self._repo = Repo.init(self._repo_dir)
+        self.addCleanup(shutil.rmtree, self._repo_dir)
+
+        self._shas = []
+
+        commit_kwargs = {
+            'committer': 'Test Committer <test@nodomain.com>',
+            'author': 'Test Author <test@nodomain.com>',
+            'commit_timestamp': 12395,
+            'commit_timezone': 0,
+            'author_timestamp': 12395,
+            'author_timezone': 0,
+        }
+
+        self._shas.append(r.do_commit(
+            'empty commit', **commit_kwargs))
+        self._shas.append(r.do_commit(
+            'empty commit', **commit_kwargs))
+        self._shas.append(r.do_commit(
+            'empty commit', **commit_kwargs))
+
+    def test_init_with_empty_info_grafts(self):
+        r = self._repo
+        r._put_named_file(os.path.join('info', 'grafts'), '')
+
+        r = Repo(self._repo_dir)
+        self.assertEqual({}, r._graftpoints)
+
+    def test_init_with_info_grafts(self):
+        r = self._repo
+        r._put_named_file(
+            os.path.join('info', 'grafts'),
+            "%s %s" % (self._shas[-1], self._shas[0]))
+
+        r = Repo(self._repo_dir)
+        self.assertEqual({self._shas[-1]: [self._shas[0]]}, r._graftpoints)
+
+
+class GraftsInMemoryRepoTests(GraftsInRepositoryBase, TestCase):
+
+    def setUp(self):
+        super(GraftsInMemoryRepoTests, self).setUp()
+        r = self._repo = MemoryRepo()
+
+        self._shas = []
+
+        tree = Tree()
+
+        commit_kwargs = {
+            'committer': 'Test Committer <test@nodomain.com>',
+            'author': 'Test Author <test@nodomain.com>',
+            'commit_timestamp': 12395,
+            'commit_timezone': 0,
+            'author_timestamp': 12395,
+            'author_timezone': 0,
+            'tree': tree.id
+        }
+
+        self._shas.append(r.do_commit(
+            'empty commit', **commit_kwargs))
+        self._shas.append(r.do_commit(
+            'empty commit', **commit_kwargs))
+        self._shas.append(r.do_commit(
+            'empty commit', **commit_kwargs))

+ 5 - 1
dulwich/tests/test_pack.py

@@ -436,12 +436,16 @@ class TestThinPack(PackTests):
         self.pack_dir = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, self.pack_dir)
         self.pack_prefix = os.path.join(self.pack_dir, 'pack')
-        with open(self.pack_prefix + '.pack', 'wb') as f:
+
+        f = open(self.pack_prefix + '.pack', 'wb')
+        try:
             build_pack(f, [
                 (REF_DELTA, (self.blobs['foo'].id, 'foo1234')),
                 (Blob.type_num, 'bar'),
                 (REF_DELTA, (self.blobs['bar'].id, 'bar2468'))],
                 store=self.store)
+        finally:
+            f.close()
 
         # Index the new pack.
         pack = self.make_pack(True)

+ 305 - 0
dulwich/tests/test_porcelain.py

@@ -0,0 +1,305 @@
+# test_porcelain.py -- porcelain tests
+# Copyright (C) 2013 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.
+
+"""Tests for dulwich.porcelain."""
+
+from cStringIO import StringIO
+import os
+import shutil
+import tarfile
+import tempfile
+
+from dulwich import porcelain
+from dulwich.objects import (
+    Blob,
+    Tree,
+    )
+from dulwich.repo import Repo
+from dulwich.tests import (
+    TestCase,
+    )
+from dulwich.tests.utils import (
+    build_commit_graph,
+    make_object,
+    )
+
+
+class PorcelainTestCase(TestCase):
+
+    def setUp(self):
+        super(TestCase, self).setUp()
+        repo_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, repo_dir)
+        self.repo = Repo.init(repo_dir)
+
+
+class ArchiveTests(PorcelainTestCase):
+    """Tests for the archive command."""
+
+    def test_simple(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 1, 2]])
+        self.repo.refs["refs/heads/master"] = c3.id
+        out = StringIO()
+        err = StringIO()
+        porcelain.archive(self.repo.path, "refs/heads/master", outstream=out,
+            errstream=err)
+        self.assertEquals("", err.getvalue())
+        tf = tarfile.TarFile(fileobj=out)
+        self.addCleanup(tf.close)
+        self.assertEquals([], tf.getnames())
+
+
+class UpdateServerInfoTests(PorcelainTestCase):
+
+    def test_simple(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1],
+            [3, 1, 2]])
+        self.repo.refs["refs/heads/foo"] = c3.id
+        porcelain.update_server_info(self.repo.path)
+        self.assertTrue(os.path.exists(os.path.join(self.repo.controldir(),
+            'info', 'refs')))
+
+
+class CommitTests(PorcelainTestCase):
+
+    def test_custom_author(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1],
+            [3, 1, 2]])
+        self.repo.refs["refs/heads/foo"] = c3.id
+        sha = porcelain.commit(self.repo.path, message="Some message",
+                author="Joe <joe@example.com>", committer="Bob <bob@example.com>")
+        self.assertTrue(type(sha) is str)
+        self.assertEquals(len(sha), 40)
+
+
+class CloneTests(PorcelainTestCase):
+
+    def test_simple_local(self):
+        f1_1 = make_object(Blob, data='f1')
+        commit_spec = [[1], [2, 1], [3, 1, 2]]
+        trees = {1: [('f1', f1_1), ('f2', f1_1)],
+                 2: [('f1', f1_1), ('f2', f1_1)],
+                 3: [('f1', f1_1), ('f2', f1_1)], }
+
+        c1, c2, c3 = build_commit_graph(self.repo.object_store,
+                                        commit_spec, trees)
+        self.repo.refs["refs/heads/master"] = c3.id
+        target_path = tempfile.mkdtemp()
+        outstream = StringIO()
+        self.addCleanup(shutil.rmtree, target_path)
+        r = porcelain.clone(self.repo.path, target_path,
+                            checkout=False, outstream=outstream)
+        self.assertEquals(r.path, target_path)
+        self.assertEquals(Repo(target_path).head(), c3.id)
+        self.assertTrue('f1' not in os.listdir(target_path))
+        self.assertTrue('f2' not in os.listdir(target_path))
+
+    def test_simple_local_with_checkout(self):
+        f1_1 = make_object(Blob, data='f1')
+        commit_spec = [[1], [2, 1], [3, 1, 2]]
+        trees = {1: [('f1', f1_1), ('f2', f1_1)],
+                 2: [('f1', f1_1), ('f2', f1_1)],
+                 3: [('f1', f1_1), ('f2', f1_1)], }
+
+        c1, c2, c3 = build_commit_graph(self.repo.object_store,
+                                        commit_spec, trees)
+        self.repo.refs["refs/heads/master"] = c3.id
+        target_path = tempfile.mkdtemp()
+        outstream = StringIO()
+        self.addCleanup(shutil.rmtree, target_path)
+        r = porcelain.clone(self.repo.path, target_path,
+                            checkout=True, outstream=outstream)
+        self.assertEquals(r.path, target_path)
+        self.assertEquals(Repo(target_path).head(), c3.id)
+        self.assertTrue('f1' in os.listdir(target_path))
+        self.assertTrue('f2' in os.listdir(target_path))
+
+    def test_bare_local_with_checkout(self):
+        f1_1 = make_object(Blob, data='f1')
+        commit_spec = [[1], [2, 1], [3, 1, 2]]
+        trees = {1: [('f1', f1_1), ('f2', f1_1)],
+                 2: [('f1', f1_1), ('f2', f1_1)],
+                 3: [('f1', f1_1), ('f2', f1_1)], }
+
+        c1, c2, c3 = build_commit_graph(self.repo.object_store,
+                                        commit_spec, trees)
+        self.repo.refs["refs/heads/master"] = c3.id
+        target_path = tempfile.mkdtemp()
+        outstream = StringIO()
+        self.addCleanup(shutil.rmtree, target_path)
+        r = porcelain.clone(self.repo.path, target_path,
+                            bare=True, outstream=outstream)
+        self.assertEquals(r.path, target_path)
+        self.assertEquals(Repo(target_path).head(), c3.id)
+        self.assertFalse('f1' in os.listdir(target_path))
+        self.assertFalse('f2' in os.listdir(target_path))
+
+    def test_no_checkout_with_bare(self):
+        f1_1 = make_object(Blob, data='f1')
+        commit_spec = [[1]]
+        trees = {1: [('f1', f1_1), ('f2', f1_1)]}
+
+        (c1, ) = build_commit_graph(self.repo.object_store, commit_spec, trees)
+        self.repo.refs["refs/heads/master"] = c1.id
+        target_path = tempfile.mkdtemp()
+        outstream = StringIO()
+        self.addCleanup(shutil.rmtree, target_path)
+        self.assertRaises(ValueError, porcelain.clone, self.repo.path,
+            target_path, checkout=True, bare=True, outstream=outstream)
+
+
+class InitTests(TestCase):
+
+    def test_non_bare(self):
+        repo_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, repo_dir)
+        porcelain.init(repo_dir)
+
+    def test_bare(self):
+        repo_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, repo_dir)
+        porcelain.init(repo_dir, bare=True)
+
+
+class AddTests(PorcelainTestCase):
+
+    def test_add_file(self):
+        f = open(os.path.join(self.repo.path, 'foo'), 'w')
+        try:
+            f.write("BAR")
+        finally:
+            f.close()
+        porcelain.add(self.repo.path, paths=["foo"])
+
+
+class RemoveTests(PorcelainTestCase):
+
+    def test_remove_file(self):
+        f = open(os.path.join(self.repo.path, 'foo'), 'w')
+        try:
+            f.write("BAR")
+        finally:
+            f.close()
+        porcelain.add(self.repo.path, paths=["foo"])
+        porcelain.rm(self.repo.path, paths=["foo"])
+
+
+class LogTests(PorcelainTestCase):
+
+    def test_simple(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1],
+            [3, 1, 2]])
+        self.repo.refs["HEAD"] = c3.id
+        outstream = StringIO()
+        porcelain.log(self.repo.path, outstream=outstream)
+        self.assertTrue(outstream.getvalue().startswith("-" * 50))
+
+
+class ShowTests(PorcelainTestCase):
+
+    def test_simple(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1],
+            [3, 1, 2]])
+        self.repo.refs["HEAD"] = c3.id
+        outstream = StringIO()
+        porcelain.show(self.repo.path, committish=c3.id, outstream=outstream)
+        self.assertTrue(outstream.getvalue().startswith("-" * 50))
+
+
+class SymbolicRefTests(PorcelainTestCase):
+
+    def test_set_wrong_symbolic_ref(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1],
+            [3, 1, 2]])
+        self.repo.refs["HEAD"] = c3.id
+
+        outstream = StringIO()
+        self.assertRaises(ValueError, porcelain.symbolic_ref, self.repo.path, 'foobar')
+
+    def test_set_force_wrong_symbolic_ref(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1],
+            [3, 1, 2]])
+        self.repo.refs["HEAD"] = c3.id
+
+        porcelain.symbolic_ref(self.repo.path, 'force_foobar', force=True)
+
+        #test if we actually changed the file
+        new_ref = self.repo.get_named_file('HEAD').read()
+        self.assertEqual(new_ref, 'ref: refs/heads/force_foobar\n')
+
+    def test_set_symbolic_ref(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1],
+            [3, 1, 2]])
+        self.repo.refs["HEAD"] = c3.id
+
+        porcelain.symbolic_ref(self.repo.path, 'master')
+
+    def test_set_symbolic_ref_other_than_master(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1],
+            [3, 1, 2]], attrs=dict(refs='develop'))
+        self.repo.refs["HEAD"] = c3.id
+        self.repo.refs["refs/heads/develop"] = c3.id
+
+        porcelain.symbolic_ref(self.repo.path, 'develop')
+
+        #test if we actually changed the file
+        new_ref = self.repo.get_named_file('HEAD').read()
+        self.assertEqual(new_ref, 'ref: refs/heads/develop\n')
+
+
+class DiffTreeTests(PorcelainTestCase):
+
+    def test_empty(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1],
+            [3, 1, 2]])
+        self.repo.refs["HEAD"] = c3.id
+        outstream = StringIO()
+        porcelain.diff_tree(self.repo.path, c2.tree, c3.tree, outstream=outstream)
+        self.assertEquals(outstream.getvalue(), "")
+
+
+class CommitTreeTests(PorcelainTestCase):
+
+    def test_simple(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1],
+            [3, 1, 2]])
+        b = Blob()
+        b.data = "foo the bar"
+        t = Tree()
+        t.add("somename", 0100644, b.id)
+        self.repo.object_store.add_object(t)
+        self.repo.object_store.add_object(b)
+        sha = porcelain.commit_tree(
+            self.repo.path, t.id, message="Withcommit.",
+            author="Joe <joe@example.com>",
+            committer="Jane <jane@example.com>")
+        self.assertTrue(type(sha) is str)
+        self.assertEquals(len(sha), 40)
+
+
+class RevListTests(PorcelainTestCase):
+
+    def test_simple(self):
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1],
+            [3, 1, 2]])
+        outstream = StringIO()
+        porcelain.rev_list(
+            self.repo.path, [c3.id], outstream=outstream)
+        self.assertEquals(
+            "%s\n%s\n%s\n" % (c3.id, c2.id, c1.id),
+            outstream.getvalue())

+ 10 - 1
dulwich/tests/test_repository.py

@@ -35,6 +35,9 @@ from dulwich.object_store import (
     )
 from dulwich import objects
 from dulwich.config import Config
+from dulwich.refs import (
+    _split_ref_line,
+    )
 from dulwich.repo import (
     check_ref_format,
     DictRefsContainer,
@@ -44,7 +47,6 @@ from dulwich.repo import (
     read_packed_refs,
     read_packed_refs_with_peeled,
     write_packed_refs,
-    _split_ref_line,
     )
 from dulwich.tests import (
     TestCase,
@@ -115,6 +117,7 @@ class RepositoryTests(TestCase):
 
     def test_ref(self):
         r = self._repo = open_repo('a.git')
+        warnings.simplefilter("ignore", DeprecationWarning)
         self.assertEqual(r.ref('refs/heads/master'),
                          'a90fa2d900a17e99b433217e988c4eb4a2e9a097')
 
@@ -178,6 +181,12 @@ class RepositoryTests(TestCase):
             f.close()
         self.assertEquals("Some description", r.get_description())
 
+    def test_set_description(self):
+        r = self._repo = open_repo('a.git')
+        description = "Some description"
+        r.set_description(description)
+        self.assertEquals(description, r.get_description())
+
     def test_contains_missing(self):
         r = self._repo = open_repo('a.git')
         self.assertFalse("bar" in r)

+ 1 - 1
dulwich/tests/utils.py

@@ -160,7 +160,7 @@ def ext_functest_builder(method, func):
 
     def do_test(self):
         if not isinstance(func, types.BuiltinFunctionType):
-            raise SkipTest("%s extension not found" % func.func_name)
+            raise SkipTest("%s extension not found" % func)
         method(self, func)
 
     return do_test

+ 18 - 12
dulwich/walk.py

@@ -56,6 +56,7 @@ class WalkEntry(object):
     def __init__(self, walker, commit):
         self.commit = commit
         self._store = walker.store
+        self._get_parents = walker.get_parents
         self._changes = None
         self._rename_detector = walker.rename_detector
 
@@ -69,15 +70,15 @@ class WalkEntry(object):
         """
         if self._changes is None:
             commit = self.commit
-            if not commit.parents:
+            if not self._get_parents(commit):
                 changes_func = tree_changes
                 parent = None
-            elif len(commit.parents) == 1:
+            elif len(self._get_parents(commit)) == 1:
                 changes_func = tree_changes
-                parent = self._store[commit.parents[0]].tree
+                parent = self._store[self._get_parents(commit)[0]].tree
             else:
                 changes_func = tree_changes_for_merge
-                parent = [self._store[p].tree for p in commit.parents]
+                parent = [self._store[p].tree for p in self._get_parents(commit)]
             self._changes = list(changes_func(
               self._store, parent, commit.tree,
               rename_detector=self._rename_detector))
@@ -94,6 +95,7 @@ class _CommitTimeQueue(object):
     def __init__(self, walker):
         self._walker = walker
         self._store = walker.store
+        self._get_parents = walker.get_parents
         self._excluded = walker.excluded
         self._pq = []
         self._pq_set = set()
@@ -123,7 +125,7 @@ class _CommitTimeQueue(object):
         todo = [commit]
         while todo:
             commit = todo.pop()
-            for parent in commit.parents:
+            for parent in self._get_parents(commit):
                 if parent not in excluded and parent in seen:
                     # TODO: This is inefficient unless the object store does
                     # some caching (which DiskObjectStore currently does not).
@@ -141,9 +143,9 @@ class _CommitTimeQueue(object):
             self._pq_set.remove(sha)
             if sha in self._done:
                 continue
-            self._done.add(commit.id)
+            self._done.add(sha)
 
-            for parent_id in commit.parents:
+            for parent_id in self._get_parents(commit):
                 self._push(parent_id)
 
             reset_extra_commits = True
@@ -196,6 +198,7 @@ class Walker(object):
     def __init__(self, store, include, exclude=None, order=ORDER_DATE,
                  reverse=False, max_entries=None, paths=None,
                  rename_detector=None, follow=False, since=None, until=None,
+                 get_parents=lambda commit: commit.parents,
                  queue_cls=_CommitTimeQueue):
         """Constructor.
 
@@ -217,6 +220,7 @@ class Walker(object):
             default rename_detector.
         :param since: Timestamp to list commits after.
         :param until: Timestamp to list commits before.
+        :param get_parents: Method to retrieve the parents of a commit
         :param queue_cls: A class to use for a queue of commits, supporting the
             iterator protocol. The constructor takes a single argument, the
             Walker.
@@ -235,6 +239,7 @@ class Walker(object):
         if follow and not rename_detector:
             rename_detector = RenameDetector(store)
         self.rename_detector = rename_detector
+        self.get_parents = get_parents
         self.follow = follow
         self.since = since
         self.until = until
@@ -287,7 +292,7 @@ class Walker(object):
         if self.paths is None:
             return True
 
-        if len(commit.parents) > 1:
+        if len(self.get_parents(commit)) > 1:
             for path_changes in entry.changes():
                 # For merge commits, only include changes with conflicts for
                 # this path. Since a rename conflict may include different
@@ -325,7 +330,7 @@ class Walker(object):
             by the Walker.
         """
         if self.order == ORDER_TOPO:
-            results = _topo_reorder(results)
+            results = _topo_reorder(results, self.get_parents)
         if self.reverse:
             results = reversed(list(results))
         return results
@@ -334,13 +339,14 @@ class Walker(object):
         return iter(self._reorder(iter(self._next, None)))
 
 
-def _topo_reorder(entries):
+def _topo_reorder(entries, get_parents=lambda commit: commit.parents):
     """Reorder an iterable of entries topologically.
 
     This works best assuming the entries are already in almost-topological
     order, e.g. in commit time order.
 
     :param entries: An iterable of WalkEntry objects.
+    :param get_parents: Optional function for getting the parents of a commit.
     :return: iterator over WalkEntry objects from entries in FIFO order, except
         where a parent would be yielded before any of its children.
     """
@@ -349,7 +355,7 @@ def _topo_reorder(entries):
     num_children = defaultdict(int)
     for entry in entries:
         todo.append(entry)
-        for p in entry.commit.parents:
+        for p in get_parents(entry.commit):
             num_children[p] += 1
 
     while todo:
@@ -359,7 +365,7 @@ def _topo_reorder(entries):
         if num_children[commit_id]:
             pending[commit_id] = entry
             continue
-        for parent_id in commit.parents:
+        for parent_id in get_parents(commit):
             num_children[parent_id] -= 1
             if not num_children[parent_id]:
                 parent_entry = pending.pop(parent_id, None)

+ 1 - 0
dulwich/web.py

@@ -1,5 +1,6 @@
 # web.py -- WSGI smart-http server
 # Copyright (C) 2010 Google, Inc.
+# Copyright (C) 2012 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

+ 13 - 0
examples/config.py

@@ -0,0 +1,13 @@
+#!/usr/bin/python
+# Read the config file for a git repository.
+#
+# Example usage:
+#  python examples/config.py
+
+from dulwich.repo import Repo
+
+repo = Repo(".")
+config = repo.get_config()
+
+print config.get("core", "filemode")
+print config.get(("remote", "origin"), "url")

+ 7 - 5
setup.py

@@ -10,7 +10,7 @@ except ImportError:
     has_setuptools = False
 from distutils.core import Distribution
 
-dulwich_version_string = '0.9.1'
+dulwich_version_string = '0.9.4'
 
 include_dirs = []
 # Windows MSVC support
@@ -63,11 +63,13 @@ setup(name='dulwich',
       author_email='jelmer@samba.org',
       long_description="""
       Simple Python implementation of the Git file formats and
-      protocols. Dulwich is the place where Mr. and Mrs. Git live
-      in one of the Monty Python sketches.
+      protocols.
 
-      All functionality is available in pure Python, but (optional)
-      C extensions are also available for better performance.
+      All functionality is available in pure Python. Optional
+      C extensions can be built for improved performance.
+
+      Dulwich takes its name from the area in London where the friendly
+      Mr. and Mrs. Git once attended a cocktail party.
       """,
       packages=['dulwich', 'dulwich.tests', 'dulwich.tests.compat'],
       scripts=['bin/dulwich', 'bin/dul-daemon', 'bin/dul-web', 'bin/dul-receive-pack', 'bin/dul-upload-pack'],