Browse Source

Add annotate support.

Jelmer Vernooij 9 years ago
parent
commit
e2d3026084
6 changed files with 181 additions and 3 deletions
  1. 26 1
      bin/dulwich
  2. 91 0
      dulwich/annotate.py
  3. 1 0
      dulwich/fastexport.py
  4. 21 1
      dulwich/porcelain.py
  5. 41 0
      dulwich/tests/test_annotate.py
  6. 1 1
      dulwich/walk.py

+ 26 - 1
bin/dulwich

@@ -33,6 +33,7 @@ import sys
 from getopt import getopt
 import optparse
 import signal
+import time
 
 def signal_int(signal, frame):
     sys.exit(1)
@@ -44,7 +45,10 @@ 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.patch import (
+    shortid,
+    write_tree_diff,
+    )
 from dulwich.repo import Repo
 
 
@@ -168,6 +172,26 @@ class cmd_dump_pack(Command):
                 print("\t%s: Unable to apply delta: %r" % (name, e))
 
 
+def format_annotate_line(i, commit, entry, line, time_format=None):
+    if time_format is None:
+        time_format = "%Y-%m-%d %H:%M:%S %z"
+    time_str = time.strftime(time_format, time.gmtime(commit.author_time))
+    author_str = commit.author.split(b' <')[0]
+    return "%s\t%s\t(%s\t%20s\t%d)%s" % (
+            commit.id[:8], entry.path, author_str, time_str, i, line)
+
+
+class cmd_annotate(Command):
+
+    def run(self, args):
+        opts, args = getopt(args, "", [])
+        opts = dict(opts)
+
+        for i, ((commit, entry), line) in enumerate(
+                porcelain.annotate(".", args.pop(0)), 1):
+            sys.stdout.write(format_annotate_line(i, commit, entry, line))
+
+
 class cmd_dump_index(Command):
 
     def run(self, args):
@@ -540,6 +564,7 @@ For a list of supported commands, see 'dulwich help -a'.
 
 commands = {
     "add": cmd_add,
+    "annotate": cmd_annotate,
     "archive": cmd_archive,
     "check-ignore": cmd_check_ignore,
     "clone": cmd_clone,

+ 91 - 0
dulwich/annotate.py

@@ -0,0 +1,91 @@
+# annotate.py -- Annotate files with last changed revision
+# Copyright (C) 2015 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# or (at your option) a later version of the License.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+# MA  02110-1301, USA.
+
+"""Annotate file contents indicating when they were last changed.
+
+Annotated lines are represented as tuples with last modified revision SHA1
+and contents.
+
+Please note that this is a very naive annotate implementation; it's
+"""
+
+import difflib
+
+from dulwich.object_store import tree_lookup_path
+from dulwich.walk import (
+    ORDER_DATE,
+    Walker,
+    )
+
+
+# Walk over ancestry graph breadth-first
+# When checking each revision, find lines that according to difflib.Differ()
+# are common between versions.
+# Any lines that are not in common were introduced by the newer revision.
+# If there were no lines kept from the older version, stop going deeper in the graph.
+
+def update_lines(differ, annotated_lines, new_history_data, new_blob):
+    """Update annotation lines with old blob lines.
+    """
+    old_index = 0
+    for diffline in differ.compare(
+            [l for (h, l) in annotated_lines],
+            new_blob.splitlines()):
+        if diffline[0:1] == '?':
+            continue
+        elif diffline[0:1] == ' ':
+            yield annotated_lines[old_index]
+            old_index += 1
+        elif diffline[0:1] == '+':
+            yield (new_history_data, diffline[2:])
+        elif diffline[0:1] == '-':
+            old_index += 1
+        else:
+            raise RuntimeError('Unknown character %s returned in diff' % diffline[0])
+
+
+def annotate_lines(store, commit_id, path, order=ORDER_DATE, lines=None, follow=True):
+    """Annotate the lines of a blob.
+
+    :param store: Object store to retrieve objects from
+    :param commit_id: Commit id in which to annotate path
+    :param path: Path to annotate
+    :param order: Order in which to process history (defaults to ORDER_DATE)
+    :param lines: Initial lines to compare to (defaults to specified)
+    :param follow: Wether to follow changes across renames/copies
+    :return: List of (commit, line) entries where
+        commit is the oldest commit that changed a line
+    """
+    walker = Walker(store, include=[commit_id], paths=[path], order=order,
+        follow=follow)
+    d = difflib.Differ()
+    revs = []
+    for log_entry in walker:
+        for tree_change in log_entry.changes():
+            if type(tree_change) is not list:
+                tree_change = [tree_change]
+            for change in tree_change:
+                if change.new.path == path:
+                    path = change.old.path
+                    revs.append((log_entry.commit, change.new))
+                    break
+
+    lines = []
+    for (commit, entry) in reversed(revs):
+        lines = list(update_lines(d, lines, (commit, entry), store[entry.sha]))
+    return lines

+ 1 - 0
dulwich/fastexport.py

@@ -46,6 +46,7 @@ import stat  # noqa: E402
 
 
 def split_email(text):
+    # TODO(jelmer): Dedupe this and the same functionality in format_annotate_line.
     (name, email) = text.rsplit(b" <", 1)
     return (name, email.rstrip(b">"))
 

+ 21 - 1
dulwich/porcelain.py

@@ -23,6 +23,7 @@
 Currently implemented:
  * archive
  * add
+ * annotate/blame
  * branch{_create,_delete,_list}
  * check-ignore
  * clone
@@ -36,7 +37,7 @@ Currently implemented:
  * ls-tree
  * pull
  * push
- * rm
+ * remove/rm
  * remote{_add}
  * receive-pack
  * reset
@@ -96,6 +97,7 @@ from dulwich.objects import (
     pretty_format_tree_entry,
     )
 from dulwich.objectspec import (
+    parse_commit,
     parse_object,
     parse_reftuples,
     )
@@ -1131,3 +1133,21 @@ def check_ignore(repo, paths, no_index=False):
                 continue
             if ignore_manager.is_ignored(path):
                 yield path
+
+
+def annotate(repo, path, committish=None):
+    """Annotate the history of a file.
+
+    :param repo: Path to the repository
+    :param path: Path to annotate
+    :param committish: Commit id to find path in
+    :return: List of ((Commit, TreeChange), line) tuples
+    """
+    if committish is None:
+        committish = "HEAD"
+    from dulwich.annotate import annotate_lines
+    with open_repo_closing(repo) as r:
+        commit_id = parse_commit(r, committish).id
+        return annotate_lines(r.object_store, commit_id, path)
+
+blame = annotate

+ 41 - 0
dulwich/tests/test_annotate.py

@@ -0,0 +1,41 @@
+# test_annotate.py -- tests for annotate
+# Copyright (C) 2015 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; version 2
+# of the License or (at your option) a later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+# MA  02110-1301, USA.
+
+"""Tests for annotate support."""
+
+import tarfile
+
+from dulwich.archive import tar_stream
+from dulwich.object_store import (
+    MemoryObjectStore,
+    )
+from dulwich.objects import (
+    Blob,
+    Tree,
+    )
+from dulwich.tests import (
+    TestCase,
+    )
+from dulwich.tests.utils import (
+    build_commit_graph,
+    )
+
+
+class AnnotateTests(TestCase):
+
+    def test_onerev(self):

+ 1 - 1
dulwich/walk.py

@@ -68,7 +68,7 @@ class WalkEntry(object):
         :return: For commits with up to one parent, a list of TreeChange
             objects; if the commit has no parents, these will be relative to
             the empty tree. For merge commits, a list of lists of TreeChange
-            objects; see dulwich.diff.tree_changes_for_merge.
+            objects; see dulwich.diff_tree.tree_changes_for_merge.
         """
         cached = self._changes.get(path_prefix)
         if cached is None: