浏览代码

Import upstream version 0.20.5, md5 450b4cb3a71ea299d6c04af3cf7eeb2e

Jelmer Vernooij 4 年之前
父节点
当前提交
82f7473f4f

+ 36 - 0
NEWS

@@ -1,3 +1,39 @@
+0.20.5	2020-06-22
+
+ * Print a clearer exception when setup.py is executed on Python < 3.5.
+   (Jelmer Vernooij, #783)
+
+ * Send an empty pack to clients if they requested objects, even if they
+   already have those objects. Thanks to Martijn Pieters for
+   the detailed bug report. (Jelmer Vernooij, #781)
+
+ * porcelain.pull: Don't ask for objects that we already have.
+   (Jelmer Vernooij, #782)
+
+ * Add LCA implementation. (Kevin Hendricks)
+
+ * Add functionality for finding the merge base. (Kevin Hendricks)
+
+ * Check for diverged branches during push.
+   (Jelmer Vernooij, #494)
+
+ * Check for fast-forward during pull. (Jelmer Vernooij, #666)
+
+ * Return a SendPackResult object from
+   GitClient.send_pack(). (Jelmer Vernooij)
+
+ * ``GitClient.send_pack`` now sets the ``ref_status`` attribute
+   on its return value to a dictionary mapping ref names
+   to error messages. Previously, it raised UpdateRefsError
+   if any of the refs failed to update.
+   (Jelmer Vernooij, #780)
+
+ * Add a ``porcelain.Error`` object that most errors in porcelain
+   derive from. (Jelmer Vernooij)
+
+ * Fix argument parsing in dulwich command-line app.
+   (Jelmer Vernooij, #784)
+
 0.20.3	2020-06-14
 
  * Add support for remembering remote refs after push/pull.

+ 2 - 2
PKG-INFO

@@ -1,14 +1,14 @@
 Metadata-Version: 2.1
 Name: dulwich
-Version: 0.20.3
+Version: 0.20.5
 Summary: Python Git Library
 Home-page: https://www.dulwich.io/
 Author: Jelmer Vernooij
 Author-email: jelmer@jelmer.uk
 License: Apachev2 or later or GPLv2
 Project-URL: Bug Tracker, https://github.com/dulwich/dulwich/issues
-Project-URL: GitHub, https://github.com/dulwich/dulwich
 Project-URL: Repository, https://www.dulwich.io/code/
+Project-URL: GitHub, https://github.com/dulwich/dulwich
 Description: .. image:: https://travis-ci.org/dulwich/dulwich.png?branch=master
           :alt: Build Status
           :target: https://travis-ci.org/dulwich/dulwich

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

@@ -1,14 +1,14 @@
 Metadata-Version: 2.1
 Name: dulwich
-Version: 0.20.3
+Version: 0.20.5
 Summary: Python Git Library
 Home-page: https://www.dulwich.io/
 Author: Jelmer Vernooij
 Author-email: jelmer@jelmer.uk
 License: Apachev2 or later or GPLv2
 Project-URL: Bug Tracker, https://github.com/dulwich/dulwich/issues
-Project-URL: GitHub, https://github.com/dulwich/dulwich
 Project-URL: Repository, https://www.dulwich.io/code/
+Project-URL: GitHub, https://github.com/dulwich/dulwich
 Description: .. image:: https://travis-ci.org/dulwich/dulwich.png?branch=master
           :alt: Build Status
           :target: https://travis-ci.org/dulwich/dulwich

+ 2 - 0
dulwich.egg-info/SOURCES.txt

@@ -56,6 +56,7 @@ dulwich/diff_tree.py
 dulwich/errors.py
 dulwich/fastexport.py
 dulwich/file.py
+dulwich/graph.py
 dulwich/greenthreads.py
 dulwich/hooks.py
 dulwich/ignore.py
@@ -104,6 +105,7 @@ dulwich/tests/test_diff_tree.py
 dulwich/tests/test_fastexport.py
 dulwich/tests/test_file.py
 dulwich/tests/test_grafts.py
+dulwich/tests/test_graph.py
 dulwich/tests/test_greenthreads.py
 dulwich/tests/test_hooks.py
 dulwich/tests/test_ignore.py

+ 1 - 1
dulwich.egg-info/requires.txt

@@ -1,5 +1,5 @@
-certifi
 urllib3>=1.24.1
+certifi
 
 [fastimport]
 fastimport

+ 1 - 1
dulwich/__init__.py

@@ -22,4 +22,4 @@
 
 """Python implementation of the Git file formats and protocols."""
 
-__version__ = (0, 20, 3)
+__version__ = (0, 20, 5)

+ 13 - 53
dulwich/_diff_tree.c

@@ -26,25 +26,6 @@
 typedef unsigned short mode_t;
 #endif
 
-#if PY_MAJOR_VERSION < 3
-typedef long Py_hash_t;
-#endif
-
-#if PY_MAJOR_VERSION >= 3
-#define PyInt_FromLong PyLong_FromLong
-#define PyInt_AsLong PyLong_AsLong
-#define PyInt_AS_LONG PyLong_AS_LONG
-#define PyString_AS_STRING PyBytes_AS_STRING
-#define PyString_AsStringAndSize PyBytes_AsStringAndSize
-#define PyString_Check PyBytes_Check
-#define PyString_CheckExact PyBytes_CheckExact
-#define PyString_FromStringAndSize PyBytes_FromStringAndSize
-#define PyString_FromString PyBytes_FromString
-#define PyString_GET_SIZE PyBytes_GET_SIZE
-#define PyString_Size PyBytes_Size
-#define _PyString_Join _PyBytes_Join
-#endif
-
 static PyObject *tree_entry_cls = NULL, *null_entry = NULL,
 	*defaultdict_cls = NULL, *int_cls = NULL;
 static int block_size;
@@ -118,7 +99,7 @@ static PyObject **tree_entries(char *path, Py_ssize_t path_len, PyObject *tree,
 		if (!sha)
 			goto error;
 		name = PyTuple_GET_ITEM(old_entry, 0);
-		name_len = PyString_Size(name);
+		name_len = PyBytes_Size(name);
 		if (PyErr_Occurred())
 			goto error;
 
@@ -133,18 +114,13 @@ static PyObject **tree_entries(char *path, Py_ssize_t path_len, PyObject *tree,
 		if (path_len) {
 			memcpy(new_path, path, path_len);
 			new_path[path_len] = '/';
-			memcpy(new_path + path_len + 1, PyString_AS_STRING(name), name_len);
+			memcpy(new_path + path_len + 1, PyBytes_AS_STRING(name), name_len);
 		} else {
-			memcpy(new_path, PyString_AS_STRING(name), name_len);
+			memcpy(new_path, PyBytes_AS_STRING(name), name_len);
 		}
 
-#if PY_MAJOR_VERSION >= 3
 		result[i] = PyObject_CallFunction(tree_entry_cls, "y#OO", new_path,
 			new_path_len, PyTuple_GET_ITEM(old_entry, 1), sha);
-#else
-		result[i] = PyObject_CallFunction(tree_entry_cls, "s#OO", new_path,
-			new_path_len, PyTuple_GET_ITEM(old_entry, 1), sha);
-#endif
 		PyMem_Free(new_path);
 		if (!result[i]) {
 			goto error;
@@ -172,7 +148,7 @@ static int entry_path_cmp(PyObject *entry1, PyObject *entry2)
 	if (!path1)
 		goto done;
 
-	if (!PyString_Check(path1)) {
+	if (!PyBytes_Check(path1)) {
 		PyErr_SetString(PyExc_TypeError, "path is not a (byte)string");
 		goto done;
 	}
@@ -181,12 +157,12 @@ static int entry_path_cmp(PyObject *entry1, PyObject *entry2)
 	if (!path2)
 		goto done;
 
-	if (!PyString_Check(path2)) {
+	if (!PyBytes_Check(path2)) {
 		PyErr_SetString(PyExc_TypeError, "path is not a (byte)string");
 		goto done;
 	}
 
-	result = strcmp(PyString_AS_STRING(path1), PyString_AS_STRING(path2));
+	result = strcmp(PyBytes_AS_STRING(path1), PyBytes_AS_STRING(path2));
 
 done:
 	Py_XDECREF(path1);
@@ -202,11 +178,7 @@ static PyObject *py_merge_entries(PyObject *self, PyObject *args)
 	char *path_str;
 	int cmp;
 
-#if PY_MAJOR_VERSION >= 3
 	if (!PyArg_ParseTuple(args, "y#OO", &path_str, &path_len, &tree1, &tree2))
-#else
-	if (!PyArg_ParseTuple(args, "s#OO", &path_str, &path_len, &tree1, &tree2))
-#endif
 		return NULL;
 
 	entries1 = tree_entries(path_str, path_len, tree1, &n1);
@@ -291,7 +263,7 @@ static PyObject *py_is_tree(PyObject *self, PyObject *args)
 		result = Py_False;
 		Py_INCREF(result);
 	} else {
-		lmode = PyInt_AsLong(mode);
+		lmode = PyLong_AsLong(mode);
 		if (lmode == -1 && PyErr_Occurred()) {
 			Py_DECREF(mode);
 			return NULL;
@@ -310,13 +282,13 @@ static Py_hash_t add_hash(PyObject *get, PyObject *set, char *str, int n)
 
 	/* It would be nice to hash without copying str into a PyString, but that
 	 * isn't exposed by the API. */
-	str_obj = PyString_FromStringAndSize(str, n);
+	str_obj = PyBytes_FromStringAndSize(str, n);
 	if (!str_obj)
 		goto error;
 	hash = PyObject_Hash(str_obj);
 	if (hash == -1)
 		goto error;
-	hash_obj = PyInt_FromLong(hash);
+	hash_obj = PyLong_FromLong(hash);
 	if (!hash_obj)
 		goto error;
 
@@ -324,7 +296,7 @@ static Py_hash_t add_hash(PyObject *get, PyObject *set, char *str, int n)
 	if (!value)
 		goto error;
 	set_value = PyObject_CallFunction(set, "(Ol)", hash_obj,
-		PyInt_AS_LONG(value) + n);
+		PyLong_AS_LONG(value) + n);
 	if (!set_value)
 		goto error;
 
@@ -377,11 +349,11 @@ static PyObject *py_count_blocks(PyObject *self, PyObject *args)
 
 	for (i = 0; i < num_chunks; i++) {
 		chunk = PyList_GET_ITEM(chunks, i);
-		if (!PyString_Check(chunk)) {
+		if (!PyBytes_Check(chunk)) {
 			PyErr_SetString(PyExc_TypeError, "chunk is not a string");
 			goto error;
 		}
-		if (PyString_AsStringAndSize(chunk, &chunk_str, &chunk_len) == -1)
+		if (PyBytes_AsStringAndSize(chunk, &chunk_str, &chunk_len) == -1)
 			goto error;
 
 		for (j = 0; j < chunk_len; j++) {
@@ -425,7 +397,6 @@ moduleinit(void)
 	PyObject *m, *objects_mod = NULL, *diff_tree_mod = NULL;
 	PyObject *block_size_obj = NULL;
 
-#if PY_MAJOR_VERSION >= 3
 	static struct PyModuleDef moduledef = {
 		PyModuleDef_HEAD_INIT,
 		"_diff_tree",         /* m_name */
@@ -438,9 +409,6 @@ moduleinit(void)
 		NULL,                 /* m_free */
 	};
 	m = PyModule_Create(&moduledef);
-#else
-	m = Py_InitModule("_diff_tree", py_diff_tree_methods);
-#endif
 	if (!m)
 		goto error;
 
@@ -464,7 +432,7 @@ moduleinit(void)
 	block_size_obj = PyObject_GetAttrString(diff_tree_mod, "_BLOCK_SIZE");
 	if (!block_size_obj)
 		goto error;
-	block_size = (int)PyInt_AsLong(block_size_obj);
+	block_size = (int)PyLong_AsLong(block_size_obj);
 
 	if (PyErr_Occurred())
 		goto error;
@@ -495,16 +463,8 @@ error:
 	return NULL;
 }
 
-#if PY_MAJOR_VERSION >= 3
 PyMODINIT_FUNC
 PyInit__diff_tree(void)
 {
 	return moduleinit();
 }
-#else
-PyMODINIT_FUNC
-init_diff_tree(void)
-{
-	moduleinit();
-}
-#endif

+ 7 - 33
dulwich/_objects.c

@@ -23,15 +23,6 @@
 #include <stdlib.h>
 #include <sys/stat.h>
 
-#if PY_MAJOR_VERSION >= 3
-#define PyInt_Check(obj) 0
-#define PyInt_CheckExact(obj) 0
-#define PyInt_AsLong PyLong_AsLong
-#define PyString_AS_STRING PyBytes_AS_STRING
-#define PyString_Check PyBytes_Check
-#define PyString_FromStringAndSize PyBytes_FromStringAndSize
-#endif
-
 #if defined(__MINGW32_VERSION) || defined(__APPLE__)
 size_t rep_strnlen(char *text, size_t maxlen);
 size_t rep_strnlen(char *text, size_t maxlen)
@@ -56,7 +47,7 @@ static PyObject *sha_to_pyhex(const unsigned char *sha)
 		hexsha[i*2+1] = bytehex(sha[i] & 0x0F);
 	}
 
-	return PyString_FromStringAndSize(hexsha, 40);
+	return PyBytes_FromStringAndSize(hexsha, 40);
 }
 
 static PyObject *py_parse_tree(PyObject *self, PyObject *args, PyObject *kw)
@@ -67,13 +58,8 @@ static PyObject *py_parse_tree(PyObject *self, PyObject *args, PyObject *kw)
 	PyObject *ret, *item, *name, *sha, *py_strict = NULL;
 	static char *kwlist[] = {"text", "strict", NULL};
 
-#if PY_MAJOR_VERSION >= 3
 	if (!PyArg_ParseTupleAndKeywords(args, kw, "y#|O", kwlist,
 	                                 &text, &len, &py_strict))
-#else
-	if (!PyArg_ParseTupleAndKeywords(args, kw, "s#|O", kwlist,
-	                                 &text, &len, &py_strict))
-#endif
 		return NULL;
 	strict = py_strict ?  PyObject_IsTrue(py_strict) : 0;
 	/* TODO: currently this returns a list; if memory usage is a concern,
@@ -100,7 +86,7 @@ static PyObject *py_parse_tree(PyObject *self, PyObject *args, PyObject *kw)
 		}
 		text++;
 		namelen = strnlen(text, len - (text - start));
-		name = PyString_FromStringAndSize(text, namelen);
+		name = PyBytes_FromStringAndSize(text, namelen);
 		if (name == NULL) {
 			Py_DECREF(ret);
 			return NULL;
@@ -207,7 +193,7 @@ static PyObject *py_sorted_tree_items(PyObject *self, PyObject *args)
 	}
 
 	while (PyDict_Next(entries, &pos, &key, &value)) {
-		if (!PyString_Check(key)) {
+		if (!PyBytes_Check(key)) {
 			PyErr_SetString(PyExc_TypeError, "Name is not a string");
 			goto error;
 		}
@@ -218,18 +204,18 @@ static PyObject *py_sorted_tree_items(PyObject *self, PyObject *args)
 		}
 
 		py_mode = PyTuple_GET_ITEM(value, 0);
-		if (!PyInt_Check(py_mode) && !PyLong_Check(py_mode)) {
+		if (!PyLong_Check(py_mode)) {
 			PyErr_SetString(PyExc_TypeError, "Mode is not an integral type");
 			goto error;
 		}
 
 		py_sha = PyTuple_GET_ITEM(value, 1);
-		if (!PyString_Check(py_sha)) {
+		if (!PyBytes_Check(py_sha)) {
 			PyErr_SetString(PyExc_TypeError, "SHA is not a string");
 			goto error;
 		}
-		qsort_entries[n].name = PyString_AS_STRING(key);
-		qsort_entries[n].mode = PyInt_AsLong(py_mode);
+		qsort_entries[n].name = PyBytes_AS_STRING(key);
+		qsort_entries[n].mode = PyLong_AsLong(py_mode);
 
 		qsort_entries[n].tuple = PyObject_CallFunctionObjArgs(
 		                tree_entry_cls, key, py_mode, py_sha, NULL);
@@ -272,7 +258,6 @@ moduleinit(void)
 {
 	PyObject *m, *objects_mod, *errors_mod;
 
-#if PY_MAJOR_VERSION >= 3
 	static struct PyModuleDef moduledef = {
 		PyModuleDef_HEAD_INIT,
 		"_objects",         /* m_name */
@@ -285,9 +270,6 @@ moduleinit(void)
 		NULL,               /* m_free */
 	};
 	m = PyModule_Create(&moduledef);
-#else
-	m = Py_InitModule3("_objects", py_objects_methods, NULL);
-#endif
 	if (m == NULL) {
 		return NULL;
 	}
@@ -320,16 +302,8 @@ moduleinit(void)
 	return m;
 }
 
-#if PY_MAJOR_VERSION >= 3
 PyMODINIT_FUNC
 PyInit__objects(void)
 {
 	return moduleinit();
 }
-#else
-PyMODINIT_FUNC
-init_objects(void)
-{
-	moduleinit();
-}
-#endif

+ 13 - 45
dulwich/_pack.c

@@ -22,27 +22,14 @@
 #include <Python.h>
 #include <stdint.h>
 
-#if PY_MAJOR_VERSION >= 3
-#define PyInt_FromLong PyLong_FromLong
-#define PyString_AS_STRING PyBytes_AS_STRING
-#define PyString_AS_STRING PyBytes_AS_STRING
-#define PyString_Check PyBytes_Check
-#define PyString_CheckExact PyBytes_CheckExact
-#define PyString_FromStringAndSize PyBytes_FromStringAndSize
-#define PyString_FromString PyBytes_FromString
-#define PyString_GET_SIZE PyBytes_GET_SIZE
-#define PyString_Size PyBytes_Size
-#define _PyString_Join _PyBytes_Join
-#endif
-
 static PyObject *PyExc_ApplyDeltaError = NULL;
 
 static int py_is_sha(PyObject *sha)
 {
-	if (!PyString_CheckExact(sha))
+	if (!PyBytes_CheckExact(sha))
 		return 0;
 
-	if (PyString_Size(sha) != 20)
+	if (PyBytes_Size(sha) != 20)
 		return 0;
 
 	return 1;
@@ -67,18 +54,18 @@ static size_t get_delta_header_size(uint8_t *delta, size_t *index, size_t length
 static PyObject *py_chunked_as_string(PyObject *py_buf)
 {
 	if (PyList_Check(py_buf)) {
-		PyObject *sep = PyString_FromString("");
+		PyObject *sep = PyBytes_FromString("");
 		if (sep == NULL) {
 			PyErr_NoMemory();
 			return NULL;
 		}
-		py_buf = _PyString_Join(sep, py_buf);
+		py_buf = _PyBytes_Join(sep, py_buf);
 		Py_DECREF(sep);
 		if (py_buf == NULL) {
 			PyErr_NoMemory();
 			return NULL;
 		}
-	} else if (PyString_Check(py_buf)) {
+	} else if (PyBytes_Check(py_buf)) {
 		Py_INCREF(py_buf);
 	} else {
 		PyErr_SetString(PyExc_TypeError,
@@ -111,11 +98,11 @@ static PyObject *py_apply_delta(PyObject *self, PyObject *args)
 		return NULL;
 	}
 
-	src_buf = (uint8_t *)PyString_AS_STRING(py_src_buf);
-	src_buf_len = (size_t)PyString_GET_SIZE(py_src_buf);
+	src_buf = (uint8_t *)PyBytes_AS_STRING(py_src_buf);
+	src_buf_len = (size_t)PyBytes_GET_SIZE(py_src_buf);
 
-	delta = (uint8_t *)PyString_AS_STRING(py_delta);
-	delta_len = (size_t)PyString_GET_SIZE(py_delta);
+	delta = (uint8_t *)PyBytes_AS_STRING(py_delta);
+	delta_len = (size_t)PyBytes_GET_SIZE(py_delta);
 
 	index = 0;
 	src_size = get_delta_header_size(delta, &index, delta_len);
@@ -127,14 +114,14 @@ static PyObject *py_apply_delta(PyObject *self, PyObject *args)
 		return NULL;
 	}
 	dest_size = get_delta_header_size(delta, &index, delta_len);
-	ret = PyString_FromStringAndSize(NULL, dest_size);
+	ret = PyBytes_FromStringAndSize(NULL, dest_size);
 	if (ret == NULL) {
 		PyErr_NoMemory();
 		Py_DECREF(py_src_buf);
 		Py_DECREF(py_delta);
 		return NULL;
 	}
-	out = (uint8_t *)PyString_AS_STRING(ret);
+	out = (uint8_t *)PyBytes_AS_STRING(ret);
 	while (index < delta_len) {
 		uint8_t cmd = delta[index];
 		index++;
@@ -208,13 +195,8 @@ static PyObject *py_bisect_find_sha(PyObject *self, PyObject *args)
 	char *sha;
 	Py_ssize_t sha_len;
 	int start, end;
-#if PY_MAJOR_VERSION >= 3
 	if (!PyArg_ParseTuple(args, "iiy#O", &start, &end,
 			      &sha, &sha_len, &unpack_name))
-#else
-	if (!PyArg_ParseTuple(args, "iis#O", &start, &end,
-			      &sha, &sha_len, &unpack_name))
-#endif
 		return NULL;
 
 	if (sha_len != 20) {
@@ -239,14 +221,14 @@ static PyObject *py_bisect_find_sha(PyObject *self, PyObject *args)
 			Py_DECREF(file_sha);
 			return NULL;
 		}
-		cmp = memcmp(PyString_AS_STRING(file_sha), sha, 20);
+		cmp = memcmp(PyBytes_AS_STRING(file_sha), sha, 20);
 		Py_DECREF(file_sha);
 		if (cmp < 0)
 			start = i + 1;
 		else if (cmp > 0)
 			end = i - 1;
 		else {
-			return PyInt_FromLong(i);
+			return PyLong_FromLong(i);
 		}
 	}
 	Py_RETURN_NONE;
@@ -265,7 +247,6 @@ moduleinit(void)
 	PyObject *m;
 	PyObject *errors_module;
 
-#if PY_MAJOR_VERSION >= 3
 	static struct PyModuleDef moduledef = {
 	  PyModuleDef_HEAD_INIT,
 	  "_pack",         /* m_name */
@@ -277,7 +258,6 @@ moduleinit(void)
 	  NULL,            /* m_clear*/
 	  NULL,            /* m_free */
 	};
-#endif
 
 	errors_module = PyImport_ImportModule("dulwich.errors");
 	if (errors_module == NULL)
@@ -288,27 +268,15 @@ moduleinit(void)
 	if (PyExc_ApplyDeltaError == NULL)
 		return NULL;
 
-#if PY_MAJOR_VERSION >= 3
 	m = PyModule_Create(&moduledef);
-#else
-	m = Py_InitModule3("_pack", py_pack_methods, NULL);
-#endif
 	if (m == NULL)
 		return NULL;
 
 	return m;
 }
 
-#if PY_MAJOR_VERSION >= 3
 PyMODINIT_FUNC
 PyInit__pack(void)
 {
 	return moduleinit();
 }
-#else
-PyMODINIT_FUNC
-init_pack(void)
-{
-	moduleinit();
-}
-#endif

+ 125 - 103
dulwich/client.py

@@ -45,6 +45,7 @@ import select
 import socket
 import subprocess
 import sys
+from typing import Optional, Dict, Callable, Set
 
 from urllib.parse import (
     quote as urlquote,
@@ -61,7 +62,6 @@ from dulwich.errors import (
     GitProtocolError,
     NotGitRepository,
     SendPackError,
-    UpdateRefsError,
     )
 from dulwich.protocol import (
     HangupException,
@@ -163,7 +163,6 @@ class ReportStatusParser(object):
     def __init__(self):
         self._done = False
         self._pack_status = None
-        self._ref_status_ok = True
         self._ref_statuses = []
 
     def check(self):
@@ -171,30 +170,24 @@ class ReportStatusParser(object):
 
         Raises:
           SendPackError: Raised when the server could not unpack
-          UpdateRefsError: Raised when refs could not be updated
+        Returns:
+          iterator over refs
         """
         if self._pack_status not in (b'unpack ok', None):
             raise SendPackError(self._pack_status)
-        if not self._ref_status_ok:
-            ref_status = {}
-            ok = set()
-            for status in self._ref_statuses:
-                if b' ' not in status:
-                    # malformed response, move on to the next one
-                    continue
-                status, ref = status.split(b' ', 1)
-
-                if status == b'ng':
-                    if b' ' in ref:
-                        ref, status = ref.split(b' ', 1)
-                else:
-                    ok.add(ref)
-                ref_status[ref] = status
-            # TODO(jelmer): don't assume encoding of refs is ascii.
-            raise UpdateRefsError(', '.join([
-                refname.decode('ascii') for refname in ref_status
-                if refname not in ok]) +
-                ' failed to update', ref_status=ref_status)
+        for status in self._ref_statuses:
+            try:
+                status, rest = status.split(b' ', 1)
+            except ValueError:
+                # malformed response, move on to the next one
+                continue
+            if status == b'ng':
+                ref, error = rest.split(b' ', 1)
+                yield ref, error.decode('utf-8')
+            elif status == b'ok':
+                yield rest, None
+            else:
+                raise GitProtocolError('invalid ref status %r' % status)
 
     def handle_packet(self, pkt):
         """Handle a packet.
@@ -213,8 +206,6 @@ class ReportStatusParser(object):
         else:
             ref_status = pkt.strip()
             self._ref_statuses.append(ref_status)
-            if not ref_status.startswith(b'ok '):
-                self._ref_status_ok = False
 
 
 def read_pkt_refs(proto):
@@ -246,8 +237,8 @@ class FetchPackResult(object):
     """
 
     _FORWARDED_ATTRS = [
-            'clear', 'copy', 'fromkeys', 'get', 'has_key', 'items',
-            'iteritems', 'iterkeys', 'itervalues', 'keys', 'pop', 'popitem',
+            'clear', 'copy', 'fromkeys', 'get', 'items',
+            'keys', 'pop', 'popitem',
             'setdefault', 'update', 'values', 'viewitems', 'viewkeys',
             'viewvalues']
 
@@ -300,6 +291,66 @@ class FetchPackResult(object):
                 self.__class__.__name__, self.refs, self.symrefs, self.agent)
 
 
+class SendPackResult(object):
+    """Result of a upload-pack operation.
+
+    Attributes:
+      refs: Dictionary with all remote refs
+      agent: User agent string
+      ref_status: Optional dictionary mapping ref name to error message (if it
+        failed to update), or None if it was updated successfully
+    """
+
+    _FORWARDED_ATTRS = [
+            'clear', 'copy', 'fromkeys', 'get', 'items',
+            'keys', 'pop', 'popitem',
+            'setdefault', 'update', 'values', 'viewitems', 'viewkeys',
+            'viewvalues']
+
+    def __init__(self, refs, agent=None, ref_status=None):
+        self.refs = refs
+        self.agent = agent
+        self.ref_status = ref_status
+
+    def _warn_deprecated(self):
+        import warnings
+        warnings.warn(
+            "Use SendPackResult.refs instead.",
+            DeprecationWarning, stacklevel=3)
+
+    def __eq__(self, other):
+        if isinstance(other, dict):
+            self._warn_deprecated()
+            return self.refs == other
+        return self.refs == other.refs and self.agent == other.agent
+
+    def __contains__(self, name):
+        self._warn_deprecated()
+        return name in self.refs
+
+    def __getitem__(self, name):
+        self._warn_deprecated()
+        return self.refs[name]
+
+    def __len__(self):
+        self._warn_deprecated()
+        return len(self.refs)
+
+    def __iter__(self):
+        self._warn_deprecated()
+        return iter(self.refs)
+
+    def __getattribute__(self, name):
+        if name in type(self)._FORWARDED_ATTRS:
+            self._warn_deprecated()
+            return getattr(self.refs, name)
+        return super(SendPackResult, self).__getattribute__(name)
+
+    def __repr__(self):
+        return "%s(%r, %r)" % (
+                self.__class__.__name__, self.refs, self.agent)
+
+
 def _read_shallow_updates(proto):
     new_shallow = set()
     new_unshallow = set()
@@ -382,13 +433,10 @@ class GitClient(object):
           progress: Optional progress function
 
         Returns:
-          new_refs dictionary containing the changes that were made
-            {refname: new_ref}, including deleted refs.
+          SendPackResult object
 
         Raises:
           SendPackError: if server rejects the pack data
-          UpdateRefsError: if the server supports report-status
-                         and rejects ref updates
 
         """
         raise NotImplementedError(self.send_pack)
@@ -469,43 +517,6 @@ class GitClient(object):
         """
         raise NotImplementedError(self.get_refs)
 
-    def _parse_status_report(self, proto):
-        unpack = proto.read_pkt_line().strip()
-        if unpack != b'unpack ok':
-            st = True
-            # flush remaining error data
-            while st is not None:
-                st = proto.read_pkt_line()
-            raise SendPackError(unpack)
-        statuses = []
-        errs = False
-        ref_status = proto.read_pkt_line()
-        while ref_status:
-            ref_status = ref_status.strip()
-            statuses.append(ref_status)
-            if not ref_status.startswith(b'ok '):
-                errs = True
-            ref_status = proto.read_pkt_line()
-
-        if errs:
-            ref_status = {}
-            ok = set()
-            for status in statuses:
-                if b' ' not in status:
-                    # malformed response, move on to the next one
-                    continue
-                status, ref = status.split(b' ', 1)
-
-                if status == b'ng':
-                    if b' ' in ref:
-                        ref, status = ref.split(b' ', 1)
-                else:
-                    ok.add(ref)
-                ref_status[ref] = status
-            raise UpdateRefsError(', '.join([
-                refname for refname in ref_status if refname not in ok]) +
-                b' failed to update', ref_status=ref_status)
-
     def _read_side_band64k_data(self, proto, channel_callbacks):
         """Read per-channel data.
 
@@ -578,13 +589,21 @@ class GitClient(object):
     def _negotiate_receive_pack_capabilities(self, server_capabilities):
         negotiated_capabilities = (
             self._send_capabilities & server_capabilities)
+        agent = None
+        for capability in server_capabilities:
+            k, v = parse_capability(capability)
+            if k == CAPABILITY_AGENT:
+                agent = v
         unknown_capabilities = (  # noqa: F841
             extract_capability_names(server_capabilities) -
             KNOWN_RECEIVE_CAPABILITIES)
         # TODO(jelmer): warn about unknown capabilities
-        return negotiated_capabilities
+        return negotiated_capabilities, agent
 
-    def _handle_receive_pack_tail(self, proto, capabilities, progress=None):
+    def _handle_receive_pack_tail(
+            self, proto: Protocol, capabilities: Set[bytes],
+            progress: Callable[[bytes], None] = None
+            ) -> Optional[Dict[bytes, Optional[str]]]:
         """Handle the tail of a 'git-receive-pack' request.
 
         Args:
@@ -593,7 +612,9 @@ class GitClient(object):
           progress: Optional progress reporting function
 
         Returns:
-
+          dict mapping ref name to:
+            error message if the ref failed to update
+            None if it was updated successfully
         """
         if CAPABILITY_SIDE_BAND_64K in capabilities:
             if progress is None:
@@ -609,7 +630,9 @@ class GitClient(object):
                 for pkt in proto.read_pkt_seq():
                     self._report_status_parser.handle_packet(pkt)
         if self._report_status_parser is not None:
-            self._report_status_parser.check()
+            return dict(self._report_status_parser.check())
+
+        return None
 
     def _negotiate_upload_pack_capabilities(self, server_capabilities):
         unknown_capabilities = (  # noqa: F841
@@ -798,13 +821,10 @@ class TraditionalGitClient(GitClient):
           progress: Optional callback called with progress updates
 
         Returns:
-          new_refs dictionary containing the changes that were made
-          {refname: new_ref}, including deleted refs.
+          SendPackResult
 
         Raises:
           SendPackError: if server rejects the pack data
-          UpdateRefsError: if the server supports report-status
-                         and rejects ref updates
 
         """
         proto, unused_can_read, stderr = self._connect(b'receive-pack', path)
@@ -813,7 +833,7 @@ class TraditionalGitClient(GitClient):
                 old_refs, server_capabilities = read_pkt_refs(proto)
             except HangupException:
                 _remote_error_from_stderr(stderr)
-            negotiated_capabilities = \
+            negotiated_capabilities, agent = \
                 self._negotiate_receive_pack_capabilities(server_capabilities)
             if CAPABILITY_REPORT_STATUS in negotiated_capabilities:
                 self._report_status_parser = ReportStatusParser()
@@ -827,7 +847,7 @@ class TraditionalGitClient(GitClient):
 
             if set(new_refs.items()).issubset(set(old_refs.items())):
                 proto.write_pkt_line(None)
-                return new_refs
+                return SendPackResult(new_refs, agent=agent, ref_status={})
 
             if CAPABILITY_DELETE_REFS not in server_capabilities:
                 # Server does not support deletions. Fail later.
@@ -836,21 +856,24 @@ class TraditionalGitClient(GitClient):
                     if sha == ZERO_SHA:
                         if CAPABILITY_REPORT_STATUS in negotiated_capabilities:
                             report_status_parser._ref_statuses.append(
-                                b'ng ' + sha +
+                                b'ng ' + ref +
                                 b' remote does not support deleting refs')
                             report_status_parser._ref_status_ok = False
                         del new_refs[ref]
 
             if new_refs is None:
                 proto.write_pkt_line(None)
-                return old_refs
+                return SendPackResult(old_refs, agent=agent, ref_status={})
 
             if len(new_refs) == 0 and len(orig_new_refs):
                 # NOOP - Original new refs filtered out by policy
                 proto.write_pkt_line(None)
                 if report_status_parser is not None:
-                    report_status_parser.check()
-                return old_refs
+                    ref_status = dict(report_status_parser.check())
+                else:
+                    ref_status = None
+                return SendPackResult(
+                    old_refs, agent=agent, ref_status=ref_status)
 
             (have, want) = self._handle_receive_pack_head(
                 proto, negotiated_capabilities, old_refs, new_refs)
@@ -862,9 +885,9 @@ class TraditionalGitClient(GitClient):
             if self._should_send_pack(new_refs):
                 write_pack_data(proto.write_file(), pack_data_count, pack_data)
 
-            self._handle_receive_pack_tail(
+            ref_status = self._handle_receive_pack_tail(
                 proto, negotiated_capabilities, progress)
-            return new_refs
+            return SendPackResult(new_refs, agent=agent, ref_status=ref_status)
 
     def fetch_pack(self, path, determine_wants, graph_walker, pack_data,
                    progress=None, depth=None):
@@ -1136,13 +1159,10 @@ class LocalGitClient(GitClient):
           progress: Optional progress function
 
         Returns:
-          new_refs dictionary containing the changes that were made
-          {refname: new_ref}, including deleted refs.
+          SendPackResult
 
         Raises:
           SendPackError: if server rejects the pack data
-          UpdateRefsError: if the server supports report-status
-                         and rejects ref updates
 
         """
         if not progress:
@@ -1163,23 +1183,27 @@ class LocalGitClient(GitClient):
 
             if (not want and
                     set(new_refs.items()).issubset(set(old_refs.items()))):
-                return new_refs
+                return SendPackResult(new_refs, ref_status={})
 
             target.object_store.add_pack_data(
                 *generate_pack_data(have, want, ofs_delta=True))
 
+            ref_status = {}
+
             for refname, new_sha1 in new_refs.items():
                 old_sha1 = old_refs.get(refname, ZERO_SHA)
                 if new_sha1 != ZERO_SHA:
                     if not target.refs.set_if_equals(
                             refname, old_sha1, new_sha1):
-                        progress('unable to set %s to %s' %
-                                 (refname, new_sha1))
+                        msg = 'unable to set %s to %s' % (refname, new_sha1)
+                        progress(msg)
+                        ref_status[refname] = msg
                 else:
                     if not target.refs.remove_if_equals(refname, old_sha1):
                         progress('unable to remove %s' % refname)
+                        ref_status[refname] = 'unable to remove'
 
-        return new_refs
+        return SendPackResult(new_refs, ref_status=ref_status)
 
     def fetch(self, path, target, determine_wants=None, progress=None,
               depth=None):
@@ -1696,20 +1720,17 @@ class HttpGitClient(GitClient):
           progress: Optional progress function
 
         Returns:
-          new_refs dictionary containing the changes that were made
-          {refname: new_ref}, including deleted refs.
+          SendPackResult
 
         Raises:
           SendPackError: if server rejects the pack data
-          UpdateRefsError: if the server supports report-status
-                         and rejects ref updates
 
         """
         url = self._get_url(path)
         old_refs, server_capabilities, url = self._discover_references(
             b"git-receive-pack", url)
-        negotiated_capabilities = self._negotiate_receive_pack_capabilities(
-                server_capabilities)
+        negotiated_capabilities, agent = (
+            self._negotiate_receive_pack_capabilities(server_capabilities))
         negotiated_capabilities.add(capability_agent())
 
         if CAPABILITY_REPORT_STATUS in negotiated_capabilities:
@@ -1718,9 +1739,9 @@ class HttpGitClient(GitClient):
         new_refs = update_refs(dict(old_refs))
         if new_refs is None:
             # Determine wants function is aborting the push.
-            return old_refs
+            return SendPackResult(old_refs, agent=agent, ref_status={})
         if set(new_refs.items()).issubset(set(old_refs.items())):
-            return new_refs
+            return SendPackResult(new_refs, agent=agent, ref_status={})
         if self.dumb:
             raise NotImplementedError(self.fetch_pack)
         req_data = BytesIO()
@@ -1736,9 +1757,10 @@ class HttpGitClient(GitClient):
                                          data=req_data.getvalue())
         try:
             resp_proto = Protocol(read, None)
-            self._handle_receive_pack_tail(
+            ref_status = self._handle_receive_pack_tail(
                 resp_proto, negotiated_capabilities, progress)
-            return new_refs
+            return SendPackResult(
+                new_refs, agent=agent, ref_status=ref_status)
         finally:
             resp.close()
 
@@ -1770,7 +1792,7 @@ class HttpGitClient(GitClient):
         if not wants:
             return FetchPackResult(refs, symrefs, agent)
         if self.dumb:
-            raise NotImplementedError(self.send_pack)
+            raise NotImplementedError(self.fetch_pack)
         req_data = BytesIO()
         req_proto = Protocol(None, req_data.write)
         (new_shallow, new_unshallow) = self._handle_upload_pack_head(

+ 1 - 1
dulwich/diff_tree.py

@@ -314,7 +314,7 @@ def _count_blocks(obj):
     block_truncate = block.truncate
     block_getvalue = block.getvalue
 
-    for c in chain(*obj.as_raw_chunks()):
+    for c in chain.from_iterable(obj.as_raw_chunks()):
         c = c.to_bytes(1, 'big')
         block_write(c)
         n += 1

+ 3 - 0
dulwich/errors.py

@@ -122,6 +122,9 @@ class SendPackError(GitProtocolError):
     """An error occurred during send_pack."""
 
 
+# N.B.: UpdateRefsError is no longer used and will be result in
+# Dulwich 0.21.
+# remove: >= 0.21
 class UpdateRefsError(GitProtocolError):
     """The server reported errors updating refs."""
 

+ 2 - 1
dulwich/file.py

@@ -23,7 +23,6 @@
 import io
 import os
 import sys
-import tempfile
 
 
 def ensure_dir_exists(dirname):
@@ -43,6 +42,8 @@ def _fancy_rename(oldname, newname):
             raise
         return
 
+    # Defer the tempfile import since it pulls in a lot of other things.
+    import tempfile
     # destination file exists
     try:
         (fd, tmpfile) = tempfile.mkstemp(".tmp", prefix=oldname, dir=".")

+ 152 - 0
dulwich/graph.py

@@ -0,0 +1,152 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# vim:ts=4:sw=4:softtabstop=4:smarttab:expandtab
+# Copyright (c) 2020 Kevin B. Hendricks, Stratford Ontario Canada
+#
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+
+"""
+Implementation of merge-base following the approach of git
+"""
+
+from collections import deque
+
+
+def _find_lcas(lookup_parents, c1, c2s):
+    cands = []
+    cstates = {}
+
+    # Flags to Record State
+    _ANC_OF_1 = 1  # ancestor of commit 1
+    _ANC_OF_2 = 2  # ancestor of commit 2
+    _DNC = 4       # Do Not Consider
+    _LCA = 8       # potential LCA
+
+    def _has_candidates(wlst, cstates):
+        for cmt in wlst:
+            if cmt in cstates:
+                if not (cstates[cmt] & _DNC):
+                    return True
+        return False
+
+    # initialize the working list
+    wlst = deque()
+    cstates[c1] = _ANC_OF_1
+    wlst.append(c1)
+    for c2 in c2s:
+        cstates[c2] = _ANC_OF_2
+        wlst.append(c2)
+
+    # loop until no other LCA candidates are viable in working list
+    # adding any parents to the list in a breadth first manner
+    while _has_candidates(wlst, cstates):
+        cmt = wlst.popleft()
+        flags = cstates[cmt]
+        if flags == (_ANC_OF_1 | _ANC_OF_2):
+            # potential common ancestor
+            if not (flags & _LCA):
+                flags = flags | _LCA
+                cstates[cmt] = flags
+                cands.append(cmt)
+                # mark any parents of this node _DNC as all parents
+                # would be one level further removed common ancestors
+                flags = flags | _DNC
+        parents = lookup_parents(cmt)
+        if parents:
+            for pcmt in parents:
+                if pcmt in cstates:
+                    cstates[pcmt] = cstates[pcmt] | flags
+                else:
+                    cstates[pcmt] = flags
+                wlst.append(pcmt)
+
+    # walk final candidates removing any superceded by _DNC by later lower LCAs
+    results = []
+    for cmt in cands:
+        if not (cstates[cmt] & _DNC):
+            results.append(cmt)
+    return results
+
+
+def find_merge_base(object_store, commit_ids):
+    """Find lowest common ancestors of commit_ids[0] and *any* of commits_ids[1:]
+
+    Args:
+      object_store: object store
+      commit_ids:  list of commit ids
+    Returns:
+      list of lowest common ancestor commit_ids
+    """
+    def lookup_parents(commit_id):
+        return object_store[commit_id].parents
+
+    if not commit_ids:
+        return []
+    c1 = commit_ids[0]
+    if not len(commit_ids) > 1:
+        return [c1]
+    c2s = commit_ids[1:]
+    if c1 in c2s:
+        return [c1]
+    return _find_lcas(lookup_parents, c1, c2s)
+
+
+def find_octopus_base(object_store, commit_ids):
+    """Find lowest common ancestors of *all* provided commit_ids
+
+    Args:
+      object_store: Object store
+      commit_ids:  list of commit ids
+    Returns:
+      list of lowest common ancestor commit_ids
+    """
+
+    def lookup_parents(commit_id):
+        return object_store[commit_id].parents
+
+    if not commit_ids:
+        return []
+    if len(commit_ids) <= 2:
+        return find_merge_base(object_store, commit_ids)
+    lcas = [commit_ids[0]]
+    others = commit_ids[1:]
+    for cmt in others:
+        next_lcas = []
+        for ca in lcas:
+            res = _find_lcas(lookup_parents, cmt, [ca])
+            next_lcas.extend(res)
+        lcas = next_lcas[:]
+    return lcas
+
+
+def can_fast_forward(object_store, c1, c2):
+    """Is it possible to fast-forward from c1 to c2?
+
+    Args:
+      object_store: Store to retrieve objects from
+      c1: Commit id for first commit
+      c2: Commit id for second commit
+    """
+    if c1 == c2:
+        return True
+
+    def lookup_parents(commit_id):
+        return object_store[commit_id].parents
+
+    # Algorithm: Find the common ancestor
+    lcas = _find_lcas(lookup_parents, c1, [c2])
+    return lcas == [c1]

+ 1 - 1
dulwich/hooks.py

@@ -22,7 +22,6 @@
 
 import os
 import subprocess
-import tempfile
 
 from dulwich.errors import (
     HookError,
@@ -137,6 +136,7 @@ class CommitMsgShellHook(ShellHook):
         filepath = os.path.join(controldir, 'hooks', 'commit-msg')
 
         def prepare_msg(*args):
+            import tempfile
             (fd, path) = tempfile.mkstemp()
 
             with os.fdopen(fd, 'wb') as f:

+ 13 - 2
dulwich/index.py

@@ -577,7 +577,7 @@ def build_index_from_tree(root_path, index_path, object_store, tree_id,
     index.write()
 
 
-def blob_from_path_and_stat(fs_path, st, tree_encoding='utf-8'):
+def blob_from_path_and_mode(fs_path, mode, tree_encoding='utf-8'):
     """Create a blob from a path and a stat object.
 
     Args:
@@ -587,7 +587,7 @@ def blob_from_path_and_stat(fs_path, st, tree_encoding='utf-8'):
     """
     assert isinstance(fs_path, bytes)
     blob = Blob()
-    if stat.S_ISLNK(st.st_mode):
+    if stat.S_ISLNK(mode):
         if sys.platform == 'win32':
             # os.readlink on Python3 on Windows requires a unicode string.
             fs_path = os.fsdecode(fs_path)
@@ -600,6 +600,17 @@ def blob_from_path_and_stat(fs_path, st, tree_encoding='utf-8'):
     return blob
 
 
+def blob_from_path_and_stat(fs_path, st, tree_encoding='utf-8'):
+    """Create a blob from a path and a stat object.
+
+    Args:
+      fs_path: Full file system path to file
+      st: A stat object
+    Returns: A `Blob` object
+    """
+    return blob_from_path_and_mode(fs_path, st.st_mode, tree_encoding)
+
+
 def read_submodule_head(path):
     """Read the head commit of a submodule.
 

+ 2 - 1
dulwich/object_store.py

@@ -26,7 +26,6 @@ from io import BytesIO
 import os
 import stat
 import sys
-import tempfile
 
 from dulwich.diff_tree import (
     tree_changes,
@@ -757,6 +756,7 @@ class DiskObjectStore(PackBasedObjectStore):
         Returns: A Pack object pointing at the now-completed thin pack in the
             objects/pack directory.
         """
+        import tempfile
         fd, path = tempfile.mkstemp(dir=self.path, prefix='tmp_pack_')
         with os.fdopen(fd, 'w+b') as f:
             indexer = PackIndexer(f, resolve_ext_ref=self.get_raw)
@@ -804,6 +804,7 @@ class DiskObjectStore(PackBasedObjectStore):
             call when the pack is finished and an abort
             function.
         """
+        import tempfile
         fd, path = tempfile.mkstemp(dir=self.pack_dir, suffix=".pack")
         f = os.fdopen(fd, 'wb')
 

+ 6 - 5
dulwich/objectspec.py

@@ -83,7 +83,7 @@ def parse_ref(container, refspec):
     raise KeyError(refspec)
 
 
-def parse_reftuple(lh_container, rh_container, refspec):
+def parse_reftuple(lh_container, rh_container, refspec, force=False):
     """Parse a reftuple spec.
 
     Args:
@@ -98,8 +98,6 @@ def parse_reftuple(lh_container, rh_container, refspec):
     if refspec.startswith(b"+"):
         force = True
         refspec = refspec[1:]
-    else:
-        force = False
     if b":" in refspec:
         (lh, rh) = refspec.split(b":")
     else:
@@ -120,13 +118,15 @@ def parse_reftuple(lh_container, rh_container, refspec):
     return (lh, rh, force)
 
 
-def parse_reftuples(lh_container, rh_container, refspecs):
+def parse_reftuples(
+        lh_container, rh_container, refspecs, force=False):
     """Parse a list of reftuple specs to a list of reftuples.
 
     Args:
       lh_container: A RefsContainer object
       hh_container: A RefsContainer object
       refspecs: A list of refspecs or a string
+      force: Force overwriting for all reftuples
     Returns: A list of refs
     Raises:
       KeyError: If one of the refs can not be found
@@ -136,7 +136,8 @@ def parse_reftuples(lh_container, rh_container, refspecs):
     ret = []
     # TODO: Support * in refspecs
     for refspec in refspecs:
-        ret.append(parse_reftuple(lh_container, rh_container, refspec))
+        ret.append(parse_reftuple(
+            lh_container, rh_container, refspec, force=force))
     return ret
 
 

+ 80 - 27
dulwich/porcelain.py

@@ -96,7 +96,9 @@ from dulwich.diff_tree import (
     )
 from dulwich.errors import (
     SendPackError,
-    UpdateRefsError,
+    )
+from dulwich.graph import (
+    can_fast_forward,
     )
 from dulwich.ignore import IgnoreFilterManager
 from dulwich.index import (
@@ -173,7 +175,15 @@ default_bytes_err_stream = (
 DEFAULT_ENCODING = 'utf-8'
 
 
-class RemoteExists(Exception):
+class Error(Exception):
+    """Porcelain-based error. """
+
+    def __init__(self, msg, inner=None):
+        super(Error, self).__init__(msg)
+        self.inner = inner
+
+
+class RemoteExists(Error):
     """Raised when the remote already exists."""
 
 
@@ -218,6 +228,26 @@ def path_to_tree_path(repopath, path, tree_encoding=DEFAULT_ENCODING):
         return bytes(relpath)
 
 
+class DivergedBranches(Error):
+    """Branches have diverged and fast-forward is not possible."""
+
+
+def check_diverged(store, current_sha, new_sha):
+    """Check if updating to a sha can be done with fast forwarding.
+
+    Args:
+      store: Object store
+      current_sha: Current head sha
+      new_sha: New head sha
+    """
+    try:
+        can = can_fast_forward(store, current_sha, new_sha)
+    except KeyError:
+        can = False
+    if not can:
+        raise DivergedBranches(current_sha, new_sha)
+
+
 def archive(repo, committish=None, outstream=default_bytes_out_stream,
             errstream=default_bytes_err_stream):
     """Create an archive.
@@ -260,7 +290,7 @@ def symbolic_ref(repo, ref_name, force=False):
     with open_repo_closing(repo) as repo_obj:
         ref_path = _make_branch_ref(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)
+            raise Error('fatal: ref `%s` is not a ref' % ref_name)
         repo_obj.refs.set_symbolic_ref(b'HEAD', ref_path)
 
 
@@ -346,7 +376,7 @@ def clone(source, target=None, bare=False, checkout=None,
     if checkout is None:
         checkout = (not bare)
     if checkout and bare:
-        raise ValueError("checkout and bare are incompatible")
+        raise Error("checkout and bare are incompatible")
 
     if target is None:
         target = source.split("/")[-1]
@@ -448,7 +478,7 @@ def clean(repo=".", target_dir=None):
 
     with open_repo_closing(repo) as r:
         if not _is_subdir(target_dir, r.path):
-            raise ValueError("target_dir must be in the repo's working dir")
+            raise Error("target_dir must be in the repo's working dir")
 
         index = r.open_index()
         ignore_manager = IgnoreFilterManager.from_repo(r)
@@ -489,7 +519,7 @@ def remove(repo=".", paths=None, cached=False):
             try:
                 index_sha = index[tree_path].sha
             except KeyError:
-                raise Exception('%s did not match any files' % p)
+                raise Error('%s did not match any files' % p)
 
             if not cached:
                 try:
@@ -509,12 +539,12 @@ def remove(repo=".", paths=None, cached=False):
                             committed_sha = None
 
                         if blob.id != index_sha and index_sha != committed_sha:
-                            raise Exception(
+                            raise Error(
                                 'file has staged content differing '
                                 'from both the file and head: %s' % p)
 
                         if index_sha != committed_sha:
-                            raise Exception(
+                            raise Error(
                                 'file has staged changes: %s' % p)
                         os.remove(full_path)
             del index[tree_path]
@@ -854,7 +884,7 @@ def tag_delete(repo, name):
         elif isinstance(name, list):
             names = name
         else:
-            raise TypeError("Unexpected tag name type %r" % name)
+            raise Error("Unexpected tag name type %r" % name)
         for name in names:
             del r.refs[_make_tag_ref(name)]
 
@@ -869,7 +899,7 @@ def reset(repo, mode, treeish="HEAD"):
     """
 
     if mode != "hard":
-        raise ValueError("hard is the only mode currently supported")
+        raise Error("hard is the only mode currently supported")
 
     with open_repo_closing(repo) as r:
         tree = parse_tree(r, treeish)
@@ -905,7 +935,8 @@ def get_remote_repo(
 
 def push(repo, remote_location=None, refspecs=None,
          outstream=default_bytes_out_stream,
-         errstream=default_bytes_err_stream, **kwargs):
+         errstream=default_bytes_err_stream,
+         force=False, **kwargs):
     """Remote push with dulwich via dulwich.client
 
     Args:
@@ -914,6 +945,7 @@ def push(repo, remote_location=None, refspecs=None,
       refspecs: Refs to push to remote
       outstream: A stream file to write output
       errstream: A stream file to write errors
+      force: Force overwriting refs
     """
 
     # Open the repo
@@ -928,14 +960,17 @@ def push(repo, remote_location=None, refspecs=None,
         remote_changed_refs = {}
 
         def update_refs(refs):
-            selected_refs.extend(parse_reftuples(r.refs, refs, refspecs))
+            selected_refs.extend(parse_reftuples(
+                r.refs, refs, refspecs, force=force))
             new_refs = {}
             # TODO: Handle selected_refs == {None: None}
-            for (lh, rh, force) in selected_refs:
+            for (lh, rh, force_ref) in selected_refs:
                 if lh is None:
                     new_refs[rh] = ZERO_SHA
                     remote_changed_refs[rh] = None
                 else:
+                    if not force_ref:
+                        check_diverged(r.object_store, refs[rh], r.refs[lh])
                     new_refs[rh] = r.refs[lh]
                     remote_changed_refs[rh] = r.refs[lh]
             return new_refs
@@ -943,19 +978,24 @@ def push(repo, remote_location=None, refspecs=None,
         err_encoding = getattr(errstream, 'encoding', None) or DEFAULT_ENCODING
         remote_location_bytes = client.get_url(path).encode(err_encoding)
         try:
-            client.send_pack(
+            result = client.send_pack(
                 path, update_refs,
                 generate_pack_data=r.generate_pack_data,
                 progress=errstream.write)
             errstream.write(
                 b"Push to " + remote_location_bytes + b" successful.\n")
-        except UpdateRefsError as e:
-            errstream.write(b"Push to " + remote_location_bytes +
-                            b" failed -> " + e.message.encode(err_encoding) +
-                            b"\n")
         except SendPackError as e:
-            errstream.write(b"Push to " + remote_location_bytes +
-                            b" failed -> " + e.args[0] + b"\n")
+            raise Error(
+                "Push to " + remote_location_bytes +
+                " failed -> " + e.args[0].decode(), inner=e)
+
+        for ref, error in (result.ref_status or {}).items():
+            if status is not None:
+                errstream.write(
+                    b"Push of ref %s failed: %s" %
+                    (ref, error.encode(err_encoding)))
+            else:
+                errstream.write(b'Ref %s updated' % ref)
 
         if remote_name is not None:
             _import_remote_refs(r.refs, remote_name, remote_changed_refs)
@@ -963,7 +1003,8 @@ def push(repo, remote_location=None, refspecs=None,
 
 def pull(repo, remote_location=None, refspecs=None,
          outstream=default_bytes_out_stream,
-         errstream=default_bytes_err_stream, **kwargs):
+         errstream=default_bytes_err_stream, fast_forward=True,
+         force=False, **kwargs):
     """Pull from remote via dulwich.client
 
     Args:
@@ -983,13 +1024,23 @@ def pull(repo, remote_location=None, refspecs=None,
 
         def determine_wants(remote_refs):
             selected_refs.extend(
-                parse_reftuples(remote_refs, r.refs, refspecs))
-            return [remote_refs[lh] for (lh, rh, force) in selected_refs]
+                parse_reftuples(remote_refs, r.refs, refspecs, force=force))
+            return [
+                remote_refs[lh] for (lh, rh, force_ref) in selected_refs
+                if remote_refs[lh] not in r.object_store]
         client, path = get_transport_and_path(
                 remote_location, config=r.get_config_stack(), **kwargs)
         fetch_result = client.fetch(
             path, r, progress=errstream.write, determine_wants=determine_wants)
-        for (lh, rh, force) in selected_refs:
+        for (lh, rh, force_ref) in selected_refs:
+            try:
+                check_diverged(
+                    r.object_store, r.refs[rh], fetch_result.refs[lh])
+            except DivergedBranches:
+                if fast_forward:
+                    raise
+                else:
+                    raise NotImplementedError('merge is not yet supported')
             r.refs[rh] = fetch_result.refs[lh]
         if selected_refs:
             r[b'HEAD'] = fetch_result.refs[selected_refs[0][1]]
@@ -1105,7 +1156,7 @@ def get_tree_changes(repo):
             elif change[0][0] == change[0][1]:
                 tracked_changes['modify'].append(change[0][0])
             else:
-                raise AssertionError('git mv ops not yet supported')
+                raise NotImplementedError('git mv ops not yet supported')
         return tracked_changes
 
 
@@ -1242,7 +1293,8 @@ def branch_create(repo, name, objectish=None, force=False):
             r.refs.set_if_equals(refname, None, object.id, message=ref_message)
         else:
             if not r.refs.add_if_new(refname, object.id, message=ref_message):
-                raise KeyError("Branch with name %s already exists." % name)
+                raise Error(
+                    "Branch with name %s already exists." % name)
 
 
 def branch_list(repo):
@@ -1315,7 +1367,8 @@ def _import_remote_refs(
 
 def fetch(repo, remote_location=None,
           outstream=sys.stdout, errstream=default_bytes_err_stream,
-          message=None, depth=None, prune=False, prune_tags=False, **kwargs):
+          message=None, depth=None, prune=False, prune_tags=False, force=False,
+          **kwargs):
     """Fetch objects from a remote server.
 
     Args:

+ 26 - 19
dulwich/server.py

@@ -219,10 +219,10 @@ class FileSystemBackend(Backend):
 class Handler(object):
     """Smart protocol command handler base class."""
 
-    def __init__(self, backend, proto, http_req=None):
+    def __init__(self, backend, proto, stateless_rpc=None):
         self.backend = backend
         self.proto = proto
-        self.http_req = http_req
+        self.stateless_rpc = stateless_rpc
 
     def handle(self):
         raise NotImplementedError(self.handle)
@@ -231,8 +231,8 @@ class Handler(object):
 class PackHandler(Handler):
     """Protocol handler for packs."""
 
-    def __init__(self, backend, proto, http_req=None):
-        super(PackHandler, self).__init__(backend, proto, http_req)
+    def __init__(self, backend, proto, stateless_rpc=None):
+        super(PackHandler, self).__init__(backend, proto, stateless_rpc)
         self._client_capabilities = None
         # Flags needed for the no-done capability
         self._done_received = False
@@ -286,10 +286,10 @@ class PackHandler(Handler):
 class UploadPackHandler(PackHandler):
     """Protocol handler for uploading a pack to the client."""
 
-    def __init__(self, backend, args, proto, http_req=None,
+    def __init__(self, backend, args, proto, stateless_rpc=None,
                  advertise_refs=False):
         super(UploadPackHandler, self).__init__(
-                backend, proto, http_req=http_req)
+                backend, proto, stateless_rpc=stateless_rpc)
         self.repo = backend.open_repository(args[0])
         self._graph_walker = None
         self.advertise_refs = advertise_refs
@@ -355,8 +355,14 @@ class UploadPackHandler(PackHandler):
         graph_walker = _ProtocolGraphWalker(
                 self, self.repo.object_store, self.repo.get_peeled,
                 self.repo.refs.get_symrefs)
+        wants = []
+
+        def wants_wrapper(refs):
+            wants.extend(graph_walker.determine_wants(refs))
+            return wants
+
         objects_iter = self.repo.fetch_objects(
-            graph_walker.determine_wants, graph_walker, self.progress,
+            wants_wrapper, graph_walker, self.progress,
             get_tagged=self.get_tagged)
 
         # Note the fact that client is only processing responses related
@@ -370,7 +376,7 @@ class UploadPackHandler(PackHandler):
         # with a graph walker with an implementation that talks over the
         # wire (which is this instance of this class) this will actually
         # iterate through everything and write things out to the wire.
-        if len(objects_iter) == 0:
+        if len(wants) == 0:
             return
 
         # The provided haves are processed, and it is safe to send side-
@@ -533,7 +539,7 @@ class _ProtocolGraphWalker(object):
         self.get_peeled = get_peeled
         self.get_symrefs = get_symrefs
         self.proto = handler.proto
-        self.http_req = handler.http_req
+        self.stateless_rpc = handler.stateless_rpc
         self.advertise_refs = handler.advertise_refs
         self._wants = []
         self.shallow = set()
@@ -548,7 +554,7 @@ class _ProtocolGraphWalker(object):
         """Determine the wants for a set of heads.
 
         The given heads are advertised to the client, who then specifies which
-        refs he wants using 'want' lines. This portion of the protocol is the
+        refs they want using 'want' lines. This portion of the protocol is the
         same regardless of ack type, and in fact is used to set the ack type of
         the ProtocolGraphWalker.
 
@@ -564,7 +570,7 @@ class _ProtocolGraphWalker(object):
         """
         symrefs = self.get_symrefs()
         values = set(heads.values())
-        if self.advertise_refs or not self.http_req:
+        if self.advertise_refs or not self.stateless_rpc:
             for i, (ref, sha) in enumerate(sorted(heads.items())):
                 try:
                     peeled_sha = self.get_peeled(ref)
@@ -613,7 +619,7 @@ class _ProtocolGraphWalker(object):
             self.unread_proto_line(command, sha)
             self._handle_shallow_request(want_revs)
 
-        if self.http_req and self.proto.eof():
+        if self.stateless_rpc and self.proto.eof():
             # The client may close the socket at this point, expecting a
             # flush-pkt from the server. We might be ready to send a packfile
             # at this point, so we need to explicitly short-circuit in this
@@ -638,7 +644,7 @@ class _ProtocolGraphWalker(object):
 
     def next(self):
         if not self._cached:
-            if not self._impl and self.http_req:
+            if not self._impl and self.stateless_rpc:
                 return None
             return next(self._impl)
         self._cache_index += 1
@@ -847,7 +853,7 @@ class MultiAckDetailedGraphWalkerImpl(object):
                 if self.walker.all_wants_satisfied(self._common):
                     self.walker.send_ack(self._common[-1], b'ready')
                 self.walker.send_nak()
-                if self.walker.http_req:
+                if self.walker.stateless_rpc:
                     # The HTTP version of this request a flush-pkt always
                     # signifies an end of request, so we also return
                     # nothing here as if we are done (but not really, as
@@ -896,10 +902,10 @@ class MultiAckDetailedGraphWalkerImpl(object):
 class ReceivePackHandler(PackHandler):
     """Protocol handler for downloading a pack from the client."""
 
-    def __init__(self, backend, args, proto, http_req=None,
+    def __init__(self, backend, args, proto, stateless_rpc=None,
                  advertise_refs=False):
         super(ReceivePackHandler, self).__init__(
-                backend, proto, http_req=http_req)
+                backend, proto, stateless_rpc=stateless_rpc)
         self.repo = backend.open_repository(args[0])
         self.advertise_refs = advertise_refs
 
@@ -999,7 +1005,7 @@ class ReceivePackHandler(PackHandler):
             self.proto.write_sideband(SIDE_BAND_CHANNEL_FATAL, repr(err))
 
     def handle(self) -> None:
-        if self.advertise_refs or not self.http_req:
+        if self.advertise_refs or not self.stateless_rpc:
             refs = sorted(self.repo.get_refs().items())
             symrefs = sorted(self.repo.refs.get_symrefs().items())
 
@@ -1045,8 +1051,9 @@ class ReceivePackHandler(PackHandler):
 
 class UploadArchiveHandler(Handler):
 
-    def __init__(self, backend, args, proto, http_req=None):
-        super(UploadArchiveHandler, self).__init__(backend, proto, http_req)
+    def __init__(self, backend, args, proto, stateless_rpc=None):
+        super(UploadArchiveHandler, self).__init__(
+            backend, proto, stateless_rpc)
         self.repo = backend.open_repository(args[0])
 
     def handle(self):

+ 1 - 0
dulwich/tests/__init__.py

@@ -108,6 +108,7 @@ def self_test_suite():
         'fastexport',
         'file',
         'grafts',
+        'graph',
         'greenthreads',
         'hooks',
         'ignore',

+ 27 - 23
dulwich/tests/compat/test_client.py

@@ -38,7 +38,6 @@ import http.server
 
 from dulwich import (
     client,
-    errors,
     file,
     index,
     protocol,
@@ -189,15 +188,11 @@ class DulwichClientTestBase(object):
         with repo.Repo(repo_dir) as src:
             sendrefs, gen_pack = self.compute_send(src)
             c = self._client()
-            try:
-                c.send_pack(self._build_path('/dest'), lambda _: sendrefs,
-                            gen_pack)
-            except errors.UpdateRefsError as e:
-                self.assertEqual('refs/heads/master failed to update',
-                                 e.args[0])
-                self.assertEqual({b'refs/heads/branch': b'ok',
-                                  b'refs/heads/master': b'non-fast-forward'},
-                                 e.ref_status)
+            result = c.send_pack(
+                self._build_path('/dest'), lambda _: sendrefs, gen_pack)
+            self.assertEqual({b'refs/heads/branch': None,
+                              b'refs/heads/master': 'non-fast-forward'},
+                             result.ref_status)
 
     def test_send_pack_multiple_errors(self):
         dest, dummy = self.disable_ff_and_make_dummy_commit()
@@ -208,19 +203,11 @@ class DulwichClientTestBase(object):
         with repo.Repo(repo_dir) as src:
             sendrefs, gen_pack = self.compute_send(src)
             c = self._client()
-            try:
-                c.send_pack(self._build_path('/dest'), lambda _: sendrefs,
-                            gen_pack)
-            except errors.UpdateRefsError as e:
-                self.assertIn(
-                        str(e),
-                        ['{0}, {1} failed to update'.format(
-                            branch.decode('ascii'), master.decode('ascii')),
-                         '{1}, {0} failed to update'.format(
-                             branch.decode('ascii'), master.decode('ascii'))])
-                self.assertEqual({branch: b'non-fast-forward',
-                                  master: b'non-fast-forward'},
-                                 e.ref_status)
+            result = c.send_pack(
+                self._build_path('/dest'), lambda _: sendrefs, gen_pack)
+            self.assertEqual({branch: 'non-fast-forward',
+                              master: 'non-fast-forward'},
+                             result.ref_status)
 
     def test_archive(self):
         c = self._client()
@@ -262,6 +249,23 @@ class DulwichClientTestBase(object):
                 dest.refs.set_if_equals(r[0], None, r[1])
             self.assertDestEqualsSrc()
 
+    def test_fetch_empty_pack(self):
+        c = self._client()
+        with repo.Repo(os.path.join(self.gitroot, 'dest')) as dest:
+            result = c.fetch(self._build_path('/server_new.export'), dest)
+            for r in result.refs.items():
+                dest.refs.set_if_equals(r[0], None, r[1])
+            self.assertDestEqualsSrc()
+
+            def dw(refs):
+                return list(refs.values())
+            result = c.fetch(
+                self._build_path('/server_new.export'), dest,
+                determine_wants=dw)
+            for r in result.refs.items():
+                dest.refs.set_if_equals(r[0], None, r[1])
+            self.assertDestEqualsSrc()
+
     def test_incremental_fetch_pack(self):
         self.test_fetch_pack()
         dest, dummy = self.disable_ff_and_make_dummy_commit()

+ 4 - 2
dulwich/tests/compat/test_repository.py

@@ -117,8 +117,10 @@ class ObjectStoreTestCase(CompatTestCase):
 
     def test_packed_objects(self):
         expected_shas = self._get_all_shas() - self._get_loose_shas()
-        self.assertShasMatch(expected_shas,
-                             chain(*self._repo.object_store.packs))
+        self.assertShasMatch(
+            expected_shas,
+            chain.from_iterable(self._repo.object_store.packs)
+        )
 
     def test_all_objects(self):
         expected_shas = self._get_all_shas()

+ 1 - 1
dulwich/tests/compat/test_server.py

@@ -85,7 +85,7 @@ class GitServerSideBand64kTestCase(GitServerTestCase):
 
     def setUp(self):
         super(GitServerSideBand64kTestCase, self).setUp()
-        # side-band-64k is broken in the widows client.
+        # side-band-64k is broken in the windows client.
         # https://github.com/msysgit/git/issues/101
         # Fix has landed for the 1.9.3 release.
         if os.name == 'nt':

+ 25 - 16
dulwich/tests/test_client.py

@@ -48,7 +48,6 @@ from dulwich.client import (
     StrangeHostname,
     SubprocessSSHVendor,
     PLinkSSHVendor,
-    UpdateRefsError,
     check_wants,
     default_urllib3_manager,
     get_credentials_from_store,
@@ -221,9 +220,11 @@ class GitClientTests(TestCase):
         def generate_pack_data(have, want, ofs_delta=False):
             return pack_objects_to_data([(commit, None), (tree, ''), ])
 
-        self.assertRaises(UpdateRefsError,
-                          self.client.send_pack, "blah",
-                          update_refs, generate_pack_data)
+        result = self.client.send_pack("blah", update_refs, generate_pack_data)
+        self.assertEqual(
+            {b'refs/foo/bar': 'pre-receive hook declined'},
+            result.ref_status)
+        self.assertEqual({b'refs/foo/bar': commit.id}, result.refs)
 
     def test_send_pack_none(self):
         # Set ref to current value
@@ -377,9 +378,14 @@ class GitClientTests(TestCase):
         def generate_pack_data(have, want, ofs_delta=False):
             return 0, []
 
-        self.assertRaises(UpdateRefsError,
-                          self.client.send_pack, b"/",
-                          update_refs, generate_pack_data)
+        result = self.client.send_pack(b"/", update_refs, generate_pack_data)
+        self.assertEqual(
+            result.ref_status,
+            {b'refs/heads/master': 'remote does not support deleting refs'})
+        self.assertEqual(
+            result.refs,
+            {b'refs/heads/master':
+             b'310ca9477129b8586fa2afc779c1f57cf64bba6c'})
         self.assertEqual(self.rout.getvalue(), b'0000')
 
 
@@ -759,21 +765,22 @@ class ReportStatusParserTests(TestCase):
         parser.handle_packet(b"unpack error - foo bar")
         parser.handle_packet(b"ok refs/foo/bar")
         parser.handle_packet(None)
-        self.assertRaises(SendPackError, parser.check)
+        self.assertRaises(SendPackError, list, parser.check())
 
     def test_update_refs_error(self):
         parser = ReportStatusParser()
         parser.handle_packet(b"unpack ok")
         parser.handle_packet(b"ng refs/foo/bar need to pull")
         parser.handle_packet(None)
-        self.assertRaises(UpdateRefsError, parser.check)
+        self.assertEqual(
+            [(b'refs/foo/bar', 'need to pull')], list(parser.check()))
 
     def test_ok(self):
         parser = ReportStatusParser()
         parser.handle_packet(b"unpack ok")
         parser.handle_packet(b"ok refs/foo/bar")
         parser.handle_packet(None)
-        parser.check()
+        self.assertEqual([(b'refs/foo/bar', None)], list(parser.check()))
 
 
 class LocalGitClientTests(TestCase):
@@ -867,14 +874,16 @@ class LocalGitClientTests(TestCase):
         """Send branch from local to remote repository and verify it worked."""
         client = LocalGitClient()
         ref_name = b"refs/heads/" + branch
-        new_refs = client.send_pack(target.path,
-                                    lambda _: {ref_name: local.refs[ref_name]},
-                                    local.generate_pack_data)
+        result = client.send_pack(target.path,
+                                  lambda _: {ref_name: local.refs[ref_name]},
+                                  local.generate_pack_data)
 
-        self.assertEqual(local.refs[ref_name], new_refs[ref_name])
+        self.assertEqual(local.refs[ref_name], result.refs[ref_name])
+        self.assertIs(None, result.agent)
+        self.assertEqual({}, result.ref_status)
 
-        obj_local = local.get_object(new_refs[ref_name])
-        obj_target = target.get_object(new_refs[ref_name])
+        obj_local = local.get_object(result.refs[ref_name])
+        obj_target = target.get_object(result.refs[ref_name])
         self.assertEqual(obj_local, obj_target)
 
 

+ 184 - 0
dulwich/tests/test_graph.py

@@ -0,0 +1,184 @@
+# -*- coding: utf-8 -*-
+# test_index.py -- Tests for merge
+# encoding: utf-8
+# Copyright (c) 2020 Kevin B. Hendricks, Stratford Ontario Canada
+#
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+
+"""Tests for dulwich.graph."""
+
+from dulwich.tests import TestCase
+from dulwich.tests.utils import make_commit
+from dulwich.object_store import MemoryObjectStore
+
+from dulwich.graph import _find_lcas, can_fast_forward
+
+
+class FindMergeBaseTests(TestCase):
+
+    @staticmethod
+    def run_test(dag, inputs):
+        def lookup_parents(commit_id):
+            return dag[commit_id]
+        c1 = inputs[0]
+        c2s = inputs[1:]
+        return set(_find_lcas(lookup_parents, c1, c2s))
+
+    def test_multiple_lca(self):
+        # two lowest common ancestors
+        graph = {
+            '5': ['1', '2'],
+            '4': ['3', '1'],
+            '3': ['2'],
+            '2': ['0'],
+            '1': [],
+            '0': []
+        }
+        self.assertEqual(self.run_test(graph, ['4', '5']), set(['1', '2']))
+
+    def test_no_common_ancestor(self):
+        # no common ancestor
+        graph = {
+            '4': ['2'],
+            '3': ['1'],
+            '2': [],
+            '1': ['0'],
+            '0': [],
+        }
+        self.assertEqual(self.run_test(graph, ['4', '3']), set([]))
+
+    def test_ancestor(self):
+        # ancestor
+        graph = {
+            'G': ['D', 'F'],
+            'F': ['E'],
+            'D': ['C'],
+            'C': ['B'],
+            'E': ['B'],
+            'B': ['A'],
+            'A': []
+        }
+        self.assertEqual(self.run_test(graph, ['D', 'C']), set(['C']))
+
+    def test_direct_parent(self):
+        # parent
+        graph = {
+            'G': ['D', 'F'],
+            'F': ['E'],
+            'D': ['C'],
+            'C': ['B'],
+            'E': ['B'],
+            'B': ['A'],
+            'A': []
+        }
+        self.assertEqual(self.run_test(graph, ['G', 'D']), set(['D']))
+
+    def test_another_crossover(self):
+        # Another cross over
+        graph = {
+            'G': ['D', 'F'],
+            'F': ['E', 'C'],
+            'D': ['C', 'E'],
+            'C': ['B'],
+            'E': ['B'],
+            'B': ['A'],
+            'A': []
+        }
+        self.assertEqual(self.run_test(graph, ['D', 'F']), set(['E', 'C']))
+
+    def test_three_way_merge_lca(self):
+        # three way merge commit straight from git docs
+        graph = {
+            'C': ['C1'],
+            'C1': ['C2'],
+            'C2': ['C3'],
+            'C3': ['C4'],
+            'C4': ['2'],
+            'B': ['B1'],
+            'B1': ['B2'],
+            'B2': ['B3'],
+            'B3': ['1'],
+            'A': ['A1'],
+            'A1': ['A2'],
+            'A2': ['A3'],
+            'A3': ['1'],
+            '1': ['2'],
+            '2': [],
+        }
+        # assumes a theoretical merge M exists that merges B and C first
+        # which actually means find the first LCA from either of B OR C with A
+        self.assertEqual(self.run_test(graph, ['A', 'B', 'C']), set(['1']))
+
+    def test_octopus(self):
+        # octopus algorithm test
+        # test straight from git docs of A, B, and C
+        # but this time use octopus to find lcas of A, B, and C simultaneously
+        graph = {
+            'C': ['C1'],
+            'C1': ['C2'],
+            'C2': ['C3'],
+            'C3': ['C4'],
+            'C4': ['2'],
+            'B': ['B1'],
+            'B1': ['B2'],
+            'B2': ['B3'],
+            'B3': ['1'],
+            'A': ['A1'],
+            'A1': ['A2'],
+            'A2': ['A3'],
+            'A3': ['1'],
+            '1': ['2'],
+            '2': [],
+        }
+
+        def lookup_parents(cid):
+            return graph[cid]
+        lcas = ['A']
+        others = ['B', 'C']
+        for cmt in others:
+            next_lcas = []
+            for ca in lcas:
+                res = _find_lcas(lookup_parents, cmt, [ca])
+                next_lcas.extend(res)
+            lcas = next_lcas[:]
+        self.assertEqual(set(lcas), set(['2']))
+
+
+class CanFastForwardTests(TestCase):
+
+    def test_ff(self):
+        store = MemoryObjectStore()
+        base = make_commit()
+        c1 = make_commit(parents=[base.id])
+        c2 = make_commit(parents=[c1.id])
+        store.add_objects([(base, None), (c1, None), (c2, None)])
+        self.assertTrue(can_fast_forward(store, c1.id, c1.id))
+        self.assertTrue(can_fast_forward(store, base.id, c1.id))
+        self.assertTrue(can_fast_forward(store, c1.id, c2.id))
+        self.assertFalse(can_fast_forward(store, c2.id, c1.id))
+
+    def test_diverged(self):
+        store = MemoryObjectStore()
+        base = make_commit()
+        c1 = make_commit(parents=[base.id])
+        c2a = make_commit(parents=[c1.id], message=b'2a')
+        c2b = make_commit(parents=[c1.id], message=b'2b')
+        store.add_objects([(base, None), (c1, None), (c2a, None), (c2b, None)])
+        self.assertTrue(can_fast_forward(store, c1.id, c2a.id))
+        self.assertTrue(can_fast_forward(store, c1.id, c2b.id))
+        self.assertFalse(can_fast_forward(store, c2a.id, c2b.id))
+        self.assertFalse(can_fast_forward(store, c2b.id, c2a.id))

+ 5 - 0
dulwich/tests/test_objectspec.py

@@ -178,6 +178,8 @@ class ParseReftupleTests(TestCase):
                          parse_reftuple(r, r, b"+foo"))
         self.assertEqual((b"refs/heads/foo", b"refs/heads/foo", True),
                          parse_reftuple(r, {}, b"+foo"))
+        self.assertEqual((b"refs/heads/foo", b"refs/heads/foo", True),
+                         parse_reftuple(r, {}, b"foo", True))
 
     def test_full(self):
         r = {b"refs/heads/foo": "bla"}
@@ -216,6 +218,9 @@ class ParseReftuplesTests(TestCase):
         r = {b"refs/heads/foo": "bla"}
         self.assertEqual([(b"refs/heads/foo", b"refs/heads/foo", False)],
                          parse_reftuples(r, r, b"refs/heads/foo"))
+        r = {b"refs/heads/foo": "bla"}
+        self.assertEqual([(b"refs/heads/foo", b"refs/heads/foo", True)],
+                         parse_reftuples(r, r, b"refs/heads/foo", True))
 
 
 class ParseTreeTests(TestCase):

+ 89 - 6
dulwich/tests/test_porcelain.py

@@ -314,7 +314,7 @@ class CloneTests(PorcelainTestCase):
         errstream = BytesIO()
         self.addCleanup(shutil.rmtree, target_path)
         self.assertRaises(
-            ValueError, porcelain.clone, self.repo.path,
+            porcelain.Error, porcelain.clone, self.repo.path,
             target_path, checkout=True, bare=True, errstream=errstream)
 
     def test_no_head_no_checkout(self):
@@ -660,8 +660,9 @@ class SymbolicRefTests(PorcelainTestCase):
                 self.repo.object_store, [[1], [2, 1], [3, 1, 2]])
         self.repo.refs[b"HEAD"] = c3.id
 
-        self.assertRaises(ValueError, porcelain.symbolic_ref, self.repo.path,
-                          b'foobar')
+        self.assertRaises(
+            porcelain.Error, porcelain.symbolic_ref, self.repo.path,
+            b'foobar')
 
     def test_set_force_wrong_symbolic_ref(self):
         c1, c2, c3 = build_commit_graph(
@@ -955,6 +956,52 @@ class PushTests(PorcelainTestCase):
             b'refs/heads/master': new_id,
             }, self.repo.get_refs())
 
+    def test_diverged(self):
+        outstream = BytesIO()
+        errstream = BytesIO()
+
+        porcelain.commit(repo=self.repo.path, message=b'init',
+                         author=b'author <email>',
+                         committer=b'committer <email>')
+
+        # Setup target repo cloned from temp test repo
+        clone_path = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, clone_path)
+        target_repo = porcelain.clone(self.repo.path, target=clone_path,
+                                      errstream=errstream)
+        target_repo.close()
+
+        remote_id = porcelain.commit(
+            repo=self.repo.path, message=b'remote change',
+            author=b'author <email>',
+            committer=b'committer <email>')
+
+        local_id = porcelain.commit(
+            repo=clone_path, message=b'local change',
+            author=b'author <email>',
+            committer=b'committer <email>')
+
+        # Push to the remote
+        self.assertRaises(
+            porcelain.DivergedBranches,
+            porcelain.push, clone_path, self.repo.path, b'refs/heads/master',
+            outstream=outstream, errstream=errstream)
+
+        self.assertEqual({
+            b'HEAD': remote_id,
+            b'refs/heads/master': remote_id,
+            }, self.repo.get_refs())
+
+        # Push to the remote with --force
+        porcelain.push(
+            clone_path, self.repo.path, b'refs/heads/master',
+            outstream=outstream, errstream=errstream, force=True)
+
+        self.assertEqual({
+            b'HEAD': local_id,
+            b'refs/heads/master': local_id,
+            }, self.repo.get_refs())
+
 
 class PullTests(PorcelainTestCase):
 
@@ -983,8 +1030,8 @@ class PullTests(PorcelainTestCase):
                          author=b'test2 <email>',
                          committer=b'test2 <email>')
 
-        self.assertTrue(b'refs/heads/master' in self.repo.refs)
-        self.assertTrue(b'refs/heads/master' in target_repo.refs)
+        self.assertIn(b'refs/heads/master', self.repo.refs)
+        self.assertIn(b'refs/heads/master', target_repo.refs)
 
     def test_simple(self):
         outstream = BytesIO()
@@ -998,6 +1045,40 @@ class PullTests(PorcelainTestCase):
         with Repo(self.target_path) as r:
             self.assertEqual(r[b'HEAD'].id, self.repo[b'HEAD'].id)
 
+    def test_diverged(self):
+        outstream = BytesIO()
+        errstream = BytesIO()
+
+        c3a = porcelain.commit(
+            repo=self.target_path, message=b'test3a',
+            author=b'test2 <email>',
+            committer=b'test2 <email>')
+
+        porcelain.commit(
+            repo=self.repo.path, message=b'test3b',
+            author=b'test2 <email>',
+            committer=b'test2 <email>')
+
+        # Pull changes into the cloned repo
+        self.assertRaises(
+            porcelain.DivergedBranches, porcelain.pull, self.target_path,
+            self.repo.path, b'refs/heads/master', outstream=outstream,
+            errstream=errstream)
+
+        # Check the target repo for pushed changes
+        with Repo(self.target_path) as r:
+            self.assertEqual(r[b'refs/heads/master'].id, c3a)
+
+        self.assertRaises(
+            NotImplementedError, porcelain.pull,
+            self.target_path, self.repo.path,
+            b'refs/heads/master', outstream=outstream, errstream=errstream,
+            fast_forward=False)
+
+        # Check the target repo for pushed changes
+        with Repo(self.target_path) as r:
+            self.assertEqual(r[b'refs/heads/master'].id, c3a)
+
     def test_no_refspec(self):
         outstream = BytesIO()
         errstream = BytesIO()
@@ -1319,7 +1400,9 @@ class BranchCreateTests(PorcelainTestCase):
         [c1] = build_commit_graph(self.repo.object_store, [[1]])
         self.repo[b"HEAD"] = c1.id
         porcelain.branch_create(self.repo, b"foo")
-        self.assertRaises(KeyError, porcelain.branch_create, self.repo, b"foo")
+        self.assertRaises(
+            porcelain.Error, porcelain.branch_create,
+            self.repo, b"foo")
         porcelain.branch_create(self.repo, b"foo", force=True)
 
     def test_new_branch(self):

+ 34 - 3
dulwich/tests/test_server.py

@@ -33,6 +33,7 @@ from dulwich.errors import (
     UnexpectedCommandError,
     HangupException,
     )
+from dulwich.objects import Tree
 from dulwich.object_store import (
     MemoryObjectStore,
     )
@@ -215,6 +216,35 @@ class UploadPackHandlerTestCase(TestCase):
         self._handler.set_client_capabilities(caps)
         self.assertEqual({}, self._handler.get_tagged(refs, repo=self._repo))
 
+    def test_nothing_to_do_but_wants(self):
+        # Just the fact that the client claims to want an object is enough
+        # for sending a pack. Even if there turns out to be nothing.
+        refs = {b'refs/tags/tag1': ONE}
+        tree = Tree()
+        self._repo.object_store.add_object(tree)
+        self._repo.object_store.add_object(make_commit(id=ONE, tree=tree))
+        self._repo.refs._update(refs)
+        self._handler.proto.set_output(
+            [b'want ' + ONE + b' side-band-64k thin-pack ofs-delta',
+             None, b'have ' + ONE, b'done', None])
+        self._handler.handle()
+        # The server should always send a pack, even if it's empty.
+        self.assertTrue(
+            self._handler.proto.get_received_line(1).startswith(b'PACK'))
+
+    def test_nothing_to_do_no_wants(self):
+        # Don't send a pack if the client didn't ask for anything.
+        refs = {b'refs/tags/tag1': ONE}
+        tree = Tree()
+        self._repo.object_store.add_object(tree)
+        self._repo.object_store.add_object(make_commit(id=ONE, tree=tree))
+        self._repo.refs._update(refs)
+        self._handler.proto.set_output([None])
+        self._handler.handle()
+        # The server should not send a pack, since the client didn't ask for
+        # anything.
+        self.assertEqual([], self._handler.proto._received[1])
+
 
 class FindShallowTests(TestCase):
 
@@ -320,6 +350,7 @@ class ReceivePackHandlerTestCase(TestCase):
 
 
 class ProtocolGraphWalkerEmptyTestCase(TestCase):
+
     def setUp(self):
         super(ProtocolGraphWalkerEmptyTestCase, self).setUp()
         self._repo = MemoryRepo.init_bare([], {})
@@ -534,7 +565,7 @@ class TestProtocolGraphWalker(object):
         self.acks = []
         self.lines = []
         self.wants_satisified = False
-        self.http_req = None
+        self.stateless_rpc = None
         self.advertise_refs = False
         self._impl = None
         self.done_required = True
@@ -957,7 +988,7 @@ class MultiAckDetailedGraphWalkerImplTestCase(AckGraphWalkerImplTestCase):
     def test_multi_ack_stateless(self):
         # transmission ends with a flush-pkt
         self._walker.lines[-1] = (None, None)
-        self._walker.http_req = True
+        self._walker.stateless_rpc = True
 
         self.assertNextEquals(TWO)
         self.assertNoAck()
@@ -980,7 +1011,7 @@ class MultiAckDetailedGraphWalkerImplTestCase(AckGraphWalkerImplTestCase):
         self._walker.done_required = False
         # transmission ends with a flush-pkt
         self._walker.lines[-1] = (None, None)
-        self._walker.http_req = True
+        self._walker.stateless_rpc = True
 
         self.assertNextEquals(TWO)
         self.assertNoAck()

+ 4 - 4
dulwich/tests/test_web.py

@@ -327,11 +327,11 @@ class DumbHandlersTestCase(WebTestCase):
 class SmartHandlersTestCase(WebTestCase):
 
     class _TestUploadPackHandler(object):
-        def __init__(self, backend, args, proto, http_req=None,
+        def __init__(self, backend, args, proto, stateless_rpc=None,
                      advertise_refs=False):
             self.args = args
             self.proto = proto
-            self.http_req = http_req
+            self.stateless_rpc = stateless_rpc
             self.advertise_refs = advertise_refs
 
         def handle(self):
@@ -368,7 +368,7 @@ class SmartHandlersTestCase(WebTestCase):
         self.assertEqual(b'handled input: foo', write_output)
         self.assertContentTypeEquals('application/x-git-upload-pack-result')
         self.assertFalse(self._handler.advertise_refs)
-        self.assertTrue(self._handler.http_req)
+        self.assertTrue(self._handler.stateless_rpc)
         self.assertFalse(self._req.cached)
 
     def test_handle_service_request(self):
@@ -412,7 +412,7 @@ class SmartHandlersTestCase(WebTestCase):
         # Ensure all output was written via the write callback.
         self.assertEqual(b'', handler_output)
         self.assertTrue(self._handler.advertise_refs)
-        self.assertTrue(self._handler.http_req)
+        self.assertTrue(self._handler.stateless_rpc)
         self.assertFalse(self._req.cached)
 
 

+ 2 - 2
dulwich/web.py

@@ -190,7 +190,7 @@ def get_info_refs(req, backend, mat):
             HTTP_OK, 'application/x-%s-advertisement' % service)
         proto = ReceivableProtocol(BytesIO().read, write)
         handler = handler_cls(backend, [url_prefix(mat)], proto,
-                              http_req=req, advertise_refs=True)
+                              stateless_rpc=req, advertise_refs=True)
         handler.proto.write_pkt_line(
             b'# service=' + service.encode('ascii') + b'\n')
         handler.proto.write_pkt_line(None)
@@ -252,7 +252,7 @@ def handle_service_request(req, backend, mat):
     proto = ReceivableProtocol(req.environ['wsgi.input'].read, write)
     # TODO(jelmer): Find a way to pass in repo, rather than having handler_cls
     # reopen.
-    handler = handler_cls(backend, [url_prefix(mat)], proto, http_req=req)
+    handler = handler_cls(backend, [url_prefix(mat)], proto, stateless_rpc=req)
     handler.handle()
 
 

+ 9 - 2
setup.py

@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/python3
 # encoding: utf-8
 # Setup file for dulwich
 # Copyright (C) 2008-2016 Jelmer Vernooij <jelmer@jelmer.uk>
@@ -15,7 +15,14 @@ import io
 import os
 import sys
 
-dulwich_version_string = '0.20.3'
+
+if sys.version_info < (3, 5):
+    raise Exception(
+        'Dulwich only supports Python 3.5 and later. '
+        'For 2.7 support, please install a version prior to 0.20')
+
+
+dulwich_version_string = '0.20.5'
 
 
 class DulwichDistribution(Distribution):