1
# -*- coding: iso-8859-1 -*-
3
# Copyright (C) 2006,2008 Herbert Valerio Riedel <hvr@gnu.org>
5
# This program is free software; you can redistribute it and/or
6
# modify it under the terms of the GNU General Public License
7
# as published by the Free Software Foundation; either version 2
8
# of the License, or (at your option) any later version.
10
# This program is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
# GNU General Public License for more details.
15
from trac.core import *
16
from trac.util import TracError, shorten_line
17
from trac.util.datefmt import FixedOffset, to_timestamp
18
from trac.versioncontrol.api import \
19
Changeset, Node, Repository, IRepositoryConnector, NoSuchChangeset, NoSuchNode
20
from trac.wiki import IWikiSyntaxProvider
21
from trac.versioncontrol.cache import CachedRepository
22
from trac.versioncontrol.web_ui import IPropertyRenderer
23
from trac.config import BoolOption, IntOption, PathOption, Option
25
# for some reason CachedRepository doesn't pass-through short_rev()s
26
class CachedRepository2(CachedRepository):
27
def short_rev(self, path):
28
return self.repos.short_rev(path)
30
from genshi.builder import tag
31
from genshi.core import Markup, escape
33
from datetime import datetime
36
if not sys.version_info[:2] >= (2,5):
37
raise TracError("python >= 2.5 dependancy not met")
41
def _last_iterable(iterable):
42
"helper for detecting last iteration in for-loop"
51
def _parse_user_time(s):
52
"""parse author/committer attribute lines and return
54
(user,time,tz_str) = s.rsplit(None, 2)
55
tz = FixedOffset((int(tz_str)*6)/10, tz_str)
56
time = datetime.fromtimestamp(float(time), tz)
59
class GitConnector(Component):
60
implements(IRepositoryConnector, IWikiSyntaxProvider, IPropertyRenderer)
66
self._version = PyGIT.Storage.git_version(git_bin=self._git_bin)
67
except PyGIT.GitError, e:
68
self.log.error("GitError: "+e.message)
71
self.log.info("detected GIT version %s" % self._version['v_str'])
72
self.env.systeminfo.append(('GIT', self._version['v_str']))
73
if not self._version['v_compatible']:
74
self.log.error("GIT version %s installed not compatible (need >= %s)" %
75
(self._version['v_str'], self._version['v_min_str']))
77
def _format_sha_link(self, formatter, ns, sha, label, fullmatch=None):
79
changeset = self.env.get_repository().get_changeset(sha)
80
return tag.a(label, class_="changeset",
81
title=shorten_line(changeset.message),
82
href=formatter.href.changeset(sha))
84
return tag.a(label, class_="missing changeset",
85
href=formatter.href.changeset(sha),
86
title=unicode(e), rel="nofollow")
88
#######################
91
# relied upon by GitChangeset
93
def match_property(self, name, mode):
94
if name in ('Parents','Children','git-committer','git-author') \
95
and mode == 'revprop':
96
return 8 # default renderer has priority 1
99
def render_property(self, name, mode, context, props):
101
return self._format_sha_link(context, 'sha', sha, sha)
103
if name in ('Parents','Children'):
106
return tag([tag(sha_link(rev), ', ') for rev in revs[:-1]],
109
if name in ('git-committer', 'git-author'):
110
user_,time_ = props[name]
111
_str = user_ + " / " + time_.strftime('%Y-%m-%dT%H:%M:%SZ%z')
114
raise TracError("internal error")
116
#######################
117
# IWikiSyntaxProvider
119
def get_wiki_syntax(self):
120
yield (r'(?:\b|!)[0-9a-fA-F]{40,40}\b',
121
lambda fmt, sha, match:
122
self._format_sha_link(fmt, 'changeset', sha, sha))
124
def get_link_resolvers(self):
125
yield ('sha', self._format_sha_link)
127
#######################
128
# IRepositoryConnector
130
_persistent_cache = BoolOption('git', 'persistent_cache', 'false',
131
"enable persistent caching of commit tree")
133
_cached_repository = BoolOption('git', 'cached_repository', 'false',
134
"wrap `GitRepository` in `CachedRepository`")
136
_shortrev_len = IntOption('git', 'shortrev_len', 7,
137
"length rev sha sums should be tried to be abbreviated to"
138
" (must be >= 4 and <= 40)")
140
_git_bin = PathOption('git', 'git_bin', 'git', "file name of git executable")
143
def get_supported_types(self):
146
def get_repository(self, type, dir, authname):
147
"""GitRepository factory method"""
150
if not self._version:
151
raise TracError("GIT backend not available")
152
elif not self._version['v_compatible']:
153
raise TracError("GIT version %s installed not compatible (need >= %s)" %
154
(self._version['v_str'], self._version['v_min_str']))
156
repos = GitRepository(dir, self.log,
157
persistent_cache=self._persistent_cache,
158
git_bin=self._git_bin,
159
shortrev_len=self._shortrev_len)
161
if self._cached_repository:
162
repos = CachedRepository2(self.env.get_db_cnx(), repos, None, self.log)
163
self.log.info("enabled CachedRepository for '%s'" % dir)
165
self.log.info("disabled CachedRepository for '%s'" % dir)
169
class GitRepository(Repository):
170
def __init__(self, path, log, persistent_cache=False, git_bin='git', shortrev_len=7):
173
self._shortrev_len = max(4, min(shortrev_len, 40))
175
self.git = PyGIT.StorageFactory(path, log, not persistent_cache,
176
git_bin=git_bin).getInstance()
177
Repository.__init__(self, "git:"+path, None, log)
182
def clear(self, youngest_rev=None):
184
if youngest_rev is not None:
185
self.youngest = self.normalize_rev(youngest_rev)
188
def get_youngest_rev(self):
189
return self.git.youngest_rev()
191
def get_oldest_rev(self):
192
return self.git.oldest_rev()
194
def normalize_path(self, path):
195
return path and path.strip('/') or ''
197
def normalize_rev(self, rev):
199
return self.get_youngest_rev()
200
normrev=self.git.verifyrev(rev)
202
raise NoSuchChangeset(rev)
205
def short_rev(self, rev):
206
return self.git.shortrev(self.normalize_rev(rev), min_len=self._shortrev_len)
208
def get_node(self, path, rev=None):
209
return GitNode(self.git, path, rev, self.log)
211
def get_quickjump_entries(self, rev):
212
for bname,bsha in self.git.get_branches():
213
yield 'branches', bname, '/', bsha
214
for t in self.git.get_tags():
215
yield 'tags', t, '/', t
217
def get_changesets(self, start, stop):
218
for rev in self.git.history_timerange(to_timestamp(start), to_timestamp(stop)):
219
yield self.get_changeset(rev)
221
def get_changeset(self, rev):
222
"""GitChangeset factory method"""
223
return GitChangeset(self.git, rev)
225
def get_changes(self, old_path, old_rev, new_path, new_rev, ignore_ancestry=0):
226
# TODO: handle renames/copies, ignore_ancestry
227
if old_path != new_path:
228
raise TracError("not supported in git_fs")
230
for chg in self.git.diff_tree(old_rev, new_rev, self.normalize_path(new_path)):
231
(mode1,mode2,obj1,obj2,action,path,path2) = chg
234
if mode2.startswith('04') or mode1.startswith('04'):
235
kind = Node.DIRECTORY
237
change = GitChangeset.action_map[action]
242
if change != Changeset.ADD:
243
old_node = self.get_node(path, old_rev)
244
if change != Changeset.DELETE:
245
new_node = self.get_node(path, new_rev)
247
yield (old_node, new_node, kind, change)
249
def next_rev(self, rev, path=''):
250
return self.git.hist_next_revision(rev)
252
def previous_rev(self, rev):
253
return self.git.hist_prev_revision(rev)
255
def rev_older_than(self, rev1, rev2):
256
rc = self.git.rev_is_anchestor_of(rev1, rev2)
259
def clear(self, youngest_rev=None):
262
def sync(self, rev_callback=None):
264
revs = set(self.git.all_revs())
266
if not self.git.sync():
267
return None # nothing expected to change
270
revs = set(self.git.all_revs()) - revs
275
def __init__(self, git, path, rev, log, ls_tree_info=None):
278
self.fs_sha = None # points to either tree or blobs
282
kind = Node.DIRECTORY
284
if p: # ie. not the root-tree
286
ls_tree_info = git.ls_tree(rev, p) or None
288
[ls_tree_info] = ls_tree_info
291
raise NoSuchNode(path, rev)
293
(self.fs_perm, k, self.fs_sha, fn) = ls_tree_info
295
# fix-up to the last commit-rev that touched this node
296
rev = self.git.last_change(rev, p)
303
raise TracError("internal error (got unexpected object kind '%s')" % k)
305
self.created_path = path
306
self.created_rev = rev
308
Node.__init__(self, path, rev, kind)
310
def __git_path(self):
311
"return path as expected by PyGIT"
312
p = self.path.strip('/')
317
return p and (p + '/')
319
raise TracError("internal error")
321
def get_content(self):
325
return self.git.get_file(self.fs_sha)
327
def get_properties(self):
328
return self.fs_perm and {'mode': self.fs_perm } or {}
330
def get_annotations(self):
334
return [ rev for (rev,lineno) in self.git.blame(self.rev, self.__git_path()) ]
336
def get_entries(self):
340
for ent in self.git.ls_tree(self.rev, self.__git_path()):
341
yield GitNode(self.git, ent[3], self.rev, self.log, ent)
343
def get_content_type(self):
349
def get_content_length(self):
353
if self.fs_size is None:
354
self.fs_size = self.git.get_obj_size(self.fs_sha)
358
def get_history(self, limit=None):
359
# TODO: find a way to follow renames/copies
360
for is_last,rev in _last_iterable(self.git.history(self.rev, self.__git_path(), limit)):
361
yield (self.path, rev, Changeset.EDIT if not is_last else Changeset.ADD)
363
def get_last_modified(self):
368
msg, props = self.git.read_commit(self.rev)
369
user,ts = _parse_user_time(props['committer'][0])
371
self.log.error("internal error (could not get timestamp from commit '%s')" % self.rev)
376
class GitChangeset(Changeset):
381
'D': Changeset.DELETE,
386
def __init__(self, git, sha):
389
(msg, props) = git.read_commit(sha)
390
except PyGIT.GitErrorSha:
391
raise NoSuchChangeset(sha)
394
assert 'children' not in props
395
_children = list(git.children(sha))
397
props['children'] = _children
399
# use 1st committer as changeset owner/timestamp
400
(user_, time_) = _parse_user_time(props['committer'][0])
402
Changeset.__init__(self, sha, msg, user_, time_)
404
def get_properties(self):
406
if 'parent' in self.props:
407
properties['Parents'] = self.props['parent']
408
if 'children' in self.props:
409
properties['Children'] = self.props['children']
410
if 'committer' in self.props:
411
properties['git-committer'] = \
412
_parse_user_time(self.props['committer'][0])
413
if 'author' in self.props:
414
git_author = _parse_user_time(self.props['author'][0])
415
if not properties.get('git-committer') == git_author:
416
properties['git-author'] = git_author
420
def get_changes(self):
422
for parent in self.props.get('parent', [None]):
423
for mode1,mode2,obj1,obj2,action,path1,path2 in \
424
self.git.diff_tree(parent, self.rev, find_renames=True):
425
path = path2 or path1
426
p_path, p_rev = path1, parent
429
if mode2.startswith('04') or mode1.startswith('04'):
430
kind = Node.DIRECTORY
432
action = GitChangeset.action_map[action[0]]
434
if action == Changeset.ADD:
438
# CachedRepository expects unique (rev, path, change_type) key
439
# this is only an issue in case of merges where files required editing
440
if path in paths_seen:
445
yield (path, kind, action, p_path, p_rev)