You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by jo...@apache.org on 2013/06/11 01:31:36 UTC

git commit: [#6272] WIP: Make SCM log indexless

Updated Branches:
  refs/heads/cj/6272 [created] 0a36f190e


[#6272] WIP: Make SCM log indexless

Signed-off-by: Cory Johns <cj...@slashdotmedia.com>


Project: http://git-wip-us.apache.org/repos/asf/incubator-allura/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-allura/commit/0a36f190
Tree: http://git-wip-us.apache.org/repos/asf/incubator-allura/tree/0a36f190
Diff: http://git-wip-us.apache.org/repos/asf/incubator-allura/diff/0a36f190

Branch: refs/heads/cj/6272
Commit: 0a36f190e0acebc5c03a3b5983555483c9a77f27
Parents: 22dc0fe
Author: Cory Johns <cj...@slashdotmedia.com>
Authored: Mon Jun 10 23:24:51 2013 +0000
Committer: Cory Johns <cj...@slashdotmedia.com>
Committed: Mon Jun 10 23:24:51 2013 +0000

----------------------------------------------------------------------
 Allura/allura/controllers/repository.py       |  73 ++++++-------
 Allura/allura/lib/helpers.py                  |  11 ++
 Allura/allura/model/repository.py             | 118 ++++++++-------------
 Allura/allura/templates/jinja_master/lib.html |   7 ++
 Allura/allura/templates/widgets/repo/log.html |  34 +++---
 ForgeGit/forgegit/model/git_repo.py           |  79 +++++++++++---
 ForgeSVN/forgesvn/model/svn.py                | 104 +++++++++++-------
 7 files changed, 237 insertions(+), 189 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/0a36f190/Allura/allura/controllers/repository.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/repository.py b/Allura/allura/controllers/repository.py
index 084e17a..ecbd1b3 100644
--- a/Allura/allura/controllers/repository.py
+++ b/Allura/allura/controllers/repository.py
@@ -22,6 +22,7 @@ import re
 import difflib
 from urllib import quote, unquote
 from collections import defaultdict
+from itertools import islice
 
 from pylons import tmpl_context as c, app_globals as g
 from pylons import request, response
@@ -206,11 +207,11 @@ class RepoRootController(BaseController, FeedController):
     @expose('json:')
     def commit_browser_data(self):
         head_ids = [ head.object_id for head in c.app.repo.get_heads() ]
-        commit_ids = list(c.app.repo.commitlog(head_ids))
+        commit_ids = list(c.app.repo.log(head_ids, id_only=True))
         log.info('Grab %d commit objects by ID', len(commit_ids))
-        commits_by_id = dict(
-            (c_obj._id, c_obj)
-            for c_obj in M.repo.CommitDoc.m.find(dict(_id={'$in': commit_ids})))
+        commits_by_id = {
+            c_obj._id: c_obj
+            for c_obj in M.repo.CommitDoc.m.find(dict(_id={'$in': commit_ids}))}
         log.info('... build graph')
         parents = {}
         children = defaultdict(list)
@@ -276,32 +277,30 @@ class RepoRestController(RepoRootController):
         return dict(commit_count=len(all_commits))
 
     @expose('json:')
-    def commits(self, **kw):
-        page_size = 25
-        offset = (int(kw.get('page',1)) * page_size) - page_size
-        revisions = c.app.repo.log(offset=offset, limit=page_size)
-
-        return dict(
-            commits=[
-                dict(
-                    parents=[dict(id=p) for p in commit.parent_ids],
-                    author=dict(
-                        name=commit.authored.name,
-                        email=commit.authored.email,
-                    ),
-                    url=commit.url(),
-                    id=commit._id,
-                    committed_date=commit.committed.date,
-                    authored_date=commit.authored.date,
-                    message=commit.message,
-                    tree=commit.tree._id,
-                    committer=dict(
-                        name=commit.committed.name,
-                        email=commit.committed.email,
-                    ),
-                )
+    def commits(self, rev=None, **kw):
+        revisions = islice(c.app.repo.log(rev), 25)
+
+        return {
+            'commits': [
+                {
+                    'parents': [dict(id=p) for p in commit.parent_ids],
+                    'author': {
+                        'name': commit.authored.name,
+                        'email': commit.authored.email,
+                        },
+                    'url': commit.url(),
+                    'id': commit._id,
+                    'committed_date': commit.committed.date,
+                    'authored_date': commit.authored.date,
+                    'message': commit.message,
+                    'tree': commit.tree._id,
+                    'committer': {
+                        'name': commit.committed.name,
+                        'email': commit.committed.email,
+                    },
+                }
             for commit in revisions
-        ])
+        ]}
 
 class MergeRequestsController(object):
     mr_filter=SCMMergeRequestFilterWidget()
@@ -478,21 +477,19 @@ class CommitBrowser(BaseController):
         if path:
             path = path.lstrip('/')
             is_file = self.tree._tree.get_blob_by_path(path) is not None
-        params = dict(path=path, rev=self._commit._id)
-        commits = list(c.app.repo.commits(limit=limit+1, **params))
+        commits = list(islice(c.app.repo.log(
+                revs=self._commit._id,
+                path=path,
+                id_only=False,
+                page_size=limit+1), limit+1))
         next_commit = None
         if len(commits) > limit:
-            next_commit = M.repo.Commit.query.get(_id=commits.pop())
-            next_commit.set_context(c.app.repo)
-        revisions = list(M.repo.Commit.query.find({'_id': {'$in': commits}}))
-        for commit in revisions:
-            commit.set_context(c.app.repo)
-        revisions = sorted(revisions, key=lambda c:commits.index(c._id))
+            next_commit = commits.pop()
         c.log_widget = self.log_widget
         return dict(
             username=c.user._id and c.user.username,
             branch=None,
-            log=revisions,
+            log=commits,
             next_commit=next_commit,
             limit=limit,
             path=path,

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/0a36f190/Allura/allura/lib/helpers.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/helpers.py b/Allura/allura/lib/helpers.py
index 9f87115..9c2bbdc 100644
--- a/Allura/allura/lib/helpers.py
+++ b/Allura/allura/lib/helpers.py
@@ -138,6 +138,17 @@ def really_unicode(s):
         yield 'latin-1'
     return _attempt_encodings(s, encodings())
 
+def find_user(email=None, username=None, display_name=None):
+    from allura import model as M
+    user = None
+    if email:
+        user = M.User.by_email_address(email)
+    if not user and username:
+        user = M.User.by_username(username)
+    if not user and display_name:
+        user = M.User.by_display_name(display_name)
+    return user
+
 def find_project(url_path):
     from allura import model as M
     for n in M.Neighborhood.query.find():

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/0a36f190/Allura/allura/model/repository.py
----------------------------------------------------------------------
diff --git a/Allura/allura/model/repository.py b/Allura/allura/model/repository.py
index dfb92f1..e624d55 100644
--- a/Allura/allura/model/repository.py
+++ b/Allura/allura/model/repository.py
@@ -101,11 +101,21 @@ class RepositoryImplementation(object):
         the repo.  Optionally provide a path from which to copy existing hooks.'''
         raise NotImplementedError, '_setup_hooks'
 
-    def log(self, object_id, skip, count): # pragma no cover
-        '''Return a list of (object_id, ci) beginning at the given commit ID and continuing
-        to the parent nodes in a breadth-first traversal.  Also return a list of 'next commit' options
-        (these are candidates for he next commit after 'count' commits have been
-        exhausted).'''
+    def log(self, revs=None, path=None, id_only=True, **kw): # pragma no cover
+        """
+        Returns a generator that returns information about commits reacable
+        by revs.
+        
+        revs can be None or a list or tuple of identifiers, each of which
+        can be anything parsable by self.commit().  If revs is None, the
+        default branch head will be used.
+
+        If path is not None, only commits which modify files under path
+        will be included.
+
+        If id_only is True, returns only the commit ID (which can be faster),
+        otherwise it returns detailed information about each commit.
+        """
         raise NotImplementedError, 'log'
 
     def compute_tree_new(self, commit, path='/'): # pragma no cover
@@ -358,14 +368,6 @@ class Repository(Artifact, ActivityObject):
         """
         return self._impl.tags
 
-    def _log(self, rev, skip, limit):
-        head = self.commit(rev)
-        if head is None: return
-        for _id in self.commitlog([head._id], skip, limit):
-            ci = head.query.get(_id=_id)
-            ci.set_context(self)
-            yield ci
-
     def init_as_clone(self, source_path, source_name, source_url):
         self.upstream_repo.name = source_name
         self.upstream_repo.url = source_url
@@ -376,68 +378,34 @@ class Repository(Artifact, ActivityObject):
         g.post_event('repo_cloned', source_url, source_path)
         self.refresh(notify=False, new_clone=True)
 
-    def log(self, branch='master', offset=0, limit=10):
-        return list(self._log(branch, offset, limit))
-
-    def commitlog(self, commit_ids, skip=0, limit=sys.maxint):
-        seen = set()
-        def _visit(commit_id):
-            if commit_id in seen: return
-            run = CommitRunDoc.m.get(commit_ids=commit_id)
-            if run is None: return
-            index = False
-            for pos, (oid, time) in enumerate(izip(run.commit_ids, run.commit_times)):
-                if oid == commit_id: index = True
-                elif not index: continue
-                seen.add(oid)
-                ci_times[oid] = time
-                if pos+1 < len(run.commit_ids):
-                    ci_parents[oid] = [ run.commit_ids[pos+1] ]
-                else:
-                    ci_parents[oid] = run.parent_commit_ids
-            for oid in run.parent_commit_ids:
-                if oid not in seen:
-                    _visit(oid)
-
-        def _gen_ids(commit_ids, skip, limit):
-            # Traverse the graph in topo order, yielding commit IDs
-            commits = set(commit_ids)
-            new_parent = None
-            while commits and limit:
-                # next commit is latest commit that's valid to log
-                if new_parent in commits:
-                    ci = new_parent
-                else:
-                    ci = max(commits, key=lambda ci:ci_times[ci])
-                commits.remove(ci)
-                # remove this commit from its parents children and add any childless
-                # parents to the 'ready set'
-                new_parent = None
-                for oid in ci_parents.get(ci, []):
-                    children = ci_children[oid]
-                    children.discard(ci)
-                    if not children:
-                        commits.add(oid)
-                        new_parent = oid
-                if skip:
-                    skip -= 1
-                    continue
-                else:
-                    limit -= 1
-                    yield ci
-
-        # Load all the runs to build a commit graph
-        ci_times = {}
-        ci_parents = {}
-        ci_children = defaultdict(set)
-        log.info('Build commit graph')
-        for cid in commit_ids:
-            _visit(cid)
-        for oid, parents in ci_parents.iteritems():
-            for ci_parent in parents:
-                ci_children[ci_parent].add(oid)
-
-        return _gen_ids(commit_ids, skip, limit)
+    def log(self, revs=None, path=None, id_only=True, **kw):
+        """
+        Returns a generator that returns information about commits reacable
+        by revs which modify path.
+        
+        revs can either be a single revision identifier or a list or tuple
+        of identifiers, each of which can be anything parsable by self.commit().
+        If revs is None, the default branch head will be used.
+
+        If path is not None, then only commits which change files under path
+        will be included.
+
+        If id_only is True, returns only the commit ID (which can be faster),
+        otherwise it returns detailed information about each commit.
+        """
+        if revs is not None and not isinstance(revs, (list, tuple)):
+            revs = [revs]
+        return self._impl.log(revs, path, id_only, **kw)
+
+    def commitlog(self, revs):
+        """
+        Return a generator that returns Commit model instances reachable by
+        the commits specified by revs.
+        """
+        for ci_id in self.log(revs, id_only=True):
+            commit = self.commit(ci_id)
+            commit.set_context(self)
+            yield commit
 
     def count(self, branch='master'):
         try:

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/0a36f190/Allura/allura/templates/jinja_master/lib.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/jinja_master/lib.html b/Allura/allura/templates/jinja_master/lib.html
index 3897e5f..c9d5c2f 100644
--- a/Allura/allura/templates/jinja_master/lib.html
+++ b/Allura/allura/templates/jinja_master/lib.html
@@ -58,6 +58,13 @@
   {% endif %}
 {%- endmacro %}
 
+{% macro user_link(email, name, size=None) -%}
+    {% set user = h.find_user(email) -%}
+    {% if user %}<a href="{{user.url()}}">{% endif -%}
+        {{ email_gravatar(email, name, size) }} {{ name }}
+    {%- if user %}</a>{% endif -%}
+{%- endmacro %}
+
 {% macro file_field(name, label) %}
   {% if label %}
   <label for="{{name}}">{{label}}</label>

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/0a36f190/Allura/allura/templates/widgets/repo/log.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/widgets/repo/log.html b/Allura/allura/templates/widgets/repo/log.html
index 61aadab..183dbd7 100644
--- a/Allura/allura/templates/widgets/repo/log.html
+++ b/Allura/allura/templates/widgets/repo/log.html
@@ -16,7 +16,7 @@
        specific language governing permissions and limitations
        under the License.
 -#}
-{% from 'allura:templates/jinja_master/lib.html' import email_gravatar, abbr_date with context %}
+{% from 'allura:templates/jinja_master/lib.html' import user_link, abbr_date with context %}
 {% set app = app or c.app %}
 <div>
   {%if is_file%}
@@ -36,25 +36,15 @@
           <td>
             <div>
                 {%if is_file%}
-                    <div class="grid-1"><input type="checkbox" class="revision" revision="{{commit._id.split(':')[-1]}}" url_commit="{{commit.url()}}"></div>
+                    <div class="grid-1"><input type="checkbox" class="revision" revision="{{commit.id}}" url_commit="{{app.repo.url_for_commit(commit.id)}}"></div>
                 {%endif%}
-                <a href="{{app.repo.url_for_commit(commit)}}">{{commit.shorthand_id()}}</a>
-                {% if app.repo.symbolics_for_commit(commit)[1] %}
-                    ({% for tag in app.repo.symbolics_for_commit(commit)[1] -%}
-                        <a href="{{app.repo.url_for_commit(tag)}}">{{tag}}</a>{% if not loop.last %}&nbsp;{% endif %}
-                    {%- endfor %})
-                {% endif %}
-                {%if is_file%}
-                    ({{commit.tree.get_obj_by_path(request.params.get('path')).size|filesizeformat}})
-                {%endif%}
-                by
-                {{email_gravatar(commit.authored.email, title=commit.authored.name, size=16)}} {{commit.authored.name}}{%if commit.committed.email != commit.authored.email %}, pushed by
-                {% if commit.committer_url %}
-                    <a href="{{commit.committer_url}}">{{email_gravatar(commit.committed.email, title=commit.committed.name, size=16)}}
-                    {{commit.committed.name}}</a>
-                {% else %}
-                {{email_gravatar(commit.committed.email, title=commit.committed.name, size=16)}} {{commit.committed.name}}
-                {% endif %}
+                <a href="{{app.repo.url_for_commit(commit.id)}}">{{app.repo.shorthand_for_commit(commit.id)}}</a>
+                ({% for ref in commit.refs -%}
+                    <a href="{{app.repo.url_for_commit(ref)}}">{{ref}}</a>{% if not loop.last %}&nbsp;{% endif %}
+                {%- endfor %})
+                by {{ user_link(commit.authored.email, commit.authored.name) }}
+                {%- if commit.committed.email != commit.authored.email %},
+                pushed by {{ user_link(commit.committed.email, commit.committed.name) }}
                 {% endif %}
                 {{g.markdown.convert(commit.message)}}
             </div>
@@ -63,7 +53,7 @@
             {% if commit.committed.date %}{{commit.committed.date|datetimeformat}}{% endif %}
           </td>
           <td style="text-align: left; vertical-align: text-top">
-            <a href="{{commit.url()}}tree{{request.params.get('path', '')}}">
+            <a href="{{app.repo.url_for_commit(commit.id)}}tree{{request.params.get('path', '')}}">
             {%if is_file%}
                 View
             {% else %}
@@ -72,7 +62,7 @@
             </a>
               {%if is_file%}
               <br/>
-              <a href="{{commit.url()}}tree{{request.params.get('path', '')}}?format=raw">Download</a>
+              <a href="{{app.repo.url_for_commit(commit.id)}}tree{{request.params.get('path', '')}}?format=raw">Download</a>
               {%endif%}
           </td>
         </tr>
@@ -80,6 +70,6 @@
     </tbody>
   </table>
   {% if show_paging and next_commit %}
-      <a class="page_list" href="{{next_commit.url()}}log{{tg.url(params=request.params)}}">Older ></a>
+      <a class="page_list" href="{{app.repo.url_for_commit(next_commit)}}log{{tg.url(params=request.params)}}">Older ></a>
   {% endif %}
 </div>

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/0a36f190/ForgeGit/forgegit/model/git_repo.py
----------------------------------------------------------------------
diff --git a/ForgeGit/forgegit/model/git_repo.py b/ForgeGit/forgegit/model/git_repo.py
index 27b52d9..c1d1da6 100644
--- a/ForgeGit/forgegit/model/git_repo.py
+++ b/ForgeGit/forgegit/model/git_repo.py
@@ -265,23 +265,70 @@ class GitImplementation(M.RepositoryImplementation):
         commit = self._git.commit(rev)
         return commit.count(path)
 
-    def log(self, object_id, skip, count):
-        obj = self._git.commit(object_id)
-        candidates = [ obj ]
-        result = []
-        seen = set()
-        while count and candidates:
-            candidates.sort(key=lambda c:c.committed_date)
-            obj = candidates.pop(-1)
-            if obj.hexsha in seen: continue
-            seen.add(obj.hexsha)
-            if skip == 0:
-                result.append(obj.hexsha)
-                count -= 1
+    def log(self, revs=None, path=None, id_only=True, **kw):
+        """
+        Returns a generator that returns information about commits reacable
+        by revs.
+        
+        revs can be None or a list or tuple of identifiers, each of which
+        can be anything parsable by self.commit().  If revs is None, the
+        default branch head will be used.
+
+        If path is not None, only commits which modify files under path
+        will be included.
+
+        If id_only is True, returns only the commit ID, otherwise it returns
+        detailed information about each commit.
+        """
+        for ci, refs in self._iter_commits_with_refs(revs, '--', path):
+            if id_only:
+                yield ci.hexsha
             else:
-                skip -= 1
-            candidates += obj.parents
-        return result, [ p.hexsha for p in candidates ]
+                yield {
+                        'id': ci.hexsha,
+                        'message': h.really_unicode(ci.message or '--none--'),
+                        'authored': {
+                                'name': h.really_unicode(ci.author.name or '--none--'),
+                                'email': h.really_unicode(ci.author.email),
+                                'date': datetime.utcfromtimestamp(ci.authored_date),
+                            },
+                        'committed': {
+                                'name': h.really_unicode(ci.committer.name or '--none--'),
+                                'email': h.really_unicode(ci.committer.email),
+                                'date': datetime.utcfromtimestamp(ci.committed_date),
+                            },
+                        'refs': refs,
+                        'parents': [pci.hexsha for pci in ci.parents],
+                    }
+
+    def _iter_commits_with_refs(self, *args, **kwargs):
+        """
+        A reimplementation of GitPython's iter_commits that includes
+        the --decorate option.
+
+        Unfortunately, iter_commits discards the additional info returned
+        by adding --decorate, and the ref names are not exposed on the
+        commit objects without making an entirely separate call to log.
+
+        Ideally, since we're reimplementing it anyway, we would prefer
+        to add all the info we need to the format to avoid the additional
+        overhead of the lazy-load of the commit data, but the commit
+        message is a problem since it can contain newlines which breaks
+        parsing of the log lines (iter_commits can be broken this way,
+        too).  This does keep the id_only case fast and the overhead
+        of lazy-loading the commit data is probably fine.  But if this
+        ends up being a bottleneck, that would be one possibile
+        optimization.
+        """
+        proc = self._git.git.log(*args, format='%H%x00%d', as_process=True, **kwargs)
+        stream = proc.stdout
+        while True:
+            line = stream.readline()
+            if not line:
+                break
+            hexsha, decoration = line.strip().split('\x00')
+            refs = decoration.strip(' ()').split(', ') if decoration else []
+            yield (git.Commit(self._git, gitdb.util.hex_to_bin(hexsha)), refs)
 
     def open_blob(self, blob):
         return _OpenedGitBlob(

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/0a36f190/ForgeSVN/forgesvn/model/svn.py
----------------------------------------------------------------------
diff --git a/ForgeSVN/forgesvn/model/svn.py b/ForgeSVN/forgesvn/model/svn.py
index 23c1887..b9a9f0f 100644
--- a/ForgeSVN/forgesvn/model/svn.py
+++ b/ForgeSVN/forgesvn/model/svn.py
@@ -81,21 +81,6 @@ class Repository(M.Repository):
         # contains the revision, just parse it out from there
         return int(self._impl._revno(ci._id))
 
-    def log(self, branch='HEAD', offset=0, limit=10):
-        return list(self._log(branch, offset, limit))
-
-    def commitlog(self, commit_ids, skip=0, limit=sys.maxint):
-        ci_id = commit_ids[0]
-        if skip > 0:
-            rid, rev = ci_id.split(':')
-            rev = int(rev) - skip
-            ci_id = '%s:%s' % (rid, rev)
-        ci = self._impl.commit(ci_id)
-        while ci is not None and limit > 0:
-            yield ci._id
-            limit -= 1
-            ci = ci.get_parent()
-
     def latest(self, branch=None):
         if self._impl is None: return None
         return self._impl.commit('HEAD')
@@ -186,13 +171,13 @@ class SVNImplementation(M.RepositoryImplementation):
         return 'file://%s%s' % (self._repo.fs_path, self._repo.name)
 
     def shorthand_for_commit(self, oid):
-        return '[r%d]' % self._revno(oid)
+        return '[r%d]' % self._revno(self.rev_parse(oid))
 
     def url_for_commit(self, commit, url_type=None):
-        if isinstance(commit, basestring):
-            object_id = commit
-        else:
+        if isinstance(commit, M.repo.Commit):
             object_id = commit._id
+        else:
+            object_id = self.rev_parse(commit)
         if ':' in object_id:
             object_id = str(self._revno(object_id))
         return os.path.join(self._repo.url(), object_id) + '/'
@@ -299,17 +284,20 @@ class SVNImplementation(M.RepositoryImplementation):
         self._setup_special_files(source_url)
 
     def commit(self, rev):
-        if rev in ('HEAD', None):
-            oid = self._oid(self.head)
-        elif isinstance(rev, int) or rev.isdigit():
-            oid = self._oid(rev)
-        else:
-            oid = rev
+        oid = self.rev_parse(rev)
         result = M.repo.Commit.query.get(_id=oid)
         if result:
             result.set_context(self._repo)
         return result
 
+    def rev_parse(self, rev):
+        if rev in ('HEAD', None):
+            return self._oid(self.head)
+        elif isinstance(rev, int) or rev.isdigit():
+            return self._oid(rev)
+        else:
+            return rev
+
     def all_commit_ids(self):
         """Return a list of commit ids, starting with the head (most recent
         commit) and ending with the root (first commit).
@@ -494,20 +482,60 @@ class SVNImplementation(M.RepositoryImplementation):
         else:
             return self._blob_oid(commit_id, path)
 
-    def log(self, object_id, skip, count):
-        revno = self._revno(object_id)
-        result = []
-        while count and revno:
-            if skip == 0:
-                result.append(self._oid(revno))
-                count -= 1
-            else:
-                skip -= 1
-            revno -= 1
-        if revno:
-            return result, [ self._oid(revno) ]
+    def log(self, revs=None, path=None, id_only=True, page_size=25):
+        """
+        Returns a generator that returns information about commits reacable
+        by revs.
+        
+        revs can be None or a list or tuple of identifiers, each of which
+        can be anything parsable by self.commit().  If revs is None, the
+        default head will be used.
+
+        If path is not None, only commits which modify files under path
+        will be included.
+
+        If id_only is True, returns only the commit ID, otherwise it returns
+        detailed information about each commit.
+
+        Since pysvn doesn't have a generator version of log, this tries to
+        balance pulling too much data from SVN with calling SVN too many
+        times by pulling in pages of page_size at a time.
+        """
+        if revs is None:
+            revno = self.head
+        else:
+            revno = max([self._revno(self.rev_parse(r)) for r in revs])
+        if path is None:
+            url = self._url
         else:
-            return result, []
+            url = '/'.join([self._url, path])
+        while revno > 0:
+            rev = pysvn.Revision(pysvn.opt_revision_kind.number, revno)
+            for ci in self._svn.log(url, revision_start=rev, limit=page_size):
+                if id_only:
+                    yield ci.revision.number
+                else:
+                    yield self._map_log(ci)
+            revno = ci.revision.number - 1
+
+    def _map_log(self, ci):
+        revno = ci.revision.number
+        return {
+                'id': revno,
+                'message': h.really_unicode(ci.get('message', '--none--')),
+                'authored': {
+                        'name': h.really_unicode(ci.get('author', '--none--')),
+                        'email': '',
+                        'date': datetime.utcfromtimestamp(ci.date),
+                    },
+                'committed': {
+                        'name': h.really_unicode(ci.get('author', '--none--')),
+                        'email': '',
+                        'date': datetime.utcfromtimestamp(ci.date),
+                    },
+                'refs': ['HEAD'] if revno == self.head else [],
+                'parents': [revno-1] if revno > 1 else [],
+            }
 
     def open_blob(self, blob):
         data = self._svn.cat(