Jelajahi Sumber

Import upstream version 0.20.3, md5 aceb2999eead67de5f5b0f53409eadcc

Jelmer Vernooij 4 tahun lalu
induk
melakukan
8e54be5e98

+ 2 - 0
.gitignore

@@ -23,3 +23,5 @@ dulwich.egg-info/
 htmlcov/
 docs/api/*.txt
 .mypy_cache/
+.eggs
+dulwich.dist-info

+ 24 - 0
NEWS

@@ -1,3 +1,27 @@
+0.20.3	2020-06-14
+
+ * Add support for remembering remote refs after push/pull.
+   (Jelmer Vernooij, #752)
+
+ * Support passing tree and output encoding to
+   dulwich.patch.unified_diff. (Jelmer Vernooij, #763)
+
+ * Fix pushing of new refs over HTTP(S) when there are
+   no new objects to be sent.
+   (Jelmer Vernooij, #739)
+
+ * Raise new error HTTPUnauthorized when the server sends
+   back a 401. The client can then retry with credentials.
+   (Jelmer Vernooij, #691)
+
+ * Move the guts of bin/dulwich to dulwich.cli, so it is easier to
+   test or import. (Jelmer Vernooij)
+
+ * Install dulwich script from entry_points when setuptools is available,
+   making it slightly easier to use on Windows. (Jelmer Vernooij, #540)
+
+ * Set python_requires>=3.5 in setup.py. (Manuel Jacob)
+
 0.20.2	2020-06-01
 
  * Brown bag release to fix uploads of Windows wheels.

+ 2 - 1
PKG-INFO

@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: dulwich
-Version: 0.20.2
+Version: 0.20.3
 Summary: Python Git Library
 Home-page: https://www.dulwich.io/
 Author: Jelmer Vernooij
@@ -123,6 +123,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
 Classifier: Operating System :: POSIX
 Classifier: Operating System :: Microsoft :: Windows
 Classifier: Topic :: Software Development :: Version Control
+Requires-Python: >=3.5
 Provides-Extra: fastimport
 Provides-Extra: https
 Provides-Extra: pgp

+ 0 - 88
appveyor.yml

@@ -1,88 +0,0 @@
-environment:
-
-  TWINE_USERNAME: "dulwich-bot"
-  TWINE_PASSWORD:
-    # See https://www.appveyor.com/docs/build-configuration/#secure-variables
-    secure: e7DTu4CwOCARfN/mwA8lXQ==
-
-  matrix:
-
-    - PYTHON: "C:\\Python35"
-      PYTHON_VERSION: "3.5.x"
-      PYTHON_ARCH: "32"
-
-    - PYTHON: "C:\\Python35-x64"
-      PYTHON_VERSION: "3.5.x"
-      PYTHON_ARCH: "64"
-
-    - PYTHON: "C:\\Python36"
-      PYTHON_VERSION: "3.6.x"
-      PYTHON_ARCH: "32"
-
-    - PYTHON: "C:\\Python36-x64"
-      PYTHON_VERSION: "3.6.x"
-      PYTHON_ARCH: "64"
-
-    - PYTHON: "C:\\Python37"
-      PYTHON_VERSION: "3.7.x"
-      PYTHON_ARCH: "32"
-
-    - PYTHON: "C:\\Python37-x64"
-      PYTHON_VERSION: "3.7.x"
-      PYTHON_ARCH: "64"
-
-install:
-  # If there is a newer build queued for the same PR, cancel this one.
-  # The AppVeyor 'rollout builds' option is supposed to serve the same
-  # purpose but it is problematic because it tends to cancel builds pushed
-  # directly to master instead of just PR builds (or the converse).
-  # credits: JuliaLang developers.
-  - ps: if ($env:APPVEYOR_PULL_REQUEST_NUMBER -and $env:APPVEYOR_BUILD_NUMBER -ne ((Invoke-RestMethod `
-        https://ci.appveyor.com/api/projects/$env:APPVEYOR_ACCOUNT_NAME/$env:APPVEYOR_PROJECT_SLUG/history?recordsNumber=50).builds | `
-        Where-Object pullRequestId -eq $env:APPVEYOR_PULL_REQUEST_NUMBER)[0].buildNumber) { `
-          throw "There are newer queued builds for this pull request, failing early." }
-  - ECHO "Filesystem root:"
-  - ps: "ls \"C:/\""
-
-  - ECHO "Installed SDKs:"
-  - ps: "ls \"C:/Program Files/Microsoft SDKs/Windows\""
-
-  # Install Python (from the official .msi of http://python.org) and pip when
-  # not already installed.
-  - ps: if (-not(Test-Path($env:PYTHON))) { & appveyor\install.ps1 }
-
-  # Prepend newly installed Python to the PATH of this build (this cannot be
-  # done from inside the powershell script as it would require to restart
-  # the parent CMD process).
-  - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%"
-
-  # Check that we have the expected version and architecture for Python
-  - "build.cmd %PYTHON%\\python.exe --version"
-  - "build.cmd %PYTHON%\\python.exe -c \"import struct; print(struct.calcsize('P') * 8)\""
-
-  # Install setuptools/wheel so that we can e.g. use bdist_wheel
-  - "pip install setuptools wheel"
-
-  - "build.cmd %PYTHON%\\python.exe setup.py develop"
-
-build_script:
-  # Build the compiled extension
-  - "build.cmd %PYTHON%\\python.exe setup.py build"
-
-test_script:
-  - "build.cmd %PYTHON%\\python.exe setup.py test"
-  - "build.cmd %PYTHON%\\pythonw.exe setup.py test"
-
-after_test:
-  - "build.cmd %PYTHON%\\python.exe setup.py bdist_wheel"
-  # http://stackoverflow.com/questions/43255455/unicode-character-causing-error-with-bdist-wininst-on-python-3-but-not-python-2
-  # - "python setup.py bdist_wininst"
-  - "build.cmd %PYTHON%\\python.exe setup.py bdist_msi"
-  - ps: "ls dist"
-
-deploy_script:
-  - if "%APPVEYOR_REPO_TAG%"=="true" pip install twine
-  - if "%APPVEYOR_REPO_TAG%"=="true" twine upload dist\dulwich-*.whl
-
-artifacts:
-  - path: dist\*

+ 4 - 695
bin/dulwich

@@ -1,8 +1,6 @@
 #!/usr/bin/python3 -u
-#
-# dulwich - Simple command-line interface to Dulwich
-# Copyright (C) 2008-2011 Jelmer Vernooij <jelmer@jelmer.uk>
-# vim: expandtab
+# command-line interface for Dulwich
+# Copyright (C) 2020 Jelmer Vernooij <jelmer@jelmer.uk>
 #
 # 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
@@ -21,696 +19,7 @@
 # License, Version 2.0.
 #
 
-"""Simple command-line interface to Dulwich>
-
-This is a very simple command-line wrapper for Dulwich. It is by
-no means intended to be a full-blown Git command-line interface but just
-a way to test Dulwich.
-"""
-
-import os
 import sys
-from getopt import getopt
-import optparse
-import signal
-
-def signal_int(signal, frame):
-    sys.exit(1)
-
-
-def signal_quit(signal, frame):
-    import pdb
-    pdb.set_trace()
-
-if 'DULWICH_PDB' in os.environ:
-    signal.signal(signal.SIGQUIT, signal_quit)
-signal.signal(signal.SIGINT, signal_int)
-
-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
-
-
-class Command(object):
-    """A Dulwich subcommand."""
-
-    def run(self, args):
-        """Run the command."""
-        raise NotImplementedError(self.run)
-
-
-class cmd_archive(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        parser.add_option("--remote", type=str,
-                          help="Retrieve archive from specified remote repo")
-        options, args = parser.parse_args(args)
-        committish = args.pop(0)
-        if options.remote:
-            client, path = get_transport_and_path(options.remote)
-            client.archive(path, committish, sys.stdout.write,
-                    write_error=sys.stderr.write)
-        else:
-            porcelain.archive('.', committish, outstream=sys.stdout,
-                errstream=sys.stderr)
-
-
-class cmd_add(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", [])
-
-        porcelain.add(".", paths=args)
-
-
-class cmd_rm(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", [])
-
-        porcelain.rm(".", paths=args)
-
-
-class cmd_fetch_pack(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", ["all"])
-        opts = dict(opts)
-        client, path = get_transport_and_path(args.pop(0))
-        r = Repo(".")
-        if "--all" in opts:
-            determine_wants = r.object_store.determine_wants_all
-        else:
-            determine_wants = lambda x: [y for y in args if not y in r.object_store]
-        client.fetch(path, r, determine_wants)
-
-
-class cmd_fetch(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", [])
-        opts = dict(opts)
-        client, path = get_transport_and_path(args.pop(0))
-        r = Repo(".")
-        if "--all" in opts:
-            determine_wants = r.object_store.determine_wants_all
-        refs = client.fetch(path, r, progress=sys.stdout.write)
-        print("Remote refs:")
-        for item in refs.items():
-            print("%s -> %s" % item)
-
-
-class cmd_fsck(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", [])
-        opts = dict(opts)
-        for (obj, msg) in porcelain.fsck('.'):
-            print("%s: %s" % (obj, msg))
-
-
-class cmd_log(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        parser.add_option("--reverse", dest="reverse", action="store_true",
-                          help="Reverse order in which entries are printed")
-        parser.add_option("--name-status", dest="name_status", action="store_true",
-                          help="Print name/status for each changed file")
-        options, args = parser.parse_args(args)
-
-        porcelain.log(".", paths=args, reverse=options.reverse,
-                      name_status=options.name_status,
-                      outstream=sys.stdout)
-
-
-class cmd_diff(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", [])
-
-        if args == []:
-            print("Usage: dulwich diff COMMITID")
-            sys.exit(1)
-
-        r = Repo(".")
-        commit_id = args[0]
-        commit = r[commit_id]
-        parent_commit = r[commit.parents[0]]
-        write_tree_diff(sys.stdout, r.object_store, parent_commit.tree, commit.tree)
-
-
-class cmd_dump_pack(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", [])
-
-        if args == []:
-            print("Usage: dulwich dump-pack FILENAME")
-            sys.exit(1)
-
-        basename, _ = os.path.splitext(args[0])
-        x = Pack(basename)
-        print("Object names checksum: %s" % x.name())
-        print("Checksum: %s" % sha_to_hex(x.get_stored_checksum()))
-        if not x.check():
-            print("CHECKSUM DOES NOT MATCH")
-        print("Length: %d" % len(x))
-        for name in x:
-            try:
-                print("\t%s" % x[name])
-            except KeyError as k:
-                print("\t%s: Unable to resolve base %s" % (name, k))
-            except ApplyDeltaError as e:
-                print("\t%s: Unable to apply delta: %r" % (name, e))
-
-
-class cmd_dump_index(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", [])
-
-        if args == []:
-            print("Usage: dulwich dump-index FILENAME")
-            sys.exit(1)
-
-        filename = args[0]
-        idx = Index(filename)
-
-        for o in idx:
-            print(o, idx[o])
-
-
-class cmd_init(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", ["bare"])
-        opts = dict(opts)
-
-        if args == []:
-            path = os.getcwd()
-        else:
-            path = args[0]
-
-        porcelain.init(path, bare=("--bare" in opts))
-
-
-class cmd_clone(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        parser.add_option("--bare", dest="bare",
-                          help="Whether to create a bare repository.",
-                          action="store_true")
-        parser.add_option("--depth", dest="depth",
-                          type=int, help="Depth at which to fetch")
-        options, args = parser.parse_args(args)
-
-        if args == []:
-            print("usage: dulwich clone host:path [PATH]")
-            sys.exit(1)
-
-        source = args.pop(0)
-        if len(args) > 0:
-            target = args.pop(0)
-        else:
-            target = None
-
-        porcelain.clone(source, target, bare=options.bare, depth=options.depth)
-
-
-class cmd_commit(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", ["message"])
-        opts = dict(opts)
-        porcelain.commit(".", message=opts["--message"])
-
-
-class cmd_commit_tree(Command):
-
-    def run(self, 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"])
-
-
-class cmd_update_server_info(Command):
-
-    def run(self, args):
-        porcelain.update_server_info(".")
-
-
-class cmd_symbolic_ref(Command):
-
-    def run(self, 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)
-
-
-class cmd_show(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", [])
-        porcelain.show(".", args)
-
-
-class cmd_diff_tree(Command):
-
-    def run(self, 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])
-
-
-class cmd_rev_list(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", [])
-        if len(args) < 1:
-            print('Usage: dulwich rev-list COMMITID...')
-            sys.exit(1)
-        porcelain.rev_list('.', args)
-
-
-class cmd_tag(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        parser.add_option("-a", "--annotated", help="Create an annotated tag.", action="store_true")
-        parser.add_option("-s", "--sign", help="Sign the annotated tag.", action="store_true")
-        options, args = parser.parse_args(args)
-        porcelain.tag_create(
-            '.', args[0], annotated=options.annotated,
-            sign=options.sign)
-
-
-class cmd_repack(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", [])
-        opts = dict(opts)
-        porcelain.repack('.')
-
-
-class cmd_reset(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, "", ["hard", "soft", "mixed"])
-        opts = dict(opts)
-        mode = ""
-        if "--hard" in opts:
-            mode = "hard"
-        elif "--soft" in opts:
-            mode = "soft"
-        elif "--mixed" in opts:
-            mode = "mixed"
-        porcelain.reset('.', mode=mode, *args)
-
-
-class cmd_daemon(Command):
-
-    def run(self, args):
-        from dulwich import log_utils
-        from dulwich.protocol import TCP_GIT_PORT
-        parser = optparse.OptionParser()
-        parser.add_option("-l", "--listen_address", dest="listen_address",
-                          default="localhost",
-                          help="Binding IP address.")
-        parser.add_option("-p", "--port", dest="port", type=int,
-                          default=TCP_GIT_PORT,
-                          help="Binding TCP port.")
-        options, args = parser.parse_args(args)
-
-        log_utils.default_logging_config()
-        if len(args) >= 1:
-            gitdir = args[0]
-        else:
-            gitdir = '.'
-        from dulwich import porcelain
-        porcelain.daemon(gitdir, address=options.listen_address,
-                         port=options.port)
-
-
-class cmd_web_daemon(Command):
-
-    def run(self, args):
-        from dulwich import log_utils
-        parser = optparse.OptionParser()
-        parser.add_option("-l", "--listen_address", dest="listen_address",
-                          default="",
-                          help="Binding IP address.")
-        parser.add_option("-p", "--port", dest="port", type=int,
-                          default=8000,
-                          help="Binding TCP port.")
-        options, args = parser.parse_args(args)
-
-        log_utils.default_logging_config()
-        if len(args) >= 1:
-            gitdir = args[0]
-        else:
-            gitdir = '.'
-        from dulwich import porcelain
-        porcelain.web_daemon(gitdir, address=options.listen_address,
-                             port=options.port)
-
-
-class cmd_write_tree(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        sys.stdout.write('%s\n' % porcelain.write_tree('.'))
-
-
-class cmd_receive_pack(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        if len(args) >= 1:
-            gitdir = args[0]
-        else:
-            gitdir = '.'
-        porcelain.receive_pack(gitdir)
-
-
-class cmd_upload_pack(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        if len(args) >= 1:
-            gitdir = args[0]
-        else:
-            gitdir = '.'
-        porcelain.upload_pack(gitdir)
-
-
-class cmd_status(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        if len(args) >= 1:
-            gitdir = args[0]
-        else:
-            gitdir = '.'
-        status = porcelain.status(gitdir)
-        if any(names for (kind, names) in status.staged.items()):
-            sys.stdout.write("Changes to be committed:\n\n")
-            for kind, names in status.staged.items():
-                for name in names:
-                    sys.stdout.write("\t%s: %s\n" % (
-                        kind, name.decode(sys.getfilesystemencoding())))
-            sys.stdout.write("\n")
-        if status.unstaged:
-            sys.stdout.write("Changes not staged for commit:\n\n")
-            for name in status.unstaged:
-                sys.stdout.write("\t%s\n" %
-                        name.decode(sys.getfilesystemencoding()))
-            sys.stdout.write("\n")
-        if status.untracked:
-            sys.stdout.write("Untracked files:\n\n")
-            for name in status.untracked:
-                sys.stdout.write("\t%s\n" % name)
-            sys.stdout.write("\n")
-
-
-class cmd_ls_remote(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, '', [])
-        if len(args) < 1:
-            print('Usage: dulwich ls-remote URL')
-            sys.exit(1)
-        refs = porcelain.ls_remote(args[0])
-        for ref in sorted(refs):
-            sys.stdout.write("%s\t%s\n" % (ref, refs[ref]))
-
-
-class cmd_ls_tree(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        parser.add_option("-r", "--recursive", action="store_true",
-                          help="Recusively list tree contents.")
-        parser.add_option("--name-only", action="store_true",
-                          help="Only display name.")
-        options, args = parser.parse_args(args)
-        try:
-            treeish = args.pop(0)
-        except IndexError:
-            treeish = None
-        porcelain.ls_tree(
-            '.', treeish, outstream=sys.stdout, recursive=options.recursive,
-            name_only=options.name_only)
-
-
-class cmd_pack_objects(Command):
-
-    def run(self, args):
-        opts, args = getopt(args, '', ['stdout'])
-        opts = dict(opts)
-        if len(args) < 1 and not '--stdout' in args:
-            print('Usage: dulwich pack-objects basename')
-            sys.exit(1)
-        object_ids = [l.strip() for l in sys.stdin.readlines()]
-        basename = args[0]
-        if '--stdout' in opts:
-            packf = getattr(sys.stdout, 'buffer', sys.stdout)
-            idxf = None
-            close = []
-        else:
-            packf = open(basename + '.pack', 'w')
-            idxf = open(basename + '.idx', 'w')
-            close = [packf, idxf]
-        porcelain.pack_objects('.', object_ids, packf, idxf)
-        for f in close:
-            f.close()
-
-
-class cmd_pull(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        try:
-            from_location = args[0]
-        except IndexError:
-            from_location = None
-        porcelain.pull('.', from_location)
-
-
-class cmd_push(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        if len(args) < 2:
-            print("Usage: dulwich push TO-LOCATION REFSPEC..")
-            sys.exit(1)
-        to_location = args[0]
-        refspecs = args[1:]
-        porcelain.push('.', to_location, refspecs)
-
-
-class cmd_remote_add(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        porcelain.remote_add('.', args[0], args[1])
-
-
-class SuperCommand(Command):
-
-    subcommands = {}
-
-    def run(self, args):
-        if not args:
-            print("Supported subcommands: %s" % ', '.join(self.subcommands.keys()))
-            return False
-        cmd = args[0]
-        try:
-            cmd_kls = self.subcommands[cmd]
-        except KeyError:
-            print('No such subcommand: %s' % args[0])
-            return False
-        return cmd_kls().run(args[1:])
-
-
-class cmd_remote(SuperCommand):
-
-    subcommands = {
-        "add": cmd_remote_add,
-    }
-
-
-class cmd_check_ignore(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        ret = 1
-        for path in porcelain.check_ignore('.', args):
-            print(path)
-            ret = 0
-        return ret
-
-
-class cmd_check_mailmap(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        for arg in args:
-            canonical_identity = porcelain.check_mailmap('.', arg)
-            print(canonical_identity)
-
-
-class cmd_stash_list(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        for i, entry in porcelain.stash_list('.'):
-            print("stash@{%d}: %s" % (i, entry.message.rstrip('\n')))
-
-
-class cmd_stash_push(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        porcelain.stash_push('.')
-        print("Saved working directory and index state")
-
-
-class cmd_stash_pop(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        porcelain.stash_pop('.')
-        print("Restrored working directory and index state")
-
-
-class cmd_stash(SuperCommand):
-
-    subcommands = {
-        "list": cmd_stash_list,
-        "pop": cmd_stash_pop,
-        "push": cmd_stash_push,
-    }
-
-
-class cmd_ls_files(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        for name in porcelain.ls_files('.'):
-            print(name)
-
-
-class cmd_describe(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        options, args = parser.parse_args(args)
-        print(porcelain.describe('.'))
-
-
-class cmd_help(Command):
-
-    def run(self, args):
-        parser = optparse.OptionParser()
-        parser.add_option("-a", "--all", dest="all",
-                          action="store_true",
-                          help="List all commands.")
-        options, args = parser.parse_args(args)
-
-        if options.all:
-            print('Available commands:')
-            for cmd in sorted(commands):
-                print('  %s' % cmd)
-        else:
-            print("""\
-The dulwich command line tool is currently a very basic frontend for the
-Dulwich python module. For full functionality, please see the API reference.
-
-For a list of supported commands, see 'dulwich help -a'.
-""")
-
-
-commands = {
-    "add": cmd_add,
-    "archive": cmd_archive,
-    "check-ignore": cmd_check_ignore,
-    "check-mailmap": cmd_check_mailmap,
-    "clone": cmd_clone,
-    "commit": cmd_commit,
-    "commit-tree": cmd_commit_tree,
-    "describe": cmd_describe,
-    "daemon": cmd_daemon,
-    "diff": cmd_diff,
-    "diff-tree": cmd_diff_tree,
-    "dump-pack": cmd_dump_pack,
-    "dump-index": cmd_dump_index,
-    "fetch-pack": cmd_fetch_pack,
-    "fetch": cmd_fetch,
-    "fsck": cmd_fsck,
-    "help": cmd_help,
-    "init": cmd_init,
-    "log": cmd_log,
-    "ls-files": cmd_ls_files,
-    "ls-remote": cmd_ls_remote,
-    "ls-tree": cmd_ls_tree,
-    "pack-objects": cmd_pack_objects,
-    "pull": cmd_pull,
-    "push": cmd_push,
-    "receive-pack": cmd_receive_pack,
-    "remote": cmd_remote,
-    "repack": cmd_repack,
-    "reset": cmd_reset,
-    "rev-list": cmd_rev_list,
-    "rm": cmd_rm,
-    "show": cmd_show,
-    "stash": cmd_stash,
-    "status": cmd_status,
-    "symbolic-ref": cmd_symbolic_ref,
-    "tag": cmd_tag,
-    "update-server-info": cmd_update_server_info,
-    "upload-pack": cmd_upload_pack,
-    "web-daemon": cmd_web_daemon,
-    "write-tree": cmd_write_tree,
-    }
-
-if len(sys.argv) < 2:
-    print("Usage: %s <%s> [OPTIONS...]" % (sys.argv[0], "|".join(commands.keys())))
-    sys.exit(1)
 
-cmd = sys.argv[1]
-try:
-    cmd_kls = commands[cmd]
-except KeyError:
-    print("No such subcommand: %s" % cmd)
-    sys.exit(1)
-# TODO(jelmer): Return non-0 on errors
-cmd_kls().run(sys.argv[2:])
+from dulwich.cli import main
+sys.exit(main(sys.argv[1:]))

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

@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: dulwich
-Version: 0.20.2
+Version: 0.20.3
 Summary: Python Git Library
 Home-page: https://www.dulwich.io/
 Author: Jelmer Vernooij
@@ -123,6 +123,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
 Classifier: Operating System :: POSIX
 Classifier: Operating System :: Microsoft :: Windows
 Classifier: Topic :: Software Development :: Version Control
+Requires-Python: >=3.5
 Provides-Extra: fastimport
 Provides-Extra: https
 Provides-Extra: pgp

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

@@ -11,7 +11,6 @@ NEWS
 README.rst
 README.swift.rst
 TODO
-appveyor.yml
 build.cmd
 dulwich.cfg
 requirements.txt
@@ -50,6 +49,7 @@ dulwich/_diff_tree.c
 dulwich/_objects.c
 dulwich/_pack.c
 dulwich/archive.py
+dulwich/cli.py
 dulwich/client.py
 dulwich/config.py
 dulwich/diff_tree.py
@@ -83,6 +83,7 @@ dulwich/web.py
 dulwich.egg-info/PKG-INFO
 dulwich.egg-info/SOURCES.txt
 dulwich.egg-info/dependency_links.txt
+dulwich.egg-info/entry_points.txt
 dulwich.egg-info/requires.txt
 dulwich.egg-info/top_level.txt
 dulwich/contrib/README.md

+ 3 - 0
dulwich.egg-info/entry_points.txt

@@ -0,0 +1,3 @@
+[console_scripts]
+dulwich = dulwich.cli:main
+

+ 1 - 1
dulwich/__init__.py

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

+ 732 - 0
dulwich/cli.py

@@ -0,0 +1,732 @@
+#!/usr/bin/python3 -u
+#
+# dulwich - Simple command-line interface to Dulwich
+# Copyright (C) 2008-2011 Jelmer Vernooij <jelmer@jelmer.uk>
+# vim: expandtab
+#
+# 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.
+#
+
+"""Simple command-line interface to Dulwich>
+
+This is a very simple command-line wrapper for Dulwich. It is by
+no means intended to be a full-blown Git command-line interface but just
+a way to test Dulwich.
+"""
+
+import os
+import sys
+from getopt import getopt
+import optparse
+import signal
+from typing import Dict, Type
+
+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
+
+
+def signal_int(signal, frame):
+    sys.exit(1)
+
+
+def signal_quit(signal, frame):
+    import pdb
+    pdb.set_trace()
+
+
+class Command(object):
+    """A Dulwich subcommand."""
+
+    def run(self, args):
+        """Run the command."""
+        raise NotImplementedError(self.run)
+
+
+class cmd_archive(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        parser.add_option("--remote", type=str,
+                          help="Retrieve archive from specified remote repo")
+        options, args = parser.parse_args(args)
+        committish = args.pop(0)
+        if options.remote:
+            client, path = get_transport_and_path(options.remote)
+            client.archive(
+                path, committish, sys.stdout.write,
+                write_error=sys.stderr.write)
+        else:
+            porcelain.archive(
+                '.', committish, outstream=sys.stdout,
+                errstream=sys.stderr)
+
+
+class cmd_add(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", [])
+
+        porcelain.add(".", paths=args)
+
+
+class cmd_rm(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", [])
+
+        porcelain.rm(".", paths=args)
+
+
+class cmd_fetch_pack(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", ["all"])
+        opts = dict(opts)
+        client, path = get_transport_and_path(args.pop(0))
+        r = Repo(".")
+        if "--all" in opts:
+            determine_wants = r.object_store.determine_wants_all
+        else:
+            def determine_wants(x):
+                return [y for y in args if y not in r.object_store]
+        client.fetch(path, r, determine_wants)
+
+
+class cmd_fetch(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", [])
+        opts = dict(opts)
+        client, path = get_transport_and_path(args.pop(0))
+        r = Repo(".")
+        refs = client.fetch(path, r, progress=sys.stdout.write)
+        print("Remote refs:")
+        for item in refs.items():
+            print("%s -> %s" % item)
+
+
+class cmd_fsck(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", [])
+        opts = dict(opts)
+        for (obj, msg) in porcelain.fsck('.'):
+            print("%s: %s" % (obj, msg))
+
+
+class cmd_log(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        parser.add_option("--reverse", dest="reverse", action="store_true",
+                          help="Reverse order in which entries are printed")
+        parser.add_option("--name-status", dest="name_status",
+                          action="store_true",
+                          help="Print name/status for each changed file")
+        options, args = parser.parse_args(args)
+
+        porcelain.log(".", paths=args, reverse=options.reverse,
+                      name_status=options.name_status,
+                      outstream=sys.stdout)
+
+
+class cmd_diff(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", [])
+
+        if args == []:
+            print("Usage: dulwich diff COMMITID")
+            sys.exit(1)
+
+        r = Repo(".")
+        commit_id = args[0]
+        commit = r[commit_id]
+        parent_commit = r[commit.parents[0]]
+        write_tree_diff(
+            sys.stdout, r.object_store, parent_commit.tree, commit.tree)
+
+
+class cmd_dump_pack(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", [])
+
+        if args == []:
+            print("Usage: dulwich dump-pack FILENAME")
+            sys.exit(1)
+
+        basename, _ = os.path.splitext(args[0])
+        x = Pack(basename)
+        print("Object names checksum: %s" % x.name())
+        print("Checksum: %s" % sha_to_hex(x.get_stored_checksum()))
+        if not x.check():
+            print("CHECKSUM DOES NOT MATCH")
+        print("Length: %d" % len(x))
+        for name in x:
+            try:
+                print("\t%s" % x[name])
+            except KeyError as k:
+                print("\t%s: Unable to resolve base %s" % (name, k))
+            except ApplyDeltaError as e:
+                print("\t%s: Unable to apply delta: %r" % (name, e))
+
+
+class cmd_dump_index(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", [])
+
+        if args == []:
+            print("Usage: dulwich dump-index FILENAME")
+            sys.exit(1)
+
+        filename = args[0]
+        idx = Index(filename)
+
+        for o in idx:
+            print(o, idx[o])
+
+
+class cmd_init(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", ["bare"])
+        opts = dict(opts)
+
+        if args == []:
+            path = os.getcwd()
+        else:
+            path = args[0]
+
+        porcelain.init(path, bare=("--bare" in opts))
+
+
+class cmd_clone(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        parser.add_option("--bare", dest="bare",
+                          help="Whether to create a bare repository.",
+                          action="store_true")
+        parser.add_option("--depth", dest="depth",
+                          type=int, help="Depth at which to fetch")
+        options, args = parser.parse_args(args)
+
+        if args == []:
+            print("usage: dulwich clone host:path [PATH]")
+            sys.exit(1)
+
+        source = args.pop(0)
+        if len(args) > 0:
+            target = args.pop(0)
+        else:
+            target = None
+
+        porcelain.clone(source, target, bare=options.bare, depth=options.depth)
+
+
+class cmd_commit(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", ["message"])
+        opts = dict(opts)
+        porcelain.commit(".", message=opts["--message"])
+
+
+class cmd_commit_tree(Command):
+
+    def run(self, 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"])
+
+
+class cmd_update_server_info(Command):
+
+    def run(self, args):
+        porcelain.update_server_info(".")
+
+
+class cmd_symbolic_ref(Command):
+
+    def run(self, 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)
+
+
+class cmd_show(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", [])
+        porcelain.show(".", args)
+
+
+class cmd_diff_tree(Command):
+
+    def run(self, 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])
+
+
+class cmd_rev_list(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", [])
+        if len(args) < 1:
+            print('Usage: dulwich rev-list COMMITID...')
+            sys.exit(1)
+        porcelain.rev_list('.', args)
+
+
+class cmd_tag(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        parser.add_option(
+            "-a", "--annotated", help="Create an annotated tag.",
+            action="store_true")
+        parser.add_option(
+            "-s", "--sign", help="Sign the annotated tag.",
+            action="store_true")
+        options, args = parser.parse_args(args)
+        porcelain.tag_create(
+            '.', args[0], annotated=options.annotated,
+            sign=options.sign)
+
+
+class cmd_repack(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", [])
+        opts = dict(opts)
+        porcelain.repack('.')
+
+
+class cmd_reset(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", ["hard", "soft", "mixed"])
+        opts = dict(opts)
+        mode = ""
+        if "--hard" in opts:
+            mode = "hard"
+        elif "--soft" in opts:
+            mode = "soft"
+        elif "--mixed" in opts:
+            mode = "mixed"
+        porcelain.reset('.', mode=mode, *args)
+
+
+class cmd_daemon(Command):
+
+    def run(self, args):
+        from dulwich import log_utils
+        from dulwich.protocol import TCP_GIT_PORT
+        parser = optparse.OptionParser()
+        parser.add_option("-l", "--listen_address", dest="listen_address",
+                          default="localhost",
+                          help="Binding IP address.")
+        parser.add_option("-p", "--port", dest="port", type=int,
+                          default=TCP_GIT_PORT,
+                          help="Binding TCP port.")
+        options, args = parser.parse_args(args)
+
+        log_utils.default_logging_config()
+        if len(args) >= 1:
+            gitdir = args[0]
+        else:
+            gitdir = '.'
+        from dulwich import porcelain
+        porcelain.daemon(gitdir, address=options.listen_address,
+                         port=options.port)
+
+
+class cmd_web_daemon(Command):
+
+    def run(self, args):
+        from dulwich import log_utils
+        parser = optparse.OptionParser()
+        parser.add_option("-l", "--listen_address", dest="listen_address",
+                          default="",
+                          help="Binding IP address.")
+        parser.add_option("-p", "--port", dest="port", type=int,
+                          default=8000,
+                          help="Binding TCP port.")
+        options, args = parser.parse_args(args)
+
+        log_utils.default_logging_config()
+        if len(args) >= 1:
+            gitdir = args[0]
+        else:
+            gitdir = '.'
+        from dulwich import porcelain
+        porcelain.web_daemon(gitdir, address=options.listen_address,
+                             port=options.port)
+
+
+class cmd_write_tree(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        sys.stdout.write('%s\n' % porcelain.write_tree('.'))
+
+
+class cmd_receive_pack(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        if len(args) >= 1:
+            gitdir = args[0]
+        else:
+            gitdir = '.'
+        porcelain.receive_pack(gitdir)
+
+
+class cmd_upload_pack(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        if len(args) >= 1:
+            gitdir = args[0]
+        else:
+            gitdir = '.'
+        porcelain.upload_pack(gitdir)
+
+
+class cmd_status(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        if len(args) >= 1:
+            gitdir = args[0]
+        else:
+            gitdir = '.'
+        status = porcelain.status(gitdir)
+        if any(names for (kind, names) in status.staged.items()):
+            sys.stdout.write("Changes to be committed:\n\n")
+            for kind, names in status.staged.items():
+                for name in names:
+                    sys.stdout.write("\t%s: %s\n" % (
+                        kind, name.decode(sys.getfilesystemencoding())))
+            sys.stdout.write("\n")
+        if status.unstaged:
+            sys.stdout.write("Changes not staged for commit:\n\n")
+            for name in status.unstaged:
+                sys.stdout.write(
+                    "\t%s\n" % name.decode(sys.getfilesystemencoding()))
+            sys.stdout.write("\n")
+        if status.untracked:
+            sys.stdout.write("Untracked files:\n\n")
+            for name in status.untracked:
+                sys.stdout.write("\t%s\n" % name)
+            sys.stdout.write("\n")
+
+
+class cmd_ls_remote(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, '', [])
+        if len(args) < 1:
+            print('Usage: dulwich ls-remote URL')
+            sys.exit(1)
+        refs = porcelain.ls_remote(args[0])
+        for ref in sorted(refs):
+            sys.stdout.write("%s\t%s\n" % (ref, refs[ref]))
+
+
+class cmd_ls_tree(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        parser.add_option("-r", "--recursive", action="store_true",
+                          help="Recusively list tree contents.")
+        parser.add_option("--name-only", action="store_true",
+                          help="Only display name.")
+        options, args = parser.parse_args(args)
+        try:
+            treeish = args.pop(0)
+        except IndexError:
+            treeish = None
+        porcelain.ls_tree(
+            '.', treeish, outstream=sys.stdout, recursive=options.recursive,
+            name_only=options.name_only)
+
+
+class cmd_pack_objects(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, '', ['stdout'])
+        opts = dict(opts)
+        if len(args) < 1 and '--stdout' not in args:
+            print('Usage: dulwich pack-objects basename')
+            sys.exit(1)
+        object_ids = [line.strip() for line in sys.stdin.readlines()]
+        basename = args[0]
+        if '--stdout' in opts:
+            packf = getattr(sys.stdout, 'buffer', sys.stdout)
+            idxf = None
+            close = []
+        else:
+            packf = open(basename + '.pack', 'w')
+            idxf = open(basename + '.idx', 'w')
+            close = [packf, idxf]
+        porcelain.pack_objects('.', object_ids, packf, idxf)
+        for f in close:
+            f.close()
+
+
+class cmd_pull(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        try:
+            from_location = args[0]
+        except IndexError:
+            from_location = None
+        porcelain.pull('.', from_location)
+
+
+class cmd_push(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        if len(args) < 2:
+            print("Usage: dulwich push TO-LOCATION REFSPEC..")
+            sys.exit(1)
+        to_location = args[0]
+        refspecs = args[1:]
+        porcelain.push('.', to_location, refspecs)
+
+
+class cmd_remote_add(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        porcelain.remote_add('.', args[0], args[1])
+
+
+class SuperCommand(Command):
+
+    subcommands = {}  # type: Dict[str, Type[Command]]
+
+    def run(self, args):
+        if not args:
+            print("Supported subcommands: %s" %
+                  ', '.join(self.subcommands.keys()))
+            return False
+        cmd = args[0]
+        try:
+            cmd_kls = self.subcommands[cmd]
+        except KeyError:
+            print('No such subcommand: %s' % args[0])
+            return False
+        return cmd_kls().run(args[1:])
+
+
+class cmd_remote(SuperCommand):
+
+    subcommands = {
+        "add": cmd_remote_add,
+    }
+
+
+class cmd_check_ignore(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        ret = 1
+        for path in porcelain.check_ignore('.', args):
+            print(path)
+            ret = 0
+        return ret
+
+
+class cmd_check_mailmap(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        for arg in args:
+            canonical_identity = porcelain.check_mailmap('.', arg)
+            print(canonical_identity)
+
+
+class cmd_stash_list(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        for i, entry in porcelain.stash_list('.'):
+            print("stash@{%d}: %s" % (i, entry.message.rstrip('\n')))
+
+
+class cmd_stash_push(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        porcelain.stash_push('.')
+        print("Saved working directory and index state")
+
+
+class cmd_stash_pop(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        porcelain.stash_pop('.')
+        print("Restrored working directory and index state")
+
+
+class cmd_stash(SuperCommand):
+
+    subcommands = {
+        "list": cmd_stash_list,
+        "pop": cmd_stash_pop,
+        "push": cmd_stash_push,
+    }
+
+
+class cmd_ls_files(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        for name in porcelain.ls_files('.'):
+            print(name)
+
+
+class cmd_describe(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        options, args = parser.parse_args(args)
+        print(porcelain.describe('.'))
+
+
+class cmd_help(Command):
+
+    def run(self, args):
+        parser = optparse.OptionParser()
+        parser.add_option("-a", "--all", dest="all",
+                          action="store_true",
+                          help="List all commands.")
+        options, args = parser.parse_args(args)
+
+        if options.all:
+            print('Available commands:')
+            for cmd in sorted(commands):
+                print('  %s' % cmd)
+        else:
+            print("""\
+The dulwich command line tool is currently a very basic frontend for the
+Dulwich python module. For full functionality, please see the API reference.
+
+For a list of supported commands, see 'dulwich help -a'.
+""")
+
+
+commands = {
+    "add": cmd_add,
+    "archive": cmd_archive,
+    "check-ignore": cmd_check_ignore,
+    "check-mailmap": cmd_check_mailmap,
+    "clone": cmd_clone,
+    "commit": cmd_commit,
+    "commit-tree": cmd_commit_tree,
+    "describe": cmd_describe,
+    "daemon": cmd_daemon,
+    "diff": cmd_diff,
+    "diff-tree": cmd_diff_tree,
+    "dump-pack": cmd_dump_pack,
+    "dump-index": cmd_dump_index,
+    "fetch-pack": cmd_fetch_pack,
+    "fetch": cmd_fetch,
+    "fsck": cmd_fsck,
+    "help": cmd_help,
+    "init": cmd_init,
+    "log": cmd_log,
+    "ls-files": cmd_ls_files,
+    "ls-remote": cmd_ls_remote,
+    "ls-tree": cmd_ls_tree,
+    "pack-objects": cmd_pack_objects,
+    "pull": cmd_pull,
+    "push": cmd_push,
+    "receive-pack": cmd_receive_pack,
+    "remote": cmd_remote,
+    "repack": cmd_repack,
+    "reset": cmd_reset,
+    "rev-list": cmd_rev_list,
+    "rm": cmd_rm,
+    "show": cmd_show,
+    "stash": cmd_stash,
+    "status": cmd_status,
+    "symbolic-ref": cmd_symbolic_ref,
+    "tag": cmd_tag,
+    "update-server-info": cmd_update_server_info,
+    "upload-pack": cmd_upload_pack,
+    "web-daemon": cmd_web_daemon,
+    "write-tree": cmd_write_tree,
+    }
+
+
+def main(argv=None):
+    if len(argv) < 1:
+        print("Usage: dulwich <%s> [OPTIONS...]" % ("|".join(commands.keys())))
+        return 1
+
+    cmd = argv[0]
+    try:
+        cmd_kls = commands[cmd]
+    except KeyError:
+        print("No such subcommand: %s" % cmd)
+        return 1
+    # TODO(jelmer): Return non-0 on errors
+    return cmd_kls().run(argv[1:])
+
+
+if __name__ == '__main__':
+    if 'DULWICH_PDB' in os.environ and getattr(signal, 'SIGQUIT', None):
+        signal.signal(signal.SIGQUIT, signal_quit)  # type: ignore
+    signal.signal(signal.SIGINT, signal_int)
+
+    sys.exit(main(sys.argv[1:]))

+ 36 - 23
dulwich/client.py

@@ -120,6 +120,14 @@ class InvalidWants(Exception):
             "requested wants not in server provided refs: %r" % wants)
 
 
+class HTTPUnauthorized(Exception):
+    """Raised when authentication fails."""
+
+    def __init__(self, www_authenticate):
+        Exception.__init__(self, "No valid credentials provided")
+        self.www_authenticate = www_authenticate
+
+
 def _fileno_can_read(fileno):
     """Check if a file descriptor is readable.
     """
@@ -519,6 +527,11 @@ class GitClient(object):
                 if cb is not None:
                     cb(pkt)
 
+    @staticmethod
+    def _should_send_pack(new_refs):
+        # The packfile MUST NOT be sent if the only command used is delete.
+        return any(sha != ZERO_SHA for sha in new_refs.values())
+
     def _handle_receive_pack_head(self, proto, capabilities, old_refs,
                                   new_refs):
         """Handle the head of a 'git-receive-pack' request.
@@ -530,8 +543,7 @@ class GitClient(object):
           new_refs: Refs to change
 
         Returns:
-          have, want) tuple
-
+          (have, want) tuple
         """
         want = []
         have = [x for x in old_refs.values() if not x == ZERO_SHA]
@@ -736,15 +748,16 @@ def check_wants(wants, refs):
         raise InvalidWants(missing)
 
 
-def remote_error_from_stderr(stderr):
+def _remote_error_from_stderr(stderr):
     if stderr is None:
-        return HangupException()
-    for line in stderr.readlines():
+        raise HangupException()
+    lines = [line.rstrip(b'\n') for line in stderr.readlines()]
+    for line in lines:
         if line.startswith(b'ERROR: '):
-            return GitProtocolError(
+            raise GitProtocolError(
                 line[len(b'ERROR: '):].decode('utf-8', 'replace'))
-        return GitProtocolError(line.decode('utf-8', 'replace'))
-    return HangupException()
+        raise GitProtocolError(line.decode('utf-8', 'replace'))
+    raise HangupException(lines)
 
 
 class TraditionalGitClient(GitClient):
@@ -799,7 +812,7 @@ class TraditionalGitClient(GitClient):
             try:
                 old_refs, server_capabilities = read_pkt_refs(proto)
             except HangupException:
-                raise remote_error_from_stderr(stderr)
+                _remote_error_from_stderr(stderr)
             negotiated_capabilities = \
                 self._negotiate_receive_pack_capabilities(server_capabilities)
             if CAPABILITY_REPORT_STATUS in negotiated_capabilities:
@@ -812,6 +825,10 @@ class TraditionalGitClient(GitClient):
                 proto.write_pkt_line(None)
                 raise
 
+            if set(new_refs.items()).issubset(set(old_refs.items())):
+                proto.write_pkt_line(None)
+                return new_refs
+
             if CAPABILITY_DELETE_REFS not in server_capabilities:
                 # Server does not support deletions. Fail later.
                 new_refs = dict(orig_new_refs)
@@ -837,18 +854,12 @@ class TraditionalGitClient(GitClient):
 
             (have, want) = self._handle_receive_pack_head(
                 proto, negotiated_capabilities, old_refs, new_refs)
-            if (not want and
-                    set(new_refs.items()).issubset(set(old_refs.items()))):
-                return new_refs
+
             pack_data_count, pack_data = generate_pack_data(
                 have, want,
                 ofs_delta=(CAPABILITY_OFS_DELTA in negotiated_capabilities))
 
-            dowrite = bool(pack_data_count)
-            dowrite = dowrite or any(old_refs.get(ref) != sha
-                                     for (ref, sha) in new_refs.items()
-                                     if sha != ZERO_SHA)
-            if dowrite:
+            if self._should_send_pack(new_refs):
                 write_pack_data(proto.write_file(), pack_data_count, pack_data)
 
             self._handle_receive_pack_tail(
@@ -878,7 +889,7 @@ class TraditionalGitClient(GitClient):
             try:
                 refs, server_capabilities = read_pkt_refs(proto)
             except HangupException:
-                raise remote_error_from_stderr(stderr)
+                _remote_error_from_stderr(stderr)
             negotiated_capabilities, symrefs, agent = (
                     self._negotiate_upload_pack_capabilities(
                             server_capabilities))
@@ -915,7 +926,7 @@ class TraditionalGitClient(GitClient):
             try:
                 refs, _ = read_pkt_refs(proto)
             except HangupException:
-                raise remote_error_from_stderr(stderr)
+                _remote_error_from_stderr(stderr)
             proto.write_pkt_line(None)
             return refs
 
@@ -935,7 +946,7 @@ class TraditionalGitClient(GitClient):
             try:
                 pkt = proto.read_pkt_line()
             except HangupException:
-                raise remote_error_from_stderr(stderr)
+                _remote_error_from_stderr(stderr)
             if pkt == b"NACK\n":
                 return
             elif pkt == b"ACK\n":
@@ -1596,6 +1607,8 @@ class HttpGitClient(GitClient):
 
         if resp.status == 404:
             raise NotGitRepository()
+        elif resp.status == 401:
+            raise HTTPUnauthorized(resp.getheader('WWW-Authenticate'))
         elif resp.status != 200:
             raise GitProtocolError("unexpected http resp %d for %s" %
                                    (resp.status, url))
@@ -1706,18 +1719,18 @@ class HttpGitClient(GitClient):
         if new_refs is None:
             # Determine wants function is aborting the push.
             return old_refs
+        if set(new_refs.items()).issubset(set(old_refs.items())):
+            return new_refs
         if self.dumb:
             raise NotImplementedError(self.fetch_pack)
         req_data = BytesIO()
         req_proto = Protocol(None, req_data.write)
         (have, want) = self._handle_receive_pack_head(
             req_proto, negotiated_capabilities, old_refs, new_refs)
-        if not want and set(new_refs.items()).issubset(set(old_refs.items())):
-            return new_refs
         pack_data_count, pack_data = generate_pack_data(
                 have, want,
                 ofs_delta=(CAPABILITY_OFS_DELTA in negotiated_capabilities))
-        if pack_data_count:
+        if self._should_send_pack(new_refs):
             write_pack_data(req_proto.write_file(), pack_data_count, pack_data)
         resp, read = self._smart_request("git-receive-pack", url,
                                          data=req_data.getvalue())

+ 10 - 3
dulwich/errors.py

@@ -133,9 +133,16 @@ class UpdateRefsError(GitProtocolError):
 class HangupException(GitProtocolError):
     """Hangup exception."""
 
-    def __init__(self):
-        super(HangupException, self).__init__(
-            "The remote server unexpectedly closed the connection.")
+    def __init__(self, stderr_lines=None):
+        if stderr_lines:
+            super(HangupException, self).__init__(
+                '\n'.join(
+                    [line.decode('utf-8', 'surrogateescape')
+                     for line in stderr_lines]))
+        else:
+            super(HangupException, self).__init__(
+                "The remote server unexpectedly closed the connection.")
+        self.stderr_lines = stderr_lines
 
 
 class UnexpectedCommandError(GitProtocolError):

+ 7 - 6
dulwich/patch.py

@@ -104,7 +104,8 @@ def _format_range_unified(start, stop):
 
 
 def unified_diff(a, b, fromfile='', tofile='', fromfiledate='',
-                 tofiledate='', n=3, lineterm='\n'):
+                 tofiledate='', n=3, lineterm='\n', tree_encoding='utf-8',
+                 output_encoding='utf-8'):
     """difflib.unified_diff that can detect "No newline at end of file" as
     original "git diff" does.
 
@@ -117,15 +118,15 @@ def unified_diff(a, b, fromfile='', tofile='', fromfiledate='',
             fromdate = '\t{}'.format(fromfiledate) if fromfiledate else ''
             todate = '\t{}'.format(tofiledate) if tofiledate else ''
             yield '--- {}{}{}'.format(
-                fromfile.decode("ascii"),
+                fromfile.decode(tree_encoding),
                 fromdate,
                 lineterm
-                ).encode('ascii')
+                ).encode(output_encoding)
             yield '+++ {}{}{}'.format(
-                tofile.decode("ascii"),
+                tofile.decode(tree_encoding),
                 todate,
                 lineterm
-                ).encode('ascii')
+                ).encode(output_encoding)
 
         first, last = group[0], group[-1]
         file1_range = _format_range_unified(first[1], last[2])
@@ -134,7 +135,7 @@ def unified_diff(a, b, fromfile='', tofile='', fromfiledate='',
             file1_range,
             file2_range,
             lineterm
-             ).encode('ascii')
+             ).encode(output_encoding)
 
         for tag, i1, i2, j1, j2 in group:
             if tag == 'equal':

+ 81 - 35
dulwich/porcelain.py

@@ -70,6 +70,12 @@ import shutil
 import stat
 import sys
 import time
+from typing import (
+    Dict,
+    Optional,
+    Tuple,
+    Union,
+    )
 
 from dulwich.archive import (
     tar_stream,
@@ -127,6 +133,7 @@ from dulwich.refs import (
     ANNOTATED_TAG_SUFFIX,
     LOCAL_BRANCH_PREFIX,
     strip_peeled_refs,
+    RefsContainer,
 )
 from dulwich.repo import (BaseRepo, Repo)
 from dulwich.server import (
@@ -354,9 +361,6 @@ def clone(source, target=None, bare=False, checkout=None,
 
     reflog_message = b'clone: from ' + source.encode('utf-8')
     try:
-        fetch_result = fetch(
-            r, source, origin, errstream=errstream, message=reflog_message,
-            depth=depth, **kwargs)
         target_config = r.get_config()
         if not isinstance(source, bytes):
             source = source.encode(DEFAULT_ENCODING)
@@ -365,10 +369,13 @@ def clone(source, target=None, bare=False, checkout=None,
             (b'remote', origin), b'fetch',
             b'+refs/heads/*:refs/remotes/' + origin + b'/*')
         target_config.write_to_path()
+        fetch_result = fetch(
+            r, origin, errstream=errstream, message=reflog_message,
+            depth=depth, **kwargs)
         # TODO(jelmer): Support symref capability,
         # https://github.com/jelmer/dulwich/issues/485
         try:
-            head = r[fetch_result[b'HEAD']]
+            head = r[fetch_result.refs[b'HEAD']]
         except KeyError:
             head = None
         else:
@@ -869,7 +876,34 @@ def reset(repo, mode, treeish="HEAD"):
         r.reset_index(tree.id)
 
 
-def push(repo, remote_location, refspecs,
+def get_remote_repo(
+        repo: Repo,
+        remote_location: Optional[Union[str, bytes]] = None
+        ) -> Tuple[Optional[str], str]:
+    config = repo.get_config()
+    if remote_location is None:
+        remote_location = get_branch_remote(repo)
+    if isinstance(remote_location, str):
+        encoded_location = remote_location.encode()
+    else:
+        encoded_location = remote_location
+
+    section = (b'remote', encoded_location)
+
+    remote_name = None  # type: Optional[str]
+
+    if config.has_section(section):
+        remote_name = encoded_location.decode()
+        url = config.get(section, 'url')
+        encoded_location = url
+    else:
+        remote_name = None
+        config = None
+
+    return (remote_name, encoded_location.decode())
+
+
+def push(repo, remote_location=None, refspecs=None,
          outstream=default_bytes_out_stream,
          errstream=default_bytes_err_stream, **kwargs):
     """Remote push with dulwich via dulwich.client
@@ -884,12 +918,14 @@ def push(repo, remote_location, refspecs,
 
     # Open the repo
     with open_repo_closing(repo) as r:
+        (remote_name, remote_location) = get_remote_repo(r, remote_location)
 
         # Get the client and path
         client, path = get_transport_and_path(
                 remote_location, config=r.get_config_stack(), **kwargs)
 
         selected_refs = []
+        remote_changed_refs = {}
 
         def update_refs(refs):
             selected_refs.extend(parse_reftuples(r.refs, refs, refspecs))
@@ -898,8 +934,10 @@ def push(repo, remote_location, refspecs,
             for (lh, rh, force) in selected_refs:
                 if lh is None:
                     new_refs[rh] = ZERO_SHA
+                    remote_changed_refs[rh] = None
                 else:
                     new_refs[rh] = r.refs[lh]
+                    remote_changed_refs[rh] = r.refs[lh]
             return new_refs
 
         err_encoding = getattr(errstream, 'encoding', None) or DEFAULT_ENCODING
@@ -919,6 +957,9 @@ def push(repo, remote_location, refspecs,
             errstream.write(b"Push to " + remote_location_bytes +
                             b" failed -> " + e.args[0] + b"\n")
 
+        if remote_name is not None:
+            _import_remote_refs(r.refs, remote_name, remote_changed_refs)
+
 
 def pull(repo, remote_location=None, refspecs=None,
          outstream=default_bytes_out_stream,
@@ -934,14 +975,7 @@ def pull(repo, remote_location=None, refspecs=None,
     """
     # Open the repo
     with open_repo_closing(repo) as r:
-        if remote_location is None:
-            config = r.get_config()
-            remote_name = get_branch_remote(r.path)
-            section = (b'remote', remote_name)
-
-            if config.has_section(section):
-                url = config.get(section, 'url')
-                remote_location = url.decode()
+        (remote_name, remote_location) = get_remote_repo(r, remote_location)
 
         if refspecs is None:
             refspecs = [b"HEAD"]
@@ -963,6 +997,8 @@ def pull(repo, remote_location=None, refspecs=None,
         # Perform 'git checkout .' - syncs staged changes
         tree = r[b"HEAD"].tree
         r.reset_index(tree=tree)
+        if remote_name is not None:
+            _import_remote_refs(r.refs, remote_name, fetch_result.refs)
 
 
 def status(repo=".", ignored=False):
@@ -1251,21 +1287,40 @@ def get_branch_remote(repo):
         branch_name = active_branch(r.path)
         config = r.get_config()
         try:
-            remote_name = config.get((b'branch', branch_name), 'remote')
+            remote_name = config.get((b'branch', branch_name), b'remote')
         except KeyError:
             remote_name = b'origin'
     return remote_name
 
 
-def fetch(repo, remote_location, remote_name=b'origin', outstream=sys.stdout,
-          errstream=default_bytes_err_stream, message=None, depth=None,
-          prune=False, prune_tags=False, **kwargs):
+def _import_remote_refs(
+        refs_container: RefsContainer, remote_name: str,
+        refs: Dict[str, str], message: Optional[bytes] = None,
+        prune: bool = False, prune_tags: bool = False):
+    stripped_refs = strip_peeled_refs(refs)
+    branches = {
+        n[len(LOCAL_BRANCH_PREFIX):]: v for (n, v) in stripped_refs.items()
+        if n.startswith(LOCAL_BRANCH_PREFIX)}
+    refs_container.import_refs(
+        b'refs/remotes/' + remote_name.encode(), branches, message=message,
+        prune=prune)
+    tags = {
+        n[len(b'refs/tags/'):]: v for (n, v) in stripped_refs.items()
+        if n.startswith(b'refs/tags/') and
+        not n.endswith(ANNOTATED_TAG_SUFFIX)}
+    refs_container.import_refs(
+        b'refs/tags', tags, message=message,
+        prune=prune_tags)
+
+
+def fetch(repo, remote_location=None,
+          outstream=sys.stdout, errstream=default_bytes_err_stream,
+          message=None, depth=None, prune=False, prune_tags=False, **kwargs):
     """Fetch objects from a remote server.
 
     Args:
       repo: Path to the repository
       remote_location: String identifying a remote server
-      remote_name: Name for remote server
       outstream: Output stream (defaults to stdout)
       errstream: Error stream (defaults to stderr)
       message: Reflog message (defaults to b"fetch: from <remote_name>")
@@ -1275,28 +1330,19 @@ def fetch(repo, remote_location, remote_name=b'origin', outstream=sys.stdout,
     Returns:
       Dictionary with refs on the remote
     """
-    if message is None:
-        message = b'fetch: from ' + remote_location.encode("utf-8")
     with open_repo_closing(repo) as r:
+        (remote_name, remote_location) = get_remote_repo(r, remote_location)
+        if message is None:
+            message = b'fetch: from ' + remote_location.encode("utf-8")
         client, path = get_transport_and_path(
             remote_location, config=r.get_config_stack(), **kwargs)
         fetch_result = client.fetch(path, r, progress=errstream.write,
                                     depth=depth)
-        stripped_refs = strip_peeled_refs(fetch_result.refs)
-        branches = {
-            n[len(LOCAL_BRANCH_PREFIX):]: v for (n, v) in stripped_refs.items()
-            if n.startswith(LOCAL_BRANCH_PREFIX)}
-        r.refs.import_refs(
-            b'refs/remotes/' + remote_name, branches, message=message,
-            prune=prune)
-        tags = {
-            n[len(b'refs/tags/'):]: v for (n, v) in stripped_refs.items()
-            if n.startswith(b'refs/tags/') and
-            not n.endswith(ANNOTATED_TAG_SUFFIX)}
-        r.refs.import_refs(
-            b'refs/tags', tags, message=message,
-            prune=prune_tags)
-    return fetch_result.refs
+        if remote_name is not None:
+            _import_remote_refs(
+                r.refs, remote_name, fetch_result.refs, message, prune=prune,
+                prune_tags=prune_tags)
+    return fetch_result
 
 
 def ls_remote(remote, config=None, **kwargs):

+ 4 - 0
dulwich/protocol.py

@@ -67,6 +67,8 @@ CAPABILITY_SIDE_BAND_64K = b'side-band-64k'
 CAPABILITY_THIN_PACK = b'thin-pack'
 CAPABILITY_AGENT = b'agent'
 CAPABILITY_SYMREF = b'symref'
+CAPABILITY_ALLOW_TIP_SHA1_IN_WANT = b'allow-tip-sha1-in-want'
+CAPABILITY_ALLOW_REACHABLE_SHA1_IN_WANT = b'allow-reachable-sha1-in-want'
 
 # Magic ref that is used to attach capabilities to when
 # there are no refs. Should always be ste to ZERO_SHA.
@@ -88,6 +90,8 @@ KNOWN_UPLOAD_CAPABILITIES = set(COMMON_CAPABILITIES + [
     CAPABILITY_SHALLOW,
     CAPABILITY_DEEPEN_NOT,
     CAPABILITY_DEEPEN_RELATIVE,
+    CAPABILITY_ALLOW_TIP_SHA1_IN_WANT,
+    CAPABILITY_ALLOW_REACHABLE_SHA1_IN_WANT,
     ])
 KNOWN_RECEIVE_CAPABILITIES = set(COMMON_CAPABILITIES + [
     CAPABILITY_REPORT_STATUS,

+ 7 - 3
dulwich/refs.py

@@ -146,15 +146,19 @@ class RefsContainer(object):
         else:
             to_delete = set()
         for name, value in other.items():
-            self.set_if_equals(b'/'.join((base, name)), None, value,
-                               message=message)
+            if value is None:
+                to_delete.add(name)
+            else:
+                self.set_if_equals(b'/'.join((base, name)), None, value,
+                                   message=message)
             if to_delete:
                 try:
                     to_delete.remove(name)
                 except KeyError:
                     pass
         for ref in to_delete:
-            self.remove_if_equals(b'/'.join((base, ref)), None)
+            self.remove_if_equals(
+                b'/'.join((base, ref)), None, message=message)
 
     def allkeys(self):
         """All refs present in this container."""

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

@@ -311,6 +311,21 @@ class DulwichClientTestBase(object):
                 self._build_path('/dest'), lambda _: sendrefs, gen_pack)
             self.assertFalse(b"refs/heads/abranch" in dest.refs)
 
+    def test_send_new_branch_empty_pack(self):
+        with repo.Repo(os.path.join(self.gitroot, 'dest')) as dest:
+            dummy_commit = self.make_dummy_commit(dest)
+            dest.refs[b'refs/heads/master'] = dummy_commit
+            dest.refs[b'refs/heads/abranch'] = dummy_commit
+            sendrefs = {b'refs/heads/bbranch': dummy_commit}
+
+            def gen_pack(have, want, ofs_delta=False):
+                return 0, []
+            c = self._client()
+            self.assertEqual(dest.refs[b"refs/heads/abranch"], dummy_commit)
+            c.send_pack(
+                self._build_path('/dest'), lambda _: sendrefs, gen_pack)
+            self.assertEqual(dummy_commit, dest.refs[b"refs/heads/abranch"])
+
     def test_get_refs(self):
         c = self._client()
         refs = c.get_refs(self._build_path('/server_new.export'))

+ 2 - 1
dulwich/tests/test_client.py

@@ -226,6 +226,7 @@ class GitClientTests(TestCase):
                           update_refs, generate_pack_data)
 
     def test_send_pack_none(self):
+        # Set ref to current value
         self.rin.write(
             b'0078310ca9477129b8586fa2afc779c1f57cf64bba6c '
             b'refs/heads/master\x00 report-status delete-refs '
@@ -242,7 +243,7 @@ class GitClientTests(TestCase):
         def generate_pack_data(have, want, ofs_delta=False):
             return 0, []
 
-        self.client.send_pack(b'/', update_refs, set(), generate_pack_data)
+        self.client.send_pack(b'/', update_refs, generate_pack_data)
         self.assertEqual(self.rout.getvalue(), b'0000')
 
     def test_send_pack_keep_and_delete(self):

+ 12 - 4
dulwich/tests/test_porcelain.py

@@ -900,9 +900,13 @@ class PushTests(PorcelainTestCase):
         self.repo.refs[refs_path] = new_id
 
         # Push to the remote
-        porcelain.push(clone_path, self.repo.path, b"HEAD:" + refs_path,
+        porcelain.push(clone_path, 'origin', b"HEAD:" + refs_path,
                        outstream=outstream, errstream=errstream)
 
+        self.assertEqual(
+            target_repo.refs[b'refs/remotes/origin/foo'],
+            target_repo.refs[b'HEAD'])
+
         # Check that the target and source
         with Repo(clone_path) as r_clone:
             self.assertEqual({
@@ -1378,7 +1382,7 @@ class FetchTests(PorcelainTestCase):
         target_repo.close()
 
         # Fetch changes into the cloned repo
-        porcelain.fetch(target_path, self.repo.path,
+        porcelain.fetch(target_path, 'origin',
                         outstream=outstream, errstream=errstream)
 
         # Assert that fetch updated the local image of the remote
@@ -1390,7 +1394,7 @@ class FetchTests(PorcelainTestCase):
             self.assertTrue(self.repo[b'HEAD'].id in r)
 
     def test_with_remote_name(self):
-        remote_name = b'origin'
+        remote_name = 'origin'
         outstream = BytesIO()
         errstream = BytesIO()
 
@@ -1420,10 +1424,14 @@ class FetchTests(PorcelainTestCase):
                          committer=b'test2 <email>')
 
         self.assertFalse(self.repo[b'HEAD'].id in target_repo)
+
+        target_config = target_repo.get_config()
+        target_config.set(
+            (b'remote', remote_name.encode()), b'url', self.repo.path.encode())
         target_repo.close()
 
         # Fetch changes into the cloned repo
-        porcelain.fetch(target_path, self.repo.path, remote_name=remote_name,
+        porcelain.fetch(target_path, remote_name,
                         outstream=outstream, errstream=errstream)
 
         # Assert that fetch updated the local image of the remote

+ 11 - 3
setup.py

@@ -15,7 +15,7 @@ import io
 import os
 import sys
 
-dulwich_version_string = '0.20.2'
+dulwich_version_string = '0.20.3'
 
 
 class DulwichDistribution(Distribution):
@@ -64,7 +64,7 @@ ext_modules = [
 ]
 
 setup_kwargs = {}
-
+scripts = ['bin/dul-receive-pack', 'bin/dul-upload-pack']
 if has_setuptools:
     setup_kwargs['extras_require'] = {
         'fastimport': ['fastimport'],
@@ -75,6 +75,14 @@ if has_setuptools:
     setup_kwargs['include_package_data'] = True
     setup_kwargs['test_suite'] = 'dulwich.tests.test_suite'
     setup_kwargs['tests_require'] = tests_require
+    setup_kwargs['entry_points'] = {
+        "console_scripts": [
+            "dulwich=dulwich.cli:main",
+        ]}
+    setup_kwargs['python_requires'] = '>=3.5'
+else:
+    scripts.append('bin/dulwich')
+
 
 with io.open(os.path.join(os.path.dirname(__file__), "README.rst"),
              encoding="utf-8") as f:
@@ -97,7 +105,7 @@ setup(name='dulwich',
       packages=['dulwich', 'dulwich.tests', 'dulwich.tests.compat',
                 'dulwich.contrib'],
       package_data={'': ['../docs/tutorial/*.txt']},
-      scripts=['bin/dulwich', 'bin/dul-receive-pack', 'bin/dul-upload-pack'],
+      scripts=scripts,
       ext_modules=ext_modules,
       distclass=DulwichDistribution,
       classifiers=[