diff --git a/.gitignore b/.gitignore
index b721d10d99c5ab4424b155b5de02644ff1c395f1..bda232640bf2c3c592479004ecb1c4665bad150a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 *.pyc
 /bin/
 env/
+.idea
diff --git a/klaus/__init__.py b/klaus/__init__.py
index dcdd2293559f59b59f94867c8613246602f1a80f..53e0d690f53b7735135ce3fe12afaa5ed89e6708 100644
--- a/klaus/__init__.py
+++ b/klaus/__init__.py
@@ -56,6 +56,7 @@ class Klaus(flask.Flask):
             ('history',     '/<repo>/tree/<rev>/'),
             ('history',     '/<repo>/tree/<rev>/<path:path>'),
             ('robots_txt',  '/robots.txt/'),
+            ('download',    '/<repo>/tarball/<rev>/'),
         ]:
             self.add_url_rule(rule, view_func=getattr(views, endpoint))
 
diff --git a/klaus/tarutils.py b/klaus/tarutils.py
new file mode 100644
index 0000000000000000000000000000000000000000..fcdf7f5f31cb24757410921f99c0cfe892159480
--- /dev/null
+++ b/klaus/tarutils.py
@@ -0,0 +1,82 @@
+import os
+import stat
+import tarfile
+from io import BytesIO
+
+
+class ListBytesIO(object):
+    """
+    Turns a list of bytestrings into a file-like object.
+
+    This is similar to creating a `BytesIO` from a concatenation of the
+    bytestring list, but saves memory by NOT creating one giant bytestring first::
+
+        BytesIO(b''.join(list_of_bytestrings)) =~= ListBytesIO(list_of_bytestrings)
+    """
+    def __init__(self, contents):
+        self.contents = contents
+        self.pos = (0, 0)
+
+    def read(self, maxbytes=None):
+        if maxbytes < 0:
+            maxbytes = float('inf')
+
+        buf = []
+        chunk, cursor = self.pos
+
+        while chunk < len(self.contents):
+            if maxbytes < len(self.contents[chunk]) - cursor:
+                buf.append(self.contents[chunk][cursor:cursor+maxbytes])
+                cursor += maxbytes
+                self.pos = (chunk, cursor)
+                break
+            else:
+                buf.append(self.contents[chunk][cursor:])
+                maxbytes -= len(self.contents[chunk]) - cursor
+                chunk += 1
+                cursor = 0
+                self.pos = (chunk, cursor)
+        return b''.join(buf)
+
+
+def tar_stream(repo, tree, mtime):
+    """
+    Returns a generator that lazily assembles a .tar.gz archive, yielding it in
+    pieces (bytestrings). To obtain the complete .tar.gz binary file, simply
+    concatenate these chunks.
+
+    'repo' and 'tree' are the dulwich Repo and Tree objects the archive shall be
+    created from. 'mtime' is a UNIX timestamp that is assigned as the modification
+    time of all files in the resulting .tar.gz archive.
+    """
+    buf = BytesIO()
+    with tarfile.open(None, "w", buf) as tar:
+        for entry_abspath, entry in walk_tree(repo, tree):
+            blob = repo[entry.sha]
+            data = ListBytesIO(blob.chunked)
+
+            info = tarfile.TarInfo()
+            info.name = entry_abspath
+            info.size = blob.raw_length()
+            info.mode = entry.mode
+            info.mtime = mtime
+
+            tar.addfile(info, data)
+            yield buf.getvalue()
+            buf.truncate(0)
+            buf.seek(0)
+    yield buf.getvalue()
+
+
+def walk_tree(repo, tree, root=''):
+    """
+    Recursively walk a dulwich Tree, yielding tuples of (absolute path,
+    TreeEntry) along the way.
+    """
+    for entry in tree.iteritems():
+        entry_abspath = os.path.join(root, entry.path)
+        if stat.S_ISDIR(entry.mode):
+            for _ in walk_tree(repo, repo[entry.sha], entry_abspath):
+                yield _
+        else:
+            yield (entry_abspath, entry)
diff --git a/klaus/templates/tree.inc.html b/klaus/templates/tree.inc.html
index 61853c577c3e8fbec06a29ff3594f41bfc10a11a..94481e2210becb155bcf27fc8bd45f2c7ca8dcf3 100644
--- a/klaus/templates/tree.inc.html
+++ b/klaus/templates/tree.inc.html
@@ -1,5 +1,7 @@
 <div class=tree>
-  <h2>Tree @<a href="{{ url_for('commit', repo=repo.name, rev=rev) }}">{{ rev|shorten_sha1 }}</a></h2>
+  <h2>Tree @<a href="{{ url_for('commit', repo=repo.name, rev=rev) }}">{{ rev|shorten_sha1 }}</a>
+    <span>(<a href="{{ url_for('download', repo=repo.name, rev=rev) }}">Download tar</a>)</span>
+  </h2>
   <ul>
     {% for _, name, fullpath in root_tree.dirs %}
     <li><a href="{{ url_for('history', repo=repo.name, rev=rev, path=fullpath) }}" class=dir>{{ name|force_unicode }}</a></li>
diff --git a/klaus/views.py b/klaus/views.py
index 326e9a8bbd744c71408c0fb944f7322f5916c96a..07c27fe5fdb40843e5985f2459c080a515f3d83b 100644
--- a/klaus/views.py
+++ b/klaus/views.py
@@ -9,7 +9,7 @@ from werkzeug.exceptions import NotFound
 
 from dulwich.objects import Blob
 
-from klaus import markup
+from klaus import markup, tarutils
 from klaus.utils import parent_directory, subpaths, pygmentize, \
                         force_unicode, guess_is_binary, guess_is_image
 
@@ -233,8 +233,31 @@ class RawView(BlobViewMixin, BaseRepoView):
         return Response(self.context['blob_or_tree'].chunked, mimetype='')
 
 
-#                                     TODO v
+class DownloadView(BaseRepoView):
+    """
+    Download a repo as a tar file
+    """
+    def get_response(self):
+        tarname = "%s@%s.tar" % (self.context['repo'].name, self.context['rev'])
+        headers = {
+            'Content-Disposition': "attachment; filename=%s" % tarname,
+            'Cache-Control': "no-store",  # Disables browser caching
+        }
+
+        tar_stream = tarutils.tar_stream(
+            self.context['repo'],
+            self.context['blob_or_tree'],
+            self.context['commit'].commit_time
+        )
+        return Response(
+            tar_stream,
+            mimetype="application/tar",
+            headers=headers
+        )
+
+
 history = HistoryView.as_view('history', 'history', 'history.html')
 commit = BaseRepoView.as_view('commit', 'commit', 'view_commit.html')
 blob = BlobView.as_view('blob', 'blob', 'view_blob.html')
 raw = RawView.as_view('raw', 'raw')
+download = DownloadView.as_view('download', 'download')