source: tracpygit2plugin/trunk/tracext/pygit2/pygit2_fs.py

Last change on this file was 15911, checked in by Jun Omae, 8 years ago

TracPygit2Plugin: fix raising AttributeError with pygit2 v0.22.1+

File size: 49.2 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2012-2014 Jun Omae <jun66j5@gmail.com>
4# All rights reserved.
5#
6# This software is licensed as described in the file COPYING, which
7# you should have received as part of this distribution.
8
9import os
10import posixpath
11from cStringIO import StringIO
12from datetime import datetime
13from threading import RLock
14
15try:
16    import pygit2
17except ImportError:
18    pygit2 = None
19    pygit2_version = None
20else:
21    from pygit2 import (
22        GIT_OBJ_BLOB, GIT_OBJ_COMMIT, GIT_OBJ_TAG, GIT_OBJ_TREE,
23        GIT_SORT_REVERSE, GIT_SORT_TIME, GIT_SORT_TOPOLOGICAL,
24    )
25    pygit2_version = pygit2.__version__
26    if hasattr(pygit2, 'LIBGIT2_VERSION'):
27        pygit2_version = '%s (compiled with libgit2 %s)' % \
28                         (pygit2_version, pygit2.LIBGIT2_VERSION)
29
30from genshi.builder import tag
31
32from trac.core import Component, implements, TracError
33from trac.env import Environment, ISystemInfoProvider
34from trac.util import shorten_line
35from trac.util.compat import any
36from trac.util.datefmt import (
37    FixedOffset, format_datetime, to_timestamp, to_utimestamp, utc,
38)
39from trac.util.text import exception_to_unicode, to_unicode
40from trac.versioncontrol.api import (
41    Changeset, Node, Repository, IRepositoryConnector, NoSuchChangeset,
42    NoSuchNode,
43)
44from trac.versioncontrol.cache import (
45    CACHE_METADATA_KEYS, CACHE_REPOSITORY_DIR, CACHE_YOUNGEST_REV,
46    CachedRepository, CachedChangeset,
47)
48from trac.versioncontrol.web_ui import IPropertyRenderer, RenderedProperty
49from trac.web.chrome import Chrome
50from trac.wiki import IWikiSyntaxProvider
51from trac.wiki.formatter import wiki_to_oneliner
52
53try:
54    from trac.util.datefmt import user_time
55except ImportError:
56    def user_time(req, func, *args, **kwargs):
57        if 'tzinfo' not in kwargs:
58            kwargs['tzinfo'] = getattr(req, 'tz', None)
59        if 'locale' not in kwargs:
60            kwargs['locale'] = getattr(req, 'locale', None)
61        return func(*args, **kwargs)
62
63try:
64    from tracopt.versioncontrol.git.git_fs import GitConnector \
65                                           as TracGitConnector
66except ImportError:
67    try:
68        from tracext.git.git_fs import GitConnector as TracGitConnector
69    except ImportError:
70        TracGitConnector = None
71
72from tracext.pygit2.translation import (
73    BoolOption, IntOption, Option, N_, _, gettext, tag_,
74)
75
76
77__all__ = ['GitCachedRepository', 'GitCachedChangeset', 'GitConnector',
78           'GitRepository', 'GitChangeset', 'GitNode']
79
80_filemode_submodule = 0160000
81_diff_find_rename_limit = 200
82
83if pygit2:
84    _status_map = {'A': Changeset.ADD, 'D': Changeset.DELETE,
85                   'M': Changeset.EDIT, 'R': Changeset.MOVE,
86                   'C': Changeset.COPY}
87    if hasattr(pygit2.TreeEntry, 'filemode'):
88        _get_filemode = lambda tree_entry: tree_entry.filemode
89    else:
90        _get_filemode = lambda tree_entry: tree_entry.attributes
91    _walk_flags = GIT_SORT_TIME
92    if not hasattr(pygit2.Patch, 'delta'):  # prior to v0.22.1
93        def _iter_changes_from_diff(diff):
94            for patch in diff:
95                yield patch.old_file_path, patch.new_file_path, patch.status
96    else:
97        def _iter_changes_from_diff(diff):
98            for patch in diff:
99                delta = patch.delta
100                yield delta.old_file.path, delta.new_file.path, delta.status
101else:
102    _status_map = {}
103    _get_filemode = None
104    _walk_flags = 0
105    _iter_changes_from_diff = None
106
107
108_inverted_kindmap = {Node.DIRECTORY: 'D', Node.FILE: 'F'}
109_inverted_actionmap = {Changeset.ADD: 'A', Changeset.COPY: 'C',
110                       Changeset.DELETE: 'D', Changeset.EDIT: 'E',
111                       Changeset.MOVE: 'M'}
112
113
114if hasattr(Environment, 'db_exc'):
115    def _db_exc(env):
116        return env.db_exc
117else:
118    def _db_exc(env):
119        uri = env.config.get('trac', 'database', '')
120        if uri.startswith('sqlite:'):
121            from trac.db.sqlite_backend import sqlite
122            return sqlite
123        if uri.startswith('postgres:'):
124            from trac.db.postgres_backend import psycopg
125            return psycopg
126        if uri.startswith('mysql:'):
127            from trac.db.mysql_backend import MySQLdb
128            return MySQLdb
129        raise ValueError('Unsupported database "%s"' % uri.split(':')[0])
130
131
132class GitCachedRepository(CachedRepository):
133    """Git-specific cached repository."""
134
135    has_linear_changesets = False
136
137    def short_rev(self, rev):
138        return self.repos.short_rev(rev)
139
140    def display_rev(self, rev):
141        return self.short_rev(rev)
142
143    def normalize_rev(self, rev):
144        return self.repos.normalize_rev(rev)
145
146    def get_changeset(self, rev):
147        return GitCachedChangeset(self, self.normalize_rev(rev), self.env)
148
149    def get_node(self, path, rev=None):
150        return self.repos.get_node(path, rev=rev)
151
152    def get_youngest_rev(self):
153        # return None if repository is empty
154        return CachedRepository.get_youngest_rev(self) or None
155
156    def get_quickjump_entries(self, rev):
157        try:
158            rev = self.normalize_rev(rev)
159        except NoSuchChangeset:
160            return ()
161        else:
162            return self.repos.get_quickjump_entries(rev)
163
164    def has_node(self, path, rev=None):
165        try:
166            self.get_node(path, rev=rev)
167        except (NoSuchChangeset, NoSuchNode):
168            return False
169        else:
170            return True
171
172    def parent_revs(self, rev):
173        return self.repos.parent_revs(rev)
174
175    def child_revs(self, rev):
176        return self.repos.child_revs(rev)
177
178    def sync(self, feedback=None, clean=False):
179        if clean:
180            self.remove_cache()
181
182        metadata = self.metadata
183        self.save_metadata(metadata)
184        meta_youngest = metadata.get(CACHE_YOUNGEST_REV, '')
185        repos = self.repos
186        git_repos = repos.git_repos
187        db = self.env.get_read_db()
188
189        IntegrityError = _db_exc(self.env).IntegrityError
190        cursor = db.cursor()
191
192        def is_synced(rev):
193            cursor.execute("SELECT COUNT(repos) FROM revision "
194                           "WHERE repos=%s AND rev=%s",
195                           (self.id, rev))
196            row = cursor.fetchone()
197            return row[0] > 0
198
199        def traverse(commit, seen):
200            commits = []
201            merges = []
202            while True:
203                rev = commit.hex
204                if rev in seen:
205                    break
206                seen.add(rev)
207                if is_synced(rev):
208                    break
209                commits.append(commit)
210                parents = commit.parents
211                if not parents:  # root commit?
212                    break
213                commit = parents[0]
214                if len(parents) > 1:
215                    merges.append((len(commits), parents[1:]))
216            for idx, parents in reversed(merges):
217                for parent in parents:
218                    commits[idx:idx] = traverse(parent, seen)
219            return commits
220
221        while True:
222            repos_youngest = repos.youngest_rev or ''
223            updated = [False]
224            seen = set()
225
226            for name in git_repos.listall_references():
227                ref = git_repos.lookup_reference(name)
228                git_object = ref.get_object()
229                type_ = git_object.type
230                if type_ == GIT_OBJ_TAG:
231                    git_object = git_object.get_object()
232                    type_ = git_object.type
233                if type_ != GIT_OBJ_COMMIT:
234                    continue
235
236                commits = traverse(git_object, seen)  # topology ordered
237                while commits:
238                    # sync revision from older revision to newer revision
239                    commit = commits.pop()
240                    rev = commit.hex
241                    self.log.info("Trying to sync revision [%s]", rev)
242                    cset = GitChangeset(repos, commit)
243                    @self.env.with_transaction()
244                    def do_insert(db):
245                        try:
246                            self._insert_cset(db, rev, cset)
247                            updated[0] = True
248                        except IntegrityError, e:
249                            self.log.info('Revision %s already cached: %r',
250                                          rev, e)
251                            db.rollback()
252                    if feedback:
253                        feedback(rev)
254
255            if updated[0]:
256                continue  # sync again
257
258            if meta_youngest != repos_youngest:
259                @self.env.with_transaction()
260                def update_metadata(db):
261                    cursor = db.cursor()
262                    cursor.execute("""
263                        UPDATE repository SET value=%s WHERE id=%s AND name=%s
264                        """, (repos_youngest, self.id, CACHE_YOUNGEST_REV))
265                    del self.metadata
266            return
267
268    if not hasattr(CachedRepository, 'remove_cache'):
269        def remove_cache(self):
270            self.log.info("Cleaning cache")
271            @self.env.with_transaction()
272            def fn(db):
273                cursor = db.cursor()
274                cursor.execute("DELETE FROM revision WHERE repos=%s",
275                               (self.id,))
276                cursor.execute("DELETE FROM node_change WHERE repos=%s",
277                               (self.id,))
278                cursor.executemany(
279                    "DELETE FROM repository WHERE id=%s AND name=%s",
280                    [(self.id, k) for k in CACHE_METADATA_KEYS])
281                cursor.executemany("""
282                    INSERT INTO repository (id, name, value)
283                    VALUES (%s, %s, %s)
284                    """, [(self.id, k, '') for k in CACHE_METADATA_KEYS])
285                del self.metadata
286
287    if not hasattr(CachedRepository, 'save_metadata'):
288        def save_metadata(self, metadata):
289            @self.env.with_transaction()
290            def fn(db):
291                invalidate = False
292                cursor = db.cursor()
293
294                # -- check that we're populating the cache for the correct
295                #    repository
296                repository_dir = metadata.get(CACHE_REPOSITORY_DIR)
297                if repository_dir:
298                    # directory part of the repo name can vary on case
299                    # insensitive fs
300                    if os.path.normcase(repository_dir) \
301                            != os.path.normcase(self.name):
302                        self.log.info("'repository_dir' has changed from %r "
303                                      "to %r", repository_dir, self.name)
304                        raise TracError(_(
305                            "The repository directory has changed, you should "
306                            "resynchronize the repository with: trac-admin "
307                            "$ENV repository resync '%(reponame)s'",
308                            reponame=self.reponame or '(default)'))
309                elif repository_dir is None: #
310                    self.log.info('Storing initial "repository_dir": %s',
311                                  self.name)
312                    cursor.execute("INSERT INTO repository (id, name, value) "
313                                   "VALUES (%s, %s, %s)",
314                                   (self.id, CACHE_REPOSITORY_DIR, self.name))
315                    invalidate = True
316                else: # 'repository_dir' cleared by a resync
317                    self.log.info('Resetting "repository_dir": %s', self.name)
318                    cursor.execute("UPDATE repository SET value=%s "
319                                   "WHERE id=%s AND name=%s",
320                                   (self.name, self.id, CACHE_REPOSITORY_DIR))
321                    invalidate = True
322
323                # -- insert a 'youngeset_rev' for the repository if necessary
324                if CACHE_YOUNGEST_REV not in metadata:
325                    cursor.execute("INSERT INTO repository (id, name, value) "
326                                   "VALUES (%s, %s, %s)",
327                                   (self.id, CACHE_YOUNGEST_REV, ''))
328                    invalidate = True
329
330                if invalidate:
331                    del self.metadata
332
333    if hasattr(CachedRepository, 'insert_changeset'):
334        def _insert_cset(self, db, rev, cset):
335            return self.insert_changeset(rev, cset)
336    else:
337        def _insert_cset(self, db, rev, cset):
338            cursor = db.cursor()
339            srev = self.db_rev(rev)
340            cursor.execute("""
341                INSERT INTO revision (repos,rev,time,author,message)
342                VALUES (%s,%s,%s,%s,%s)
343                """, (self.id, srev, to_utimestamp(cset.date),
344                      cset.author, cset.message))
345            for path, kind, action, bpath, brev in cset.get_changes():
346                self.log.debug("Caching node change in [%s]: %r", rev,
347                               (path, kind, action, bpath, brev))
348                kind = _inverted_kindmap[kind]
349                action = _inverted_actionmap[action]
350                cursor.execute("""
351                    INSERT INTO node_change
352                        (repos,rev,path,node_type,change_type,base_path,
353                         base_rev)
354                    VALUES (%s,%s,%s,%s,%s,%s,%s)
355                    """, (self.id, srev, path, kind, action, bpath, brev))
356
357
358class GitCachedChangeset(CachedChangeset):
359    """Git-specific cached changeset."""
360
361    def get_branches(self):
362        return self.repos.repos._get_branches_cset(self.rev)
363
364    def get_tags(self):
365        return self.repos.repos._get_tags_cset(self.rev)
366
367
368def intersperse(sep, iterable):
369    """The 'intersperse' generator takes an element and an iterable and
370    intersperses that element between the elements of the iterable.
371
372    inspired by Haskell's ``Data.List.intersperse``
373    """
374
375    for i, item in enumerate(iterable):
376        if i:
377            yield sep
378        yield item
379
380
381def _git_timestamp(ts, offset):
382    if offset == 0:
383        tz = utc
384    else:
385        hours, rem = divmod(abs(offset), 60)
386        tzname = 'UTC%+03d:%02d' % ((hours, -hours)[offset < 0], rem)
387        tz = FixedOffset(offset, tzname)
388    return datetime.fromtimestamp(ts, tz)
389
390
391def _format_signature(signature):
392    name = signature.name.strip()
393    email = signature.email.strip()
394    return ('%s <%s>' % (name, email)).strip()
395
396
397def _walk_tree(repos, tree, path=None):
398    for entry in tree:
399        if _get_filemode(entry) == _filemode_submodule:
400            continue
401        git_object = repos.get(entry.oid)
402        if git_object is None:
403            continue
404        if path is not None:
405            name = posixpath.join(path, entry.name)
406        else:
407            name = entry.name
408        if git_object.type == GIT_OBJ_TREE:
409            for val in _walk_tree(repos, git_object, name):
410                yield val
411        else:
412            yield git_object, name
413
414
415class _CachedWalker(object):
416
417    __slots__ = ('rev', 'walker', 'revs', 'commits', '_lock')
418
419    def __init__(self, git_repos, rev, flags=_walk_flags):
420        self.rev = rev
421        self.walker = git_repos.walk(rev, flags)
422        self.revs = set()
423        self.commits = []
424        self._lock = RLock()
425
426    def __contains__(self, rev):
427        self._lock.acquire()
428        try:
429            if rev in self.revs:
430                return True
431            add_rev = self.revs.add
432            add_commit = self.commits.append
433            for commit in self.walker:
434                old_rev = commit.hex
435                add_commit(commit)
436                add_rev(old_rev)
437                if old_rev == rev:
438                    return True
439            return False
440        finally:
441            self._lock.release()
442
443    def reverse(self, start_rev):
444        self._lock.acquire()
445        try:
446            if start_rev in self:
447                commits = self.commits
448                idx = len(commits) - 1
449                for idx in xrange(len(commits) - 1, -1, -1):
450                    commit = commits[idx]
451                    if commit.hex == start_rev:
452                        return reversed(commits[0:idx + 1])
453            return ()
454        finally:
455            self._lock.release()
456
457
458class GitConnector(Component):
459
460    implements(ISystemInfoProvider, IRepositoryConnector, IWikiSyntaxProvider)
461
462    # ISystemInfoProvider methods
463
464    def get_system_info(self):
465        if pygit2:
466            yield 'pygit2', pygit2_version
467
468    # IWikiSyntaxProvider methods
469
470    def _format_sha_link(self, formatter, rev, label):
471        # FIXME: this function needs serious rethinking...
472
473        reponame = ''
474
475        context = formatter.context
476        while context:
477            if context.resource.realm in ('source', 'changeset'):
478                reponame = context.resource.parent.id
479                break
480            context = context.parent
481
482        try:
483            repos = self.env.get_repository(reponame)
484
485            if not repos:
486                raise Exception("Repository '%s' not found" % reponame)
487
488            rev = repos.normalize_rev(rev)  # in case it was abbreviated
489            changeset = repos.get_changeset(rev)
490            return tag.a(label, class_='changeset',
491                         title=shorten_line(changeset.message),
492                         href=formatter.href.changeset(rev, repos.reponame))
493        except Exception, e:
494            return tag.a(label, class_='missing changeset',
495                         title=to_unicode(e), rel='nofollow')
496
497    def get_wiki_syntax(self):
498        yield (r'(?:\b|!)r?[0-9a-fA-F]{%d,40}\b' % self.wiki_shortrev_len,
499               lambda fmt, rev, match:
500                    self._format_sha_link(fmt, rev.startswith('r')
501                                          and rev[1:] or rev, rev))
502
503    def get_link_resolvers(self):
504        return ()
505
506    if TracGitConnector:
507        @property
508        def cached_repository(self):
509            return TracGitConnector(self.env).cached_repository
510
511        @property
512        def shortrev_len(self):
513            return TracGitConnector(self.env).shortrev_len
514
515        @property
516        def wiki_shortrev_len(self):
517            return TracGitConnector(self.env).wiki_shortrev_len
518
519        @property
520        def trac_user_rlookup(self):
521            return TracGitConnector(self.env).trac_user_rlookup
522
523        @property
524        def use_committer_id(self):
525            return TracGitConnector(self.env).use_committer_id
526
527        @property
528        def use_committer_time(self):
529            return TracGitConnector(self.env).use_committer_time
530
531        @property
532        def git_fs_encoding(self):
533            return TracGitConnector(self.env).git_fs_encoding
534    else:
535        cached_repository = BoolOption('git', 'cached_repository', 'false',
536            N_("Wrap `GitRepository` in `CachedRepository`."))
537
538        shortrev_len = IntOption('git', 'shortrev_len', 7,
539            N_("The length at which a sha1 should be abbreviated to (must be "
540               ">= 4 and <= 40)."))
541
542        wiki_shortrev_len = IntOption('git', 'wikishortrev_len', 40,
543            N_("The minimum length of an hex-string for which auto-detection "
544               "as sha1 is performed (must be >= 4 and <= 40)."))
545
546        trac_user_rlookup = BoolOption('git', 'trac_user_rlookup', 'false',
547            N_("Enable reverse mapping of git email addresses to trac user "
548               "ids (costly if you have many users)."))
549
550        use_committer_id = BoolOption('git', 'use_committer_id', 'true',
551            N_("Use git-committer id instead of git-author id for the "
552               "changeset ''Author'' field."))
553
554        use_committer_time = BoolOption('git', 'use_committer_time', 'true',
555            N_("Use git-committer timestamp instead of git-author timestamp "
556               "for the changeset ''Timestamp'' field."))
557
558        git_fs_encoding = Option('git', 'git_fs_encoding', 'utf-8',
559            N_("Define charset encoding of paths within git repositories."))
560
561    # IRepositoryConnector methods
562
563    def get_supported_types(self):
564        if pygit2:
565            yield 'git', 4  # lower priority than tracopt.v.git
566            yield 'pygit2', 8
567            yield 'direct-pygit2', 8
568            yield 'cached-pygit2', 8
569
570    def get_repository(self, type, dir, params):
571        """GitRepository factory method"""
572        assert type in ('git', 'pygit2', 'direct-pygit2', 'cached-pygit2')
573
574        if not (4 <= self.shortrev_len <= 40):
575            raise TracError(_("[git] shortrev_len setting must be within "
576                              "[4..40]"))
577
578        if not (4 <= self.wiki_shortrev_len <= 40):
579            raise TracError(_("[git] wikishortrev_len must be within [4..40]"))
580
581        if self.trac_user_rlookup:
582            format_signature = self._format_signature_by_email
583        else:
584            format_signature = None
585
586        repos = GitRepository(dir, params, self.log,
587                              git_fs_encoding=self.git_fs_encoding,
588                              shortrev_len=self.shortrev_len,
589                              format_signature=format_signature,
590                              use_committer_id=self.use_committer_id,
591                              use_committer_time=self.use_committer_time)
592
593        if type == 'cached-pygit2':
594            use_cached = True
595        elif type == 'direct-pygit2':
596            use_cached = False
597        else:
598            use_cached = self.cached_repository
599        if use_cached:
600            repos = GitCachedRepository(self.env, repos, self.log)
601            self.log.debug("enabled CachedRepository for '%s'", dir)
602        else:
603            self.log.debug("disabled CachedRepository for '%s'", dir)
604
605        return repos
606
607    def _format_signature_by_email(self, signature):
608        """Reverse map 'real name <user@domain.tld>' addresses to trac
609        user ids.
610        """
611        email = (signature.email or '').strip()
612        if email:
613            email = email.lower()
614            for username, name, _email in self.env.get_known_users():
615                if _email and email == _email.lower():
616                    return username
617        return _format_signature(signature)
618
619
620class CsetPropertyRenderer(Component):
621
622    implements(IPropertyRenderer)
623
624    git_properties = (
625        N_("Parents:"), N_("Children:"), N_("Branches:"), N_("Tags:"),
626    )
627
628    # relied upon by GitChangeset
629    def match_property(self, name, mode):
630        if (mode == 'revprop' and
631            name.startswith('git-') and
632            name[4:] in ('Parents', 'Children', 'Branches', 'Tags',
633                         'committer', 'author')):
634            return 4
635        return 0
636
637    def render_property(self, name, mode, context, props):
638        if name.startswith('git-'):
639            label = name[4:] + ':'
640            if label in self.git_properties:
641                label = gettext(label)
642        else:
643            label = name
644        return RenderedProperty(
645                name=label, name_attributes=[('class', 'property')],
646                content=self._render_property(name, props[name], context))
647
648    def _render_property(self, name, value, context):
649        if name == 'git-Branches':
650            return self._render_branches(context, value)
651
652        if name == 'git-Tags':
653            return self._render_tags(context, value)
654
655        if name == 'git-Parents' and len(value) > 1:
656            return self._render_merge_commit(context, value)
657
658        if name in ('git-Parents', 'git-Children'):
659            return self._render_revs(context, value)
660
661        if name in ('git-committer', 'git-author'):
662            return self._render_signature(context, value)
663
664        raise TracError("Internal error")
665
666    def _render_branches(self, context, branches):
667        links = [self._changeset_link(context, rev, name)
668                 for name, rev in branches]
669        return tag(*intersperse(', ', links))
670
671    def _render_tags(self, context, names):
672        rev = context.resource.id
673        links = [self._changeset_link(context, rev, name) for name in names]
674        return tag(*intersperse(', ', links))
675
676    def _render_revs(self, context, revs):
677        links = [self._changeset_link(context, rev) for rev in revs]
678        return tag(*intersperse(', ', links))
679
680    def _render_merge_commit(self, context, revs):
681        # we got a merge...
682        curr_rev = context.resource.id
683        reponame = context.resource.parent.id
684        href = context.href
685
686        def parent_diff(rev):
687            link = self._changeset_link(context, rev)
688            diff = tag.a(_("diff"),
689                         href=href.changeset(curr_rev, reponame, old=rev),
690                         title=_("Diff against this parent (show the changes "
691                                 "merged from the other parents)"))
692            return tag_("%(rev)s (%(diff)s)", rev=link, diff=diff)
693
694        links = intersperse(', ', map(parent_diff, revs))
695        hint = wiki_to_oneliner(
696            _("'''Note''': this is a '''merge''' changeset, the changes "
697              "displayed below correspond to the merge itself. Use the "
698              "`(diff)` links above to see all the changes relative to each "
699              "parent."),
700            self.env)
701        return tag(list(links), tag.br(), tag.span(hint, class_='hint'))
702
703    def _render_signature(self, context, signature):
704        req = context.req
705        dt = _git_timestamp(signature.time, signature.offset)
706        chrome = Chrome(self.env)
707        return u'%s (%s)' % (chrome.format_author(req, signature.name),
708                             user_time(req, format_datetime, dt))
709
710    def _changeset_link(self, context, rev, label=None):
711        # `rev` is assumed to be a non-abbreviated 40-chars sha id
712        reponame = context.resource.parent.id
713        repos = self.env.get_repository(reponame)
714        try:
715            cset = repos.get_changeset(rev)
716        except (NoSuchChangeset, NoSuchNode), e:
717            return tag.a(rev, class_='missing changeset', title=to_unicode(e),
718                         rel='nofollow')
719        if label is None:
720            label = repos.display_rev(rev)
721        return tag.a(label, class_='changeset',
722                     title=shorten_line(cset.message),
723                     href=context.href.changeset(rev, repos.reponame))
724
725
726class GitRepository(Repository):
727    """Git repository"""
728
729    has_linear_changesets = False
730
731    def __init__(self, path, params, log, git_fs_encoding='utf-8',
732                 shortrev_len=7, format_signature=None, use_committer_id=False,
733                 use_committer_time=False):
734
735        try:
736            self.git_repos = pygit2.Repository(path)
737        except Exception, e:
738            log.warn('Not git repository: %r (%s)', path,
739                     exception_to_unicode(e))
740            raise TracError(_("%(path)s does not appear to be a Git "
741                              "repository.", path=path))
742
743        self.path = path
744        self.params = params
745        self.git_fs_encoding = git_fs_encoding
746        self.shortrev_len = max(4, min(shortrev_len, 40))
747        self.format_signature = format_signature or _format_signature
748        self.use_committer_id = use_committer_id
749        self.use_committer_time = use_committer_time
750        self._ref_walkers = {}
751        Repository.__init__(self, 'git:' + path, self.params, log)
752
753    def _from_fspath(self, name):
754        return name.decode(self.git_fs_encoding)
755
756    def _to_fspath(self, name):
757        return name.encode(self.git_fs_encoding)
758
759    def _stringify_rev(self, rev):
760        if rev is not None and not isinstance(rev, unicode):
761            rev = to_unicode(rev)
762        return rev
763
764    def _get_commit_username(self, commit):
765        if self.use_committer_id:
766            signature = commit.committer or commit.author
767        else:
768            signature = commit.author or commit.committer
769        return self.format_signature(signature)
770
771    def _get_commit_time(self, commit):
772        if self.use_committer_time:
773            signature = commit.committer or commit.author
774        else:
775            signature = commit.author or commit.committer
776        return _git_timestamp(signature.time, signature.offset)
777
778    def _get_tree_entry(self, tree, path):
779        if not path:
780            return None
781        if isinstance(path, unicode):
782            path = self._to_fspath(path)
783        entry = tree
784        for name in path.split('/'):
785            if tree is None or tree.type != GIT_OBJ_TREE or name not in tree:
786                return None
787            entry = tree[name]
788            tree = self.git_repos.get(entry.oid)
789        return entry
790
791    def _get_tree(self, tree, path):
792        if not path:
793            return tree
794        entry = self._get_tree_entry(tree, path)
795        if entry:
796            return self.git_repos.get(entry.oid)
797        return None
798
799    def _get_commit(self, oid):
800        git_repos = self.git_repos
801        try:
802            git_object = git_repos[oid]
803        except (KeyError, ValueError):
804            return None
805        if git_object.type == GIT_OBJ_TAG:
806            git_object = git_object.get_object()
807        if git_object.type == GIT_OBJ_COMMIT:
808            return git_object
809        return None
810
811    def _iter_ref_walkers(self, rev):
812        git_repos = self.git_repos
813        target = git_repos[rev]
814
815        walkers = self._ref_walkers
816        for name in git_repos.listall_references():
817            if not name.startswith('refs/heads/'):
818                continue
819            ref = git_repos.lookup_reference(name)
820            commit = self._get_commit(ref.target)
821            if not commit:
822                continue
823            if commit.commit_time < target.commit_time:
824                continue
825            walker = walkers.get(name)
826            if walker and walker.rev != commit.hex:
827                walker = None
828            if not walker:
829                walkers[name] = walker = _CachedWalker(git_repos, commit.hex)
830            yield self._from_fspath(name), ref, walker
831
832    def _get_changes(self, parent_tree, commit_tree):
833        diff = parent_tree.diff_to_tree(commit_tree)
834        # don't detect rename if the diff has too many files
835        if len(diff) <= _diff_find_rename_limit or \
836                sum(patch.status == 'A'
837                    for patch in diff) <= _diff_find_rename_limit:
838            diff.find_similar()
839        _from_fspath = self._from_fspath
840        generator = ((_from_fspath(old_path), _from_fspath(new_path), status)
841                     for old_path, new_path, status
842                     in _iter_changes_from_diff(diff) if status in _status_map)
843        return sorted(generator, key=lambda item: item[1])
844
845    def _get_branches(self, rev):
846        return sorted((name[11:], ref.target.hex)
847                      for name, ref, walker in self._iter_ref_walkers(rev)
848                      if rev in walker)
849
850    def _get_branches_cset(self, rev):
851        return [(name, r == rev) for name, r in self._get_branches(rev)]
852
853    def _get_tags_cset(self, rev):
854        git_repos = self.git_repos
855        _from_fspath = self._from_fspath
856
857        def iter_tags():
858            for name in git_repos.listall_references():
859                if not name.startswith('refs/tags/'):
860                    continue
861                ref = git_repos.lookup_reference(name)
862                git_object = ref.get_object()
863                if git_object.type == GIT_OBJ_TAG:
864                    git_object = git_object.get_object()
865                if rev == git_object.hex:
866                    yield _from_fspath(name[10:])
867
868        return sorted(iter_tags())
869
870    def _resolve_rev(self, rev, raises=True):
871        git_repos = self.git_repos
872        rev = self._stringify_rev(rev)
873        if not rev:
874            try:
875                return git_repos.get(git_repos.head.target)
876            except pygit2.GitError:
877                if raises:
878                    raise NoSuchChangeset(rev)
879                return None
880
881        commit = self._get_commit(rev)
882        if commit:
883            return commit
884
885        for name in git_repos.listall_references():
886            ref = git_repos.lookup_reference(name)
887            name = self._from_fspath(name)
888            if name.startswith('refs/heads/'):
889                match = name[11:] == rev
890            elif name.startswith('refs/tags/'):
891                match = name[10:] == rev
892            else:
893                continue
894            if not match:
895                continue
896            commit = self._get_commit(ref.target)
897            if commit:
898                return commit
899
900        if raises:
901            raise NoSuchChangeset(rev)
902
903    def close(self):
904        self._ref_walkers.clear()
905        self.git_repos = None
906
907    def get_youngest_rev(self):
908        try:
909            return self.git_repos.head.target.hex
910        except pygit2.GitError:
911            return None
912
913    def get_oldest_rev(self):
914        try:
915            self.git_repos.head
916        except pygit2.GitError:
917            return None
918        for commit in self.git_repos.walk(self.git_repos.head.target,
919                                          _walk_flags | GIT_SORT_REVERSE):
920            return commit.hex
921
922    def normalize_path(self, path):
923        if isinstance(path, str):
924            path = self._from_fspath(path)
925        if path:
926            return path.strip('/')
927        else:
928            return ''
929
930    def normalize_rev(self, rev):
931        return to_unicode(self._resolve_rev(rev).oid.hex)
932
933    def short_rev(self, rev):
934        rev = self.normalize_rev(rev)
935        git_repos = self.git_repos
936        for size in xrange(self.shortrev_len, 40):
937            short_rev = rev[:size]
938            try:
939                git_object = git_repos[short_rev]
940                if git_object.type == GIT_OBJ_COMMIT:
941                    return short_rev
942            except (KeyError, ValueError):
943                pass
944        return rev
945
946    def display_rev(self, rev):
947        return self.short_rev(rev)
948
949    def get_node(self, path, rev=None):
950        rev = self._stringify_rev(rev)
951        commit = self._resolve_rev(rev, raises=False)
952        if commit is None and rev:
953            raise NoSuchChangeset(rev)
954        return GitNode(self, self.normalize_path(path), commit)
955
956    def get_quickjump_entries(self, rev):
957        git_repos = self.git_repos
958        refs = sorted(
959            (self._from_fspath(name), git_repos.lookup_reference(name))
960            for name in git_repos.listall_references()
961            if name.startswith('refs/heads/') or name.startswith('refs/tags/'))
962
963        for name, ref in refs:
964            if name.startswith('refs/heads/'):
965                commit = self._get_commit(ref.target)
966                if commit:
967                    yield 'branches', name[11:], '/', commit.hex
968
969        for name, ref in refs:
970            if name.startswith('refs/tags/'):
971                commit = self._get_commit(ref.target)
972                yield 'tags', name[10:], '/', commit.hex
973
974    def get_path_url(self, path, rev):
975        return self.params.get('url')
976
977    def get_changesets(self, start, stop):
978        seen_oids = set()
979
980        def iter_commits():
981            ts_start = to_timestamp(start)
982            ts_stop = to_timestamp(stop)
983            git_repos = self.git_repos
984            for name in git_repos.listall_references():
985                if not name.startswith('refs/heads/'):
986                    continue
987                ref = git_repos.lookup_reference(name)
988                for commit in git_repos.walk(ref.target, _walk_flags):
989                    ts = commit.commit_time
990                    if ts < ts_start:
991                        break
992                    if ts_start <= ts <= ts_stop:
993                        oid = commit.oid
994                        if oid not in seen_oids:
995                            seen_oids.add(oid)
996                            yield ts, commit
997
998        for ts, commit in sorted(iter_commits(), key=lambda v: v[0],
999                                 reverse=True):
1000            yield GitChangeset(self, commit)
1001
1002    def get_changeset(self, rev):
1003        return GitChangeset(self, self._resolve_rev(rev))
1004
1005    def get_changeset_uid(self, rev):
1006        return rev
1007
1008    def get_changes(self, old_path, old_rev, new_path, new_rev,
1009                    ignore_ancestry=0):
1010        # TODO: handle ignore_ancestry
1011
1012        def iter_changes(old_commit, old_path, new_commit, new_path):
1013            old_tree = self._get_tree(old_commit.tree, old_path)
1014            old_rev = old_commit.hex
1015            new_tree = self._get_tree(new_commit.tree, new_path)
1016            new_rev = new_commit.hex
1017
1018            for old_file, new_file, status in \
1019                    self._get_changes(old_tree, new_tree):
1020                action = _status_map.get(status)
1021                if not action:
1022                    continue
1023                old_node = new_node = None
1024                if status != 'A':
1025                    old_node = self.get_node(
1026                                posixpath.join(old_path, old_file), old_rev)
1027                if status != 'D':
1028                    new_node = self.get_node(
1029                                posixpath.join(new_path, new_file), new_rev)
1030                yield old_node, new_node, Node.FILE, action
1031
1032        old_commit = self._resolve_rev(old_rev)
1033        new_commit = self._resolve_rev(new_rev)
1034        return iter_changes(old_commit, self.normalize_path(old_path),
1035                            new_commit, self.normalize_path(new_path))
1036
1037    def previous_rev(self, rev, path=''):
1038        commit = self._resolve_rev(rev)
1039        if not path or path == '/':
1040            for parent in commit.parents:
1041                return parent.hex
1042        else:
1043            node = GitNode(self, self.normalize_path(path), commit)
1044            for commit, action in node._walk_commits():
1045                for parent in commit.parents:
1046                    return parent.hex
1047
1048    def next_rev(self, rev, path=''):
1049        rev = self.normalize_rev(rev)
1050        path = self._to_fspath(self.normalize_path(path))
1051
1052        for name, ref, walker in self._iter_ref_walkers(rev):
1053            if rev not in walker:
1054                continue
1055            for commit in walker.reverse(rev):
1056                if not any(p.hex == rev for p in commit.parents):
1057                    continue
1058                tree = commit.tree
1059                entry = self._get_tree(tree, path)
1060                if entry is None:
1061                    return None
1062                for parent in commit.parents:
1063                    parent_tree = parent.tree
1064                    if tree.oid == parent_tree.oid:
1065                        continue
1066                    parent_entry = self._get_tree(parent_tree, path)
1067                    if entry is None or parent_entry is None or \
1068                            entry.oid != parent_entry.oid:
1069                        return commit.hex
1070                rev = commit.hex
1071
1072    def parent_revs(self, rev):
1073        commit = self._resolve_rev(rev)
1074        return [c.hex for c in commit.parents]
1075
1076    def child_revs(self, rev):
1077        def iter_children(rev):
1078            seen = set()
1079            for name, ref, walker in self._iter_ref_walkers(rev):
1080                if rev not in walker:
1081                    continue
1082                for commit in walker.reverse(rev):
1083                    if commit.oid in seen:
1084                        break
1085                    seen.add(commit.oid)
1086                    if any(p.hex == rev for p in commit.parents):
1087                        yield commit
1088        return [c.hex for c in iter_children(self.normalize_rev(rev))]
1089
1090    def rev_older_than(self, rev1, rev2):
1091        oid1 = self._resolve_rev(rev1).oid
1092        oid2 = self._resolve_rev(rev2).oid
1093        if oid1 == oid2:
1094            return False
1095        return any(oid1 == commit.oid
1096                   for commit in self.git_repos.walk(oid2, _walk_flags))
1097
1098    def get_path_history(self, path, rev=None, limit=None):
1099        raise TracError(_("GitRepository does not support path_history"))
1100
1101
1102class GitNode(Node):
1103
1104    def __init__(self, repos, path, rev, created_commit=None):
1105        self.log = repos.log
1106
1107        if type(rev) is pygit2.Commit:
1108            commit = rev
1109            rev = commit.hex
1110        else:
1111            if rev is not None and not isinstance(rev, unicode):
1112                rev = to_unicode(rev)
1113            commit = repos._resolve_rev(rev, raises=False)
1114            if commit is None and rev:
1115                raise NoSuchChangeset(rev)
1116
1117        tree_entry = None
1118        filemode = None
1119        tree = None
1120        blob = None
1121        if commit:
1122            normrev = commit.hex
1123            git_object = commit.tree
1124            if path:
1125                tree_entry = repos._get_tree_entry(git_object, path)
1126                if tree_entry is None:
1127                    raise NoSuchNode(path, rev)
1128                filemode = _get_filemode(tree_entry)
1129                if filemode == _filemode_submodule:
1130                    git_object = None
1131                else:
1132                    git_object = repos.git_repos.get(tree_entry.oid)
1133            if git_object is None:
1134                if filemode == _filemode_submodule:
1135                    kind = Node.DIRECTORY
1136                else:
1137                    kind = None
1138            elif git_object.type == GIT_OBJ_TREE:
1139                kind = Node.DIRECTORY
1140                tree = git_object
1141            elif git_object.type == GIT_OBJ_BLOB:
1142                kind = Node.FILE
1143                blob = git_object
1144            if kind is None:
1145                raise NoSuchNode(path, rev)
1146        else:
1147            if path:
1148                raise NoSuchNode(path, rev)
1149            normrev = None
1150            kind = Node.DIRECTORY
1151
1152        self.commit = commit
1153        self.tree_entry = tree_entry
1154        self.tree = tree
1155        self.blob = blob
1156        self.filemode = filemode
1157        self.created_path = path  # XXX how to use?
1158        self._created_commit = created_commit
1159        Node.__init__(self, repos, path, normrev, kind)
1160
1161    def _get_created_commit(self):
1162        commit = self._created_commit
1163        if commit is None and self.commit and self.rev:
1164            _get_tree_entry = self.repos._get_tree_entry
1165            path = self.repos._to_fspath(self.path)
1166            commit = self.commit
1167            entry = _get_tree_entry(commit.tree, path)
1168            parents = commit.parents
1169            if parents:
1170                parent_entry = _get_tree_entry(parents[0].tree, path)
1171                if parent_entry is not None and parent_entry.oid == entry.oid:
1172                    commit = None
1173                    for commit, action in self._walk_commits():
1174                        break
1175            self._created_commit = commit or self.commit
1176        return commit
1177
1178    @property
1179    def created_rev(self):
1180        commit = self._get_created_commit()
1181        if commit is None:
1182            return None
1183        return commit.hex
1184
1185    def _walk_commits(self):
1186        skip_merges = self.isfile
1187        _get_tree = self.repos._get_tree
1188        path = self.repos._to_fspath(self.path)
1189        parent = parent_tree = None
1190        for commit in self.repos.git_repos.walk(self.rev, _walk_flags):
1191            if parent is not None and parent.oid == commit.oid:
1192                tree = parent_tree
1193            else:
1194                tree = _get_tree(commit.tree, path)
1195            parents = commit.parents
1196            n_parents = len(parents)
1197            if skip_merges and n_parents > 1:
1198                continue
1199            if n_parents == 0:
1200                if tree is not None:
1201                    yield commit, Changeset.ADD
1202                return
1203            parent = parents[0]
1204            parent_tree = _get_tree(parent.tree, path)
1205            if tree is None:
1206                if parent_tree is None:
1207                    continue
1208                action = Changeset.DELETE
1209            elif parent_tree is None:
1210                action = Changeset.ADD
1211            elif parent_tree.oid != tree.oid:
1212                action = Changeset.EDIT
1213            else:
1214                continue
1215            yield commit, action
1216
1217    def get_content(self):
1218        if not self.isfile:
1219            return None
1220        return StringIO(self.blob.data)
1221
1222    def get_properties(self):
1223        props = {}
1224        if self.filemode is not None:
1225            props['mode'] = '%06o' % self.filemode
1226        return props
1227
1228    def get_annotations(self):
1229        if not self.isfile:
1230            return
1231        annotations = []
1232        for hunk in self.repos.git_repos.blame(
1233                self.repos._to_fspath(self.path),
1234                newest_commit=self.commit.oid):
1235            commit_id = str(hunk.final_commit_id)
1236            annotations.extend([commit_id] * hunk.lines_in_hunk)
1237        return annotations
1238
1239    def get_entries(self):
1240        if self.commit is None or self.tree is None or not self.isdir:
1241            return
1242
1243        repos = self.repos
1244        git_repos = repos.git_repos
1245        _get_tree = repos._get_tree
1246        _from_fspath = repos._from_fspath
1247        path = repos._to_fspath(self.path)
1248        names = sorted(entry.name for entry in self.tree)
1249
1250        def get_entries(commit):
1251            tree = _get_tree(commit.tree, path)
1252            if tree is None:
1253                tree = ()
1254            return dict((entry.name, entry) for entry in tree)
1255
1256        def is_blob(entry):
1257            if entry:
1258                return git_repos[entry.oid].type == GIT_OBJ_BLOB
1259            else:
1260                return True
1261
1262        def get_commits():
1263            commits = {}
1264            parent = parent_entries = None
1265            for commit in git_repos.walk(self.rev, _walk_flags):
1266                parents = commit.parents
1267                n_parents = len(parents)
1268                if n_parents == 0:
1269                    break
1270                parent = parents[0]
1271                if not parent and parent.oid == commit.oid:
1272                    curr_entries = parent_entries
1273                else:
1274                    curr_entries = get_entries(commit)
1275                parent_entries = get_entries(parent)
1276                for name in names:
1277                    if name in commits:
1278                        continue
1279                    curr_entry = curr_entries.get(name)
1280                    parent_entry = parent_entries.get(name)
1281                    if not curr_entry and not parent_entry:
1282                        continue
1283                    object_changed = not curr_entry or not parent_entry or \
1284                                     curr_entry.oid != parent_entry.oid
1285                    if n_parents > 1 and object_changed and \
1286                            is_blob(curr_entry) and is_blob(parent_entry):
1287                        continue  # skip merge-commit if blob
1288                    if object_changed:
1289                        commits[name] = commit
1290                if len(commits) == len(names):
1291                    break
1292            return commits
1293
1294        commits = get_commits()
1295        for name in names:
1296            yield GitNode(repos, posixpath.join(self.path, _from_fspath(name)),
1297                          self.commit, created_commit=commits.get(name))
1298
1299    def get_content_type(self):
1300        if self.isdir:
1301            return None
1302        return ''
1303
1304    def get_content_length(self):
1305        if not self.isfile:
1306            return None
1307        return self.blob.size
1308
1309    def get_history(self, limit=None):
1310        path = self.path
1311        count = 0
1312        for commit, action in self._walk_commits():
1313            yield path, commit.hex, action
1314            count += 1
1315            if limit == count:
1316                return
1317
1318    def get_last_modified(self):
1319        if not self.isfile:
1320            return None
1321        commit = self._get_created_commit()
1322        if commit is None:
1323            return None
1324        return self.repos._get_commit_time(commit)
1325
1326
1327class GitChangeset(Changeset):
1328    """A Git changeset in the Git repository.
1329
1330    Corresponds to a Git commit blob.
1331    """
1332
1333    def __init__(self, repos, rev):
1334        self.log = repos.log
1335
1336        if type(rev) is pygit2.Commit:
1337            commit = rev
1338            rev = commit.hex
1339        else:
1340            commit = repos._resolve_rev(rev)
1341            rev = commit.hex
1342
1343        author = repos._get_commit_username(commit)
1344        date = repos._get_commit_time(commit)
1345
1346        self.commit = commit
1347        Changeset.__init__(self, repos, rev, commit.message, author, date)
1348
1349    def get_branches(self):
1350        return self.repos._get_branches_cset(self.rev)
1351
1352    def get_tags(self):
1353        return self.repos._get_tags_cset(self.rev)
1354
1355    def get_properties(self):
1356        properties = {}
1357        commit = self.commit
1358
1359        if commit.parents:
1360            properties['git-Parents'] = [c.hex for c in commit.parents]
1361
1362        if (commit.author.name != commit.committer.name or
1363            commit.author.email != commit.committer.email):
1364            properties['git-committer'] = commit.committer
1365            properties['git-author'] = commit.author
1366
1367        branches = self.repos._get_branches(self.rev)
1368        if branches:
1369            properties['git-Branches'] = branches
1370        tags = self.get_tags()
1371        if tags:
1372            properties['git-Tags'] = tags
1373
1374        children = self.repos.child_revs(self.rev)
1375        if children:
1376            properties['git-Children'] = children
1377
1378        return properties
1379
1380    def get_changes(self):
1381        commit = self.commit
1382        if commit.parents:
1383            # diff for the first parent if even merge-commit
1384            parent = commit.parents[0]
1385            parent_rev = parent.hex
1386            files = self.repos._get_changes(parent.tree, commit.tree)
1387        else:
1388            _from_fspath = self.repos._from_fspath
1389            files = sorted(((None, _from_fspath(name), 'A')
1390                            for git_object, name in _walk_tree(
1391                                        self.repos.git_repos, commit.tree)),
1392                           key=lambda change: change[1])
1393            parent_rev = None
1394
1395        for old_path, new_path, status in files:
1396            action = _status_map.get(status)
1397            if not action:
1398                continue
1399            if status == 'A':
1400                yield new_path, Node.FILE, action, None, None
1401            else:
1402                yield new_path, Node.FILE, action, old_path, parent_rev
Note: See TracBrowser for help on using the repository browser.