| # -*- coding: utf-8 -*- |
| # |
| # Copyright (C) 2012 Edgewall Software |
| # Copyright (C) 2006-2011, Herbert Valerio Riedel <hvr@gnu.org> |
| # All rights reserved. |
| # |
| # This software is licensed as described in the file COPYING, which |
| # you should have received as part of this distribution. The terms |
| # are also available at http://trac.edgewall.org/wiki/TracLicense. |
| # |
| # This software consists of voluntary contributions made by many |
| # individuals. For the exact contribution history, see the revision |
| # history and logs, available at http://trac.edgewall.org/log/. |
| |
| from __future__ import with_statement |
| |
| from datetime import datetime |
| import itertools |
| import os |
| import sys |
| |
| from genshi.builder import tag |
| from genshi.core import Markup |
| |
| from trac.cache import cached |
| from trac.config import BoolOption, IntOption, PathOption, Option |
| from trac.core import * |
| from trac.util import TracError, shorten_line |
| from trac.util.datefmt import FixedOffset, to_timestamp, format_datetime |
| from trac.util.text import to_unicode, exception_to_unicode |
| from trac.util.translation import _ |
| from trac.versioncontrol.api import Changeset, Node, Repository, \ |
| IRepositoryConnector, NoSuchChangeset, \ |
| NoSuchNode, IRepositoryProvider |
| from trac.versioncontrol.cache import CACHE_YOUNGEST_REV, CachedRepository, \ |
| CachedChangeset |
| from trac.versioncontrol.web_ui import IPropertyRenderer |
| from trac.web.chrome import Chrome |
| from trac.wiki import IWikiSyntaxProvider |
| |
| from tracopt.versioncontrol.git import PyGIT |
| |
| |
| class GitCachedRepository(CachedRepository): |
| """Git-specific cached repository.""" |
| |
| def display_rev(self, rev): |
| return self.short_rev(rev) |
| |
| def short_rev(self, path): |
| return self.repos.short_rev(path) |
| |
| def normalize_rev(self, rev): |
| if not rev: |
| return self.get_youngest_rev() |
| normrev = self.repos.git.verifyrev(rev) |
| if normrev is None: |
| raise NoSuchChangeset(rev) |
| return normrev |
| |
| def get_youngest_rev(self): |
| # return None if repository is empty |
| return CachedRepository.get_youngest_rev(self) or None |
| |
| def child_revs(self, rev): |
| return self.repos.child_revs(rev) |
| |
| def get_changesets(self, start, stop): |
| for key, csets in itertools.groupby( |
| CachedRepository.get_changesets(self, start, stop), |
| key=lambda cset: cset.date): |
| csets = list(csets) |
| if len(csets) == 1: |
| yield csets[0] |
| continue |
| rev_csets = dict((cset.rev, cset) for cset in csets) |
| while rev_csets: |
| revs = [rev for rev in rev_csets |
| if not any(r in rev_csets |
| for r in self.repos.child_revs(rev))] |
| for rev in sorted(revs): |
| yield rev_csets.pop(rev) |
| |
| def get_changeset(self, rev): |
| return GitCachedChangeset(self, self.normalize_rev(rev), self.env) |
| |
| def sync(self, feedback=None, clean=False): |
| if clean: |
| self.remove_cache() |
| |
| metadata = self.metadata |
| self.save_metadata(metadata) |
| meta_youngest = metadata.get(CACHE_YOUNGEST_REV) |
| repos = self.repos |
| |
| def is_synced(rev): |
| for count, in self.env.db_query(""" |
| SELECT COUNT(*) FROM revision WHERE repos=%s AND rev=%s |
| """, (self.id, rev)): |
| return count > 0 |
| return False |
| |
| def traverse(rev, seen, revs=None): |
| if revs is None: |
| revs = [] |
| while True: |
| if rev in seen: |
| return revs |
| seen.add(rev) |
| if is_synced(rev): |
| return revs |
| revs.append(rev) |
| parent_revs = repos.parent_revs(rev) |
| if not parent_revs: |
| return revs |
| if len(parent_revs) == 1: |
| rev = parent_revs[0] |
| continue |
| idx = len(revs) |
| traverse(parent_revs.pop(), seen, revs) |
| for parent in parent_revs: |
| revs[idx:idx] = traverse(parent, seen) |
| |
| while True: |
| repos.sync() |
| repos_youngest = repos.youngest_rev |
| updated = False |
| seen = set() |
| |
| for rev in repos.git.all_revs(): |
| if repos.child_revs(rev): |
| continue |
| revs = traverse(rev, seen) # topology ordered |
| while revs: |
| # sync revision from older revision to newer revision |
| rev = revs.pop() |
| self.log.info("Trying to sync revision [%s]", rev) |
| cset = repos.get_changeset(rev) |
| with self.env.db_transaction as db: |
| try: |
| self._insert_changeset(db, rev, cset) |
| updated = True |
| except self.env.db_exc.IntegrityError, e: |
| self.log.info('Revision %s already cached: %r', |
| rev, e) |
| db.rollback() |
| continue |
| if feedback: |
| feedback(rev) |
| |
| if updated: |
| continue # sync again |
| |
| if meta_youngest != repos_youngest: |
| with self.env.db_transaction as db: |
| db(""" |
| UPDATE repository SET value=%s WHERE id=%s AND name=%s |
| """, (repos_youngest, self.id, CACHE_YOUNGEST_REV)) |
| del self.metadata |
| return |
| |
| |
| class GitCachedChangeset(CachedChangeset): |
| """Git-specific cached changeset. |
| |
| Handles get_branches() |
| """ |
| def get_branches(self): |
| _rev = self.rev |
| |
| return [(k, v == _rev) for k, v in |
| self.repos.repos.git.get_branch_contains(_rev, resolve=True)] |
| |
| |
| def _last_iterable(iterable): |
| """helper for detecting last iteration in for-loop""" |
| i = iter(iterable) |
| v = i.next() |
| for nextv in i: |
| yield False, v |
| v = nextv |
| yield True, v |
| |
| def intersperse(sep, iterable): |
| """The 'intersperse' generator takes an element and an iterable and |
| intersperses that element between the elements of the iterable. |
| |
| inspired by Haskell's ``Data.List.intersperse`` |
| """ |
| |
| for i, item in enumerate(iterable): |
| if i: yield sep |
| yield item |
| |
| # helper |
| def _parse_user_time(s): |
| """Parse author or committer attribute lines and return |
| corresponding ``(user, timestamp)`` pair. |
| """ |
| |
| user, time, tz_str = s.rsplit(None, 2) |
| tz = FixedOffset((int(tz_str) * 6) / 10, tz_str) |
| time = datetime.fromtimestamp(float(time), tz) |
| return user, time |
| |
| |
| class GitConnector(Component): |
| |
| implements(IRepositoryConnector, IWikiSyntaxProvider) |
| |
| def __init__(self): |
| self._version = None |
| |
| try: |
| self._version = PyGIT.Storage.git_version(git_bin=self.git_bin) |
| except PyGIT.GitError, e: |
| self.log.error("GitError: " + str(e)) |
| |
| if self._version: |
| self.log.info("detected GIT version %s" % self._version['v_str']) |
| self.env.systeminfo.append(('GIT', self._version['v_str'])) |
| if not self._version['v_compatible']: |
| self.log.error("GIT version %s installed not compatible" |
| "(need >= %s)" % |
| (self._version['v_str'], |
| self._version['v_min_str'])) |
| |
| # IWikiSyntaxProvider methods |
| |
| def _format_sha_link(self, formatter, sha, label): |
| # FIXME: this function needs serious rethinking... |
| |
| reponame = '' |
| |
| context = formatter.context |
| while context: |
| if context.resource.realm in ('source', 'changeset'): |
| reponame = context.resource.parent.id |
| break |
| context = context.parent |
| |
| try: |
| repos = self.env.get_repository(reponame) |
| |
| if not repos: |
| raise Exception("Repository '%s' not found" % reponame) |
| |
| sha = repos.normalize_rev(sha) # in case it was abbreviated |
| changeset = repos.get_changeset(sha) |
| return tag.a(label, class_='changeset', |
| title=shorten_line(changeset.message), |
| href=formatter.href.changeset(sha, repos.reponame)) |
| except Exception, e: |
| return tag.a(label, class_='missing changeset', |
| title=to_unicode(e), rel='nofollow') |
| |
| def get_wiki_syntax(self): |
| yield (r'(?:\b|!)r?[0-9a-fA-F]{%d,40}\b' % self.wiki_shortrev_len, |
| lambda fmt, sha, match: |
| self._format_sha_link(fmt, sha.startswith('r') |
| and sha[1:] or sha, sha)) |
| |
| def get_link_resolvers(self): |
| yield ('sha', lambda fmt, _, sha, label, match=None: |
| self._format_sha_link(fmt, sha, label)) |
| |
| # IRepositoryConnector methods |
| |
| persistent_cache = BoolOption('git', 'persistent_cache', 'false', |
| """Enable persistent caching of commit tree.""") |
| |
| cached_repository = BoolOption('git', 'cached_repository', 'false', |
| """Wrap `GitRepository` in `CachedRepository`.""") |
| |
| shortrev_len = IntOption('git', 'shortrev_len', 7, |
| """The length at which a sha1 should be abbreviated to (must |
| be >= 4 and <= 40). |
| """) |
| |
| wiki_shortrev_len = IntOption('git', 'wikishortrev_len', 40, |
| """The minimum length of an hex-string for which |
| auto-detection as sha1 is performed (must be >= 4 and <= 40). |
| """) |
| |
| trac_user_rlookup = BoolOption('git', 'trac_user_rlookup', 'false', |
| """Enable reverse mapping of git email addresses to trac user ids |
| (costly if you have many users).""") |
| |
| use_committer_id = BoolOption('git', 'use_committer_id', 'true', |
| """Use git-committer id instead of git-author id for the |
| changeset ''Author'' field. |
| """) |
| |
| use_committer_time = BoolOption('git', 'use_committer_time', 'true', |
| """Use git-committer timestamp instead of git-author timestamp |
| for the changeset ''Timestamp'' field. |
| """) |
| |
| git_fs_encoding = Option('git', 'git_fs_encoding', 'utf-8', |
| """Define charset encoding of paths within git repositories.""") |
| |
| git_bin = Option('git', 'git_bin', 'git', |
| """Path to the git executable.""") |
| |
| |
| def get_supported_types(self): |
| yield ('git', 8) |
| |
| def get_repository(self, type, dir, params): |
| """GitRepository factory method""" |
| assert type == 'git' |
| |
| if not (4 <= self.shortrev_len <= 40): |
| raise TracError("[git] shortrev_len setting must be within [4..40]") |
| |
| if not (4 <= self.wiki_shortrev_len <= 40): |
| raise TracError("[git] wikishortrev_len must be within [4..40]") |
| |
| if not self._version: |
| raise TracError("GIT backend not available") |
| elif not self._version['v_compatible']: |
| raise TracError("GIT version %s installed not compatible" |
| "(need >= %s)" % |
| (self._version['v_str'], |
| self._version['v_min_str'])) |
| |
| if self.trac_user_rlookup: |
| def rlookup_uid(email): |
| """Reverse map 'real name <user@domain.tld>' addresses to trac |
| user ids. |
| |
| :return: `None` if lookup failed |
| """ |
| |
| try: |
| _, email = email.rsplit('<', 1) |
| email, _ = email.split('>', 1) |
| email = email.lower() |
| except Exception: |
| return None |
| |
| for _uid, _name, _email in self.env.get_known_users(): |
| try: |
| if email == _email.lower(): |
| return _uid |
| except Exception: |
| continue |
| |
| else: |
| def rlookup_uid(_): |
| return None |
| |
| repos = GitRepository(self.env, dir, params, self.log, |
| persistent_cache=self.persistent_cache, |
| git_bin=self.git_bin, |
| git_fs_encoding=self.git_fs_encoding, |
| shortrev_len=self.shortrev_len, |
| rlookup_uid=rlookup_uid, |
| use_committer_id=self.use_committer_id, |
| use_committer_time=self.use_committer_time, |
| ) |
| |
| if self.cached_repository: |
| repos = GitCachedRepository(self.env, repos, self.log) |
| self.log.debug("enabled CachedRepository for '%s'" % dir) |
| else: |
| self.log.debug("disabled CachedRepository for '%s'" % dir) |
| |
| return repos |
| |
| |
| class CsetPropertyRenderer(Component): |
| |
| implements(IPropertyRenderer) |
| |
| # relied upon by GitChangeset |
| def match_property(self, name, mode): |
| # default renderer has priority 1 |
| return (name in ('Parents', |
| 'Children', |
| 'Branches', |
| 'git-committer', |
| 'git-author', |
| ) and mode == 'revprop') and 4 or 0 |
| |
| def render_property(self, name, mode, context, props): |
| |
| def sha_link(sha, label=None): |
| # sha is assumed to be a non-abbreviated 40-chars sha id |
| try: |
| reponame = context.resource.parent.id |
| repos = self.env.get_repository(reponame) |
| cset = repos.get_changeset(sha) |
| if label is None: |
| label = repos.display_rev(sha) |
| |
| return tag.a(label, class_='changeset', |
| title=shorten_line(cset.message), |
| href=context.href.changeset(sha, repos.reponame)) |
| |
| except Exception, e: |
| return tag.a(sha, class_='missing changeset', |
| title=to_unicode(e), rel='nofollow') |
| |
| if name == 'Branches': |
| branches = props[name] |
| |
| # simple non-merge commit |
| return tag(*intersperse(', ', (sha_link(rev, label) |
| for label, rev in branches))) |
| |
| elif name in ('Parents', 'Children'): |
| revs = props[name] # list of commit ids |
| |
| if name == 'Parents' and len(revs) > 1: |
| # we got a merge... |
| current_sha = context.resource.id |
| reponame = context.resource.parent.id |
| |
| parent_links = intersperse(', ', \ |
| ((sha_link(rev), |
| ' (', |
| tag.a(_("diff"), |
| title=_("Diff against this parent (show the " |
| "changes merged from the other parents)"), |
| href=context.href.changeset(current_sha, reponame, |
| old=rev)), |
| ')') |
| for rev in revs)) |
| |
| return tag(list(parent_links), |
| tag.br(), |
| tag.span(Markup(_("Note: this is a <strong>merge" |
| "</strong> changeset, the " |
| "changes displayed below " |
| "correspond to the merge " |
| "itself.")), |
| class_='hint'), |
| tag.br(), |
| tag.span(Markup(_("Use the <tt>(diff)</tt> links " |
| "above to see all the changes " |
| "relative to each parent.")), |
| class_='hint')) |
| |
| # simple non-merge commit |
| return tag(*intersperse(', ', map(sha_link, revs))) |
| |
| elif name in ('git-committer', 'git-author'): |
| user_, time_ = props[name] |
| _str = "%s (%s)" % ( |
| Chrome(self.env).format_author(context.req, user_), |
| format_datetime(time_, tzinfo=context.req.tz)) |
| return unicode(_str) |
| |
| raise TracError("Internal error") |
| |
| |
| class GitRepository(Repository): |
| """Git repository""" |
| |
| def __init__(self, env, path, params, log, |
| persistent_cache=False, |
| git_bin='git', |
| git_fs_encoding='utf-8', |
| shortrev_len=7, |
| rlookup_uid=lambda _: None, |
| use_committer_id=False, |
| use_committer_time=False, |
| ): |
| |
| self.env = env |
| self.logger = log |
| self.gitrepo = path |
| self.params = params |
| self.persistent_cache = persistent_cache |
| self.shortrev_len = max(4, min(shortrev_len, 40)) |
| self.rlookup_uid = rlookup_uid |
| self.use_committer_time = use_committer_time |
| self.use_committer_id = use_committer_id |
| |
| try: |
| factory = PyGIT.StorageFactory(path, log, not persistent_cache, |
| git_bin=git_bin, |
| git_fs_encoding=git_fs_encoding) |
| self._git = factory.getInstance() |
| except PyGIT.GitError, e: |
| log.error(exception_to_unicode(e)) |
| raise TracError("%s does not appear to be a Git " |
| "repository." % path) |
| |
| Repository.__init__(self, 'git:' + path, self.params, log) |
| self._cached_git_id = str(self.id) |
| |
| def close(self): |
| self._git = None |
| |
| @property |
| def git(self): |
| if self.persistent_cache: |
| return self._cached_git |
| else: |
| return self._git |
| |
| @cached('_cached_git_id') |
| def _cached_git(self): |
| self._git.invalidate_rev_cache() |
| return self._git |
| |
| def get_youngest_rev(self): |
| return self.git.youngest_rev() |
| |
| def get_oldest_rev(self): |
| return self.git.oldest_rev() |
| |
| def normalize_path(self, path): |
| return path and path.strip('/') or '/' |
| |
| def normalize_rev(self, rev): |
| if not rev: |
| return self.get_youngest_rev() |
| normrev = self.git.verifyrev(rev) |
| if normrev is None: |
| raise NoSuchChangeset(rev) |
| return normrev |
| |
| def display_rev(self, rev): |
| return self.short_rev(rev) |
| |
| def short_rev(self, rev): |
| return self.git.shortrev(self.normalize_rev(rev), |
| min_len=self.shortrev_len) |
| |
| def get_node(self, path, rev=None, historian=None): |
| return GitNode(self, path, rev, self.log, None, historian) |
| |
| def get_quickjump_entries(self, rev): |
| for bname, bsha in self.git.get_branches(): |
| yield 'branches', bname, '/', bsha |
| for t in self.git.get_tags(): |
| yield 'tags', t, '/', t |
| |
| def get_path_url(self, path, rev): |
| return self.params.get('url') |
| |
| def get_changesets(self, start, stop): |
| for rev in self.git.history_timerange(to_timestamp(start), |
| to_timestamp(stop)): |
| yield self.get_changeset(rev) |
| |
| def get_changeset(self, rev): |
| """GitChangeset factory method""" |
| return GitChangeset(self, rev) |
| |
| def get_changeset_uid(self, rev): |
| return self.normalize_rev(rev) |
| |
| def get_changes(self, old_path, old_rev, new_path, new_rev, |
| ignore_ancestry=0): |
| # TODO: handle renames/copies, ignore_ancestry |
| if old_path != new_path: |
| raise TracError("not supported in git_fs") |
| |
| with self.git.get_historian(old_rev, |
| old_path.strip('/')) as old_historian: |
| with self.git.get_historian(new_rev, |
| new_path.strip('/')) as new_historian: |
| for chg in self.git.diff_tree(old_rev, new_rev, |
| self.normalize_path(new_path)): |
| mode1, mode2, obj1, obj2, action, path, path2 = chg |
| |
| kind = Node.FILE |
| if mode2.startswith('04') or mode1.startswith('04'): |
| kind = Node.DIRECTORY |
| |
| change = GitChangeset.action_map[action] |
| |
| old_node = None |
| new_node = None |
| |
| if change != Changeset.ADD: |
| old_node = self.get_node(path, old_rev, old_historian) |
| if change != Changeset.DELETE: |
| new_node = self.get_node(path, new_rev, new_historian) |
| |
| yield old_node, new_node, kind, change |
| |
| def next_rev(self, rev, path=''): |
| return self.git.hist_next_revision(rev) |
| |
| def previous_rev(self, rev, path=''): |
| return self.git.hist_prev_revision(rev) |
| |
| def parent_revs(self, rev): |
| return self.git.parents(rev) |
| |
| def child_revs(self, rev): |
| return self.git.children(rev) |
| |
| def rev_older_than(self, rev1, rev2): |
| return self.git.rev_is_anchestor_of(self.normalize_rev(rev1), |
| self.normalize_rev(rev2)) |
| |
| # def clear(self, youngest_rev=None): |
| # self.youngest = None |
| # if youngest_rev is not None: |
| # self.youngest = self.normalize_rev(youngest_rev) |
| # self.oldest = None |
| |
| def clear(self, youngest_rev=None): |
| self.sync() |
| |
| def sync(self, rev_callback=None, clean=None): |
| if rev_callback: |
| revs = set(self.git.all_revs()) |
| |
| if self.persistent_cache: |
| del self._cached_git # invalidate persistent cache |
| if not self.git.sync(): |
| return None # nothing expected to change |
| |
| if rev_callback: |
| revs = set(self.git.all_revs()) - revs |
| for rev in revs: |
| rev_callback(rev) |
| |
| |
| class GitNode(Node): |
| |
| def __init__(self, repos, path, rev, log, ls_tree_info=None, |
| historian=None): |
| self.log = log |
| self.repos = repos |
| self.fs_sha = None # points to either tree or blobs |
| self.fs_perm = None |
| self.fs_size = None |
| if rev: |
| rev = repos.normalize_rev(to_unicode(rev)) |
| else: |
| rev = repos.youngest_rev |
| |
| kind = Node.DIRECTORY |
| p = path.strip('/') |
| if p: # ie. not the root-tree |
| if not rev: |
| raise NoSuchNode(path, rev) |
| if not ls_tree_info: |
| ls_tree_info = repos.git.ls_tree(rev, p) or None |
| if ls_tree_info: |
| [ls_tree_info] = ls_tree_info |
| |
| if not ls_tree_info: |
| raise NoSuchNode(path, rev) |
| |
| self.fs_perm, k, self.fs_sha, self.fs_size, _ = ls_tree_info |
| |
| # fix-up to the last commit-rev that touched this node |
| rev = repos.git.last_change(rev, p, historian) |
| |
| if k == 'tree': |
| pass |
| elif k == 'commit': |
| # FIXME: this is a workaround for missing git submodule |
| # support in the plugin |
| pass |
| elif k == 'blob': |
| kind = Node.FILE |
| else: |
| raise TracError("Internal error (got unexpected object " \ |
| "kind '%s')" % k) |
| |
| self.created_path = path |
| self.created_rev = rev |
| |
| Node.__init__(self, repos, path, rev, kind) |
| |
| def __git_path(self): |
| """return path as expected by PyGIT""" |
| p = self.path.strip('/') |
| if self.isfile: |
| assert p |
| return p |
| if self.isdir: |
| return p and (p + '/') |
| |
| raise TracError("internal error") |
| |
| def get_content(self): |
| if not self.isfile: |
| return None |
| |
| return self.repos.git.get_file(self.fs_sha) |
| |
| def get_properties(self): |
| return self.fs_perm and {'mode': self.fs_perm } or {} |
| |
| def get_annotations(self): |
| if not self.isfile: |
| return |
| |
| return [rev for rev, lineno in \ |
| self.repos.git.blame(self.rev,self.__git_path())] |
| |
| def get_entries(self): |
| if not self.rev: # if empty repository |
| return |
| if not self.isdir: |
| return |
| |
| with self.repos.git.get_historian(self.rev, |
| self.path.strip('/')) as historian: |
| for ent in self.repos.git.ls_tree(self.rev, self.__git_path()): |
| yield GitNode(self.repos, ent[-1], self.rev, self.log, ent, |
| historian) |
| |
| def get_content_type(self): |
| if self.isdir: |
| return None |
| |
| return '' |
| |
| def get_content_length(self): |
| if not self.isfile: |
| return None |
| |
| if self.fs_size is None: |
| self.fs_size = self.repos.git.get_obj_size(self.fs_sha) |
| |
| return self.fs_size |
| |
| def get_history(self, limit=None): |
| if not self.rev: # if empty repository |
| return |
| # TODO: find a way to follow renames/copies |
| for is_last, rev in _last_iterable(self.repos.git.history(self.rev, |
| self.__git_path(), limit)): |
| yield (self.path, rev, Changeset.EDIT if not is_last else |
| Changeset.ADD) |
| |
| def get_last_modified(self): |
| if not self.isfile: |
| return None |
| |
| try: |
| msg, props = self.repos.git.read_commit(self.rev) |
| user, ts = _parse_user_time(props['committer'][0]) |
| except: |
| self.log.error("internal error (could not get timestamp from " |
| "commit '%s')" % self.rev) |
| return None |
| |
| return ts |
| |
| |
| class GitChangeset(Changeset): |
| """A Git changeset in the Git repository. |
| |
| Corresponds to a Git commit blob. |
| """ |
| |
| action_map = { # see also git-diff-tree(1) --diff-filter |
| 'A': Changeset.ADD, |
| 'M': Changeset.EDIT, # modified |
| 'T': Changeset.EDIT, # file type (mode) change |
| 'D': Changeset.DELETE, |
| 'R': Changeset.MOVE, # renamed |
| 'C': Changeset.COPY |
| } # TODO: U, X, B |
| |
| def __init__(self, repos, sha): |
| if sha is None: |
| raise NoSuchChangeset(sha) |
| |
| try: |
| msg, props = repos.git.read_commit(sha) |
| except PyGIT.GitErrorSha: |
| raise NoSuchChangeset(sha) |
| |
| self.props = props |
| |
| assert 'children' not in props |
| _children = list(repos.git.children(sha)) |
| if _children: |
| props['children'] = _children |
| |
| committer, author = self._get_committer_and_author() |
| # use 1st author/committer as changeset owner/timestamp |
| c_user = a_user = c_time = a_time = None |
| if committer: |
| c_user, c_time = _parse_user_time(committer) |
| if author: |
| a_user, a_time = _parse_user_time(author) |
| |
| if repos.use_committer_time: |
| time = c_time or a_time |
| else: |
| time = a_time or c_time |
| |
| if repos.use_committer_id: |
| user = c_user or a_user |
| else: |
| user = a_user or c_user |
| |
| # try to resolve email address to trac uid |
| user = repos.rlookup_uid(user) or user |
| |
| Changeset.__init__(self, repos, rev=sha, message=msg, author=user, |
| date=time) |
| |
| def _get_committer_and_author(self): |
| committer = author = None |
| if 'committer' in self.props: |
| committer = self.props['committer'][0] |
| if 'author' in self.props: |
| author = self.props['author'][0] |
| return committer, author |
| |
| def get_properties(self): |
| properties = {} |
| |
| if 'parent' in self.props: |
| properties['Parents'] = self.props['parent'] |
| |
| if 'children' in self.props: |
| properties['Children'] = self.props['children'] |
| |
| committer, author = self._get_committer_and_author() |
| if author != committer: |
| properties['git-committer'] = _parse_user_time(committer) |
| properties['git-author'] = _parse_user_time(author) |
| |
| branches = list(self.repos.git.get_branch_contains(self.rev, |
| resolve=True)) |
| if branches: |
| properties['Branches'] = branches |
| |
| return properties |
| |
| def get_changes(self): |
| paths_seen = set() |
| for parent in self.props.get('parent', [None]): |
| for mode1, mode2, obj1, obj2, action, path1, path2 in \ |
| self.repos.git.diff_tree(parent, self.rev, |
| find_renames=True): |
| path = path2 or path1 |
| p_path, p_rev = path1, parent |
| |
| kind = Node.FILE |
| if mode2.startswith('04') or mode1.startswith('04'): |
| kind = Node.DIRECTORY |
| |
| action = GitChangeset.action_map[action[0]] |
| |
| if action == Changeset.ADD: |
| p_path = '' |
| p_rev = None |
| |
| # CachedRepository expects unique (rev, path, change_type) key |
| # this is only an issue in case of merges where files required |
| # editing |
| if path in paths_seen: |
| continue |
| |
| paths_seen.add(path) |
| |
| yield path, kind, action, p_path, p_rev |
| |
| |
| def get_branches(self): |
| _rev = self.rev |
| |
| return [(k, v == _rev) |
| for k, v in self.repos.git.get_branch_contains(_rev, |
| resolve=True)] |
| |
| |
| class GitwebProjectsRepositoryProvider(Component): |
| |
| implements(IRepositoryProvider) |
| |
| projects_list = PathOption('git', 'projects_list', doc= |
| """Path to a gitweb-formatted projects.list""") |
| |
| projects_base = PathOption('git', 'projects_base', doc= |
| """Path to the base of your git projects""") |
| |
| projects_url = Option('git', 'projects_url', doc= |
| """Template for project URLs. %s will be replaced with the repo |
| name""") |
| |
| def get_repositories(self): |
| if not self.projects_list: |
| return |
| |
| for line in open(self.projects_list): |
| line = line.strip() |
| name = line |
| if name.endswith('.git'): |
| name = name[:-4] |
| repo = { |
| 'dir': os.path.join(self.projects_base, line), |
| 'type': 'git', |
| } |
| description_path = os.path.join(repo['dir'], 'description') |
| if os.path.exists(description_path): |
| repo['description'] = open(description_path).read().strip() |
| if self.projects_url: |
| repo['url'] = self.projects_url % name |
| yield name, repo |