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 | |
---|
9 | import os |
---|
10 | import posixpath |
---|
11 | from cStringIO import StringIO |
---|
12 | from datetime import datetime |
---|
13 | from threading import RLock |
---|
14 | |
---|
15 | try: |
---|
16 | import pygit2 |
---|
17 | except ImportError: |
---|
18 | pygit2 = None |
---|
19 | pygit2_version = None |
---|
20 | else: |
---|
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 | |
---|
30 | from genshi.builder import tag |
---|
31 | |
---|
32 | from trac.core import Component, implements, TracError |
---|
33 | from trac.env import Environment, ISystemInfoProvider |
---|
34 | from trac.util import shorten_line |
---|
35 | from trac.util.compat import any |
---|
36 | from trac.util.datefmt import ( |
---|
37 | FixedOffset, format_datetime, to_timestamp, to_utimestamp, utc, |
---|
38 | ) |
---|
39 | from trac.util.text import exception_to_unicode, to_unicode |
---|
40 | from trac.versioncontrol.api import ( |
---|
41 | Changeset, Node, Repository, IRepositoryConnector, NoSuchChangeset, |
---|
42 | NoSuchNode, |
---|
43 | ) |
---|
44 | from trac.versioncontrol.cache import ( |
---|
45 | CACHE_METADATA_KEYS, CACHE_REPOSITORY_DIR, CACHE_YOUNGEST_REV, |
---|
46 | CachedRepository, CachedChangeset, |
---|
47 | ) |
---|
48 | from trac.versioncontrol.web_ui import IPropertyRenderer, RenderedProperty |
---|
49 | from trac.web.chrome import Chrome |
---|
50 | from trac.wiki import IWikiSyntaxProvider |
---|
51 | from trac.wiki.formatter import wiki_to_oneliner |
---|
52 | |
---|
53 | try: |
---|
54 | from trac.util.datefmt import user_time |
---|
55 | except 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 | |
---|
63 | try: |
---|
64 | from tracopt.versioncontrol.git.git_fs import GitConnector \ |
---|
65 | as TracGitConnector |
---|
66 | except ImportError: |
---|
67 | try: |
---|
68 | from tracext.git.git_fs import GitConnector as TracGitConnector |
---|
69 | except ImportError: |
---|
70 | TracGitConnector = None |
---|
71 | |
---|
72 | from 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 | |
---|
83 | if 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 |
---|
101 | else: |
---|
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 | |
---|
114 | if hasattr(Environment, 'db_exc'): |
---|
115 | def _db_exc(env): |
---|
116 | return env.db_exc |
---|
117 | else: |
---|
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 | |
---|
132 | class 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 | |
---|
358 | class 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 | |
---|
368 | def 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 | |
---|
381 | def _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 | |
---|
391 | def _format_signature(signature): |
---|
392 | name = signature.name.strip() |
---|
393 | email = signature.email.strip() |
---|
394 | return ('%s <%s>' % (name, email)).strip() |
---|
395 | |
---|
396 | |
---|
397 | def _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 | |
---|
415 | class _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 | |
---|
458 | class 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 | |
---|
620 | class 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 | |
---|
726 | class 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 | |
---|
1102 | class 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 | |
---|
1327 | class 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 |
---|