##// END OF EJS Templates
git: run external commands as list of strings so we really get correct quoting (Issue #135)...
Mads Kiilerich -
r5182:0e2d450f default
parent child Browse files
Show More
@@ -1,289 +1,289 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 """
15 15 kallithea.controllers.compare
16 16 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
17 17
18 18 compare controller for pylons showing differences between two
19 19 repos, branches, bookmarks or tips
20 20
21 21 This file was forked by the Kallithea project in July 2014.
22 22 Original author and date, and relevant copyright and licensing information is below:
23 23 :created_on: May 6, 2012
24 24 :author: marcink
25 25 :copyright: (c) 2013 RhodeCode GmbH, and others.
26 26 :license: GPLv3, see LICENSE.md for more details.
27 27 """
28 28
29 29
30 30 import logging
31 31 import re
32 32
33 33 from webob.exc import HTTPBadRequest
34 34 from pylons import request, tmpl_context as c, url
35 35 from pylons.controllers.util import redirect
36 36 from pylons.i18n.translation import _
37 37
38 38 from kallithea.lib.vcs.utils.hgcompat import unionrepo
39 39 from kallithea.lib import helpers as h
40 40 from kallithea.lib.base import BaseRepoController, render
41 41 from kallithea.lib.auth import LoginRequired, HasRepoPermissionAnyDecorator
42 42 from kallithea.lib import diffs
43 43 from kallithea.model.db import Repository
44 44 from kallithea.lib.diffs import LimitedDiffContainer
45 45 from kallithea.controllers.changeset import _ignorews_url,\
46 46 _context_url, get_line_ctx, get_ignore_ws
47 47 from kallithea.lib.graphmod import graph_data
48 48 from kallithea.lib.compat import json
49 49
50 50 log = logging.getLogger(__name__)
51 51
52 52
53 53 class CompareController(BaseRepoController):
54 54
55 55 def __before__(self):
56 56 super(CompareController, self).__before__()
57 57
58 58 @staticmethod
59 59 def _get_changesets(alias, org_repo, org_rev, other_repo, other_rev):
60 60 """
61 61 Returns lists of changesets that can be merged from org_repo@org_rev
62 62 to other_repo@other_rev
63 63 ... and the other way
64 64 ... and the ancestor that would be used for merge
65 65
66 66 :param org_repo: repo object, that is most likely the original repo we forked from
67 67 :param org_rev: the revision we want our compare to be made
68 68 :param other_repo: repo object, most likely the fork of org_repo. It has
69 69 all changesets that we need to obtain
70 70 :param other_rev: revision we want out compare to be made on other_repo
71 71 """
72 72 ancestor = None
73 73 if org_rev == other_rev or org_repo.EMPTY_CHANGESET in (org_rev, other_rev):
74 74 org_changesets = []
75 75 other_changesets = []
76 76 ancestor = org_rev
77 77
78 78 elif alias == 'hg':
79 79 #case two independent repos
80 80 if org_repo != other_repo:
81 81 hgrepo = unionrepo.unionrepository(other_repo.baseui,
82 82 other_repo.path,
83 83 org_repo.path)
84 84 # all ancestors of other_rev will be in other_repo and
85 85 # rev numbers from hgrepo can be used in other_repo - org_rev ancestors cannot
86 86
87 87 #no remote compare do it on the same repository
88 88 else:
89 89 hgrepo = other_repo._repo
90 90
91 91 ancestors = hgrepo.revs("ancestor(id(%s), id(%s))", org_rev, other_rev)
92 92 if ancestors:
93 93 # FIXME: picks arbitrary ancestor - but there is usually only one
94 94 try:
95 95 ancestor = hgrepo[ancestors.first()].hex()
96 96 except AttributeError:
97 97 # removed in hg 3.2
98 98 ancestor = hgrepo[ancestors[0]].hex()
99 99
100 100 other_revs = hgrepo.revs("ancestors(id(%s)) and not ancestors(id(%s)) and not id(%s)",
101 101 other_rev, org_rev, org_rev)
102 102 other_changesets = [other_repo.get_changeset(rev) for rev in other_revs]
103 103 org_revs = hgrepo.revs("ancestors(id(%s)) and not ancestors(id(%s)) and not id(%s)",
104 104 org_rev, other_rev, other_rev)
105 105
106 106 org_changesets = [org_repo.get_changeset(hgrepo[rev].hex()) for rev in org_revs]
107 107
108 108 elif alias == 'git':
109 109 if org_repo != other_repo:
110 110 from dulwich.repo import Repo
111 111 from dulwich.client import SubprocessGitClient
112 112
113 113 gitrepo = Repo(org_repo.path)
114 114 SubprocessGitClient(thin_packs=False).fetch(other_repo.path, gitrepo)
115 115
116 116 gitrepo_remote = Repo(other_repo.path)
117 117 SubprocessGitClient(thin_packs=False).fetch(org_repo.path, gitrepo_remote)
118 118
119 119 revs = []
120 120 for x in gitrepo_remote.get_walker(include=[other_rev],
121 121 exclude=[org_rev]):
122 122 revs.append(x.commit.id)
123 123
124 124 other_changesets = [other_repo.get_changeset(rev) for rev in reversed(revs)]
125 125 if other_changesets:
126 126 ancestor = other_changesets[0].parents[0].raw_id
127 127 else:
128 128 # no changesets from other repo, ancestor is the other_rev
129 129 ancestor = other_rev
130 130
131 131 else:
132 132 so, se = org_repo.run_git_command(
133 'log --reverse --pretty="format: %%H" -s %s..%s'
134 % (org_rev, other_rev)
133 ['log', '--reverse', '--pretty=format:%H',
134 '-s', '%s..%s' % (org_rev, other_rev)]
135 135 )
136 136 other_changesets = [org_repo.get_changeset(cs)
137 137 for cs in re.findall(r'[0-9a-fA-F]{40}', so)]
138 138 so, se = org_repo.run_git_command(
139 'merge-base %s %s' % (org_rev, other_rev)
139 ['merge-base', org_rev, other_rev]
140 140 )
141 141 ancestor = re.findall(r'[0-9a-fA-F]{40}', so)[0]
142 142 org_changesets = []
143 143
144 144 else:
145 145 raise Exception('Bad alias only git and hg is allowed')
146 146
147 147 return other_changesets, org_changesets, ancestor
148 148
149 149 @LoginRequired()
150 150 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
151 151 'repository.admin')
152 152 def index(self, repo_name):
153 153 c.compare_home = True
154 154 org_repo = c.db_repo.repo_name
155 155 other_repo = request.GET.get('other_repo', org_repo)
156 156 c.a_repo = Repository.get_by_repo_name(org_repo)
157 157 c.cs_repo = Repository.get_by_repo_name(other_repo)
158 158 c.a_ref_name = c.cs_ref_name = _('Select changeset')
159 159 return render('compare/compare_diff.html')
160 160
161 161 @LoginRequired()
162 162 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
163 163 'repository.admin')
164 164 def compare(self, repo_name, org_ref_type, org_ref_name, other_ref_type, other_ref_name):
165 165 org_repo = c.db_repo.repo_name
166 166 other_repo = request.GET.get('other_repo', org_repo)
167 167 # If merge is True:
168 168 # Show what org would get if merged with other:
169 169 # List changesets that are ancestors of other but not of org.
170 170 # New changesets in org is thus ignored.
171 171 # Diff will be from common ancestor, and merges of org to other will thus be ignored.
172 172 # If merge is False:
173 173 # Make a raw diff from org to other, no matter if related or not.
174 174 # Changesets in one and not in the other will be ignored
175 175 merge = bool(request.GET.get('merge'))
176 176 # fulldiff disables cut_off_limit
177 177 c.fulldiff = request.GET.get('fulldiff')
178 178 # partial uses compare_cs.html template directly
179 179 partial = request.environ.get('HTTP_X_PARTIAL_XHR')
180 180 # as_form puts hidden input field with changeset revisions
181 181 c.as_form = partial and request.GET.get('as_form')
182 182 # swap url for compare_diff page - never partial and never as_form
183 183 c.swap_url = h.url('compare_url',
184 184 repo_name=other_repo,
185 185 org_ref_type=other_ref_type, org_ref_name=other_ref_name,
186 186 other_repo=org_repo,
187 187 other_ref_type=org_ref_type, other_ref_name=org_ref_name,
188 188 merge=merge or '')
189 189
190 190 # set callbacks for generating markup for icons
191 191 c.ignorews_url = _ignorews_url
192 192 c.context_url = _context_url
193 193 ignore_whitespace = request.GET.get('ignorews') == '1'
194 194 line_context = request.GET.get('context', 3)
195 195
196 196 org_repo = Repository.get_by_repo_name(org_repo)
197 197 other_repo = Repository.get_by_repo_name(other_repo)
198 198
199 199 if org_repo is None:
200 200 msg = 'Could not find org repo %s' % org_repo
201 201 log.error(msg)
202 202 h.flash(msg, category='error')
203 203 return redirect(url('compare_home', repo_name=c.repo_name))
204 204
205 205 if other_repo is None:
206 206 msg = 'Could not find other repo %s' % other_repo
207 207 log.error(msg)
208 208 h.flash(msg, category='error')
209 209 return redirect(url('compare_home', repo_name=c.repo_name))
210 210
211 211 if org_repo.scm_instance.alias != other_repo.scm_instance.alias:
212 212 msg = 'compare of two different kind of remote repos not available'
213 213 log.error(msg)
214 214 h.flash(msg, category='error')
215 215 return redirect(url('compare_home', repo_name=c.repo_name))
216 216
217 217 c.a_rev = self._get_ref_rev(org_repo, org_ref_type, org_ref_name,
218 218 returnempty=True)
219 219 c.cs_rev = self._get_ref_rev(other_repo, other_ref_type, other_ref_name)
220 220
221 221 c.compare_home = False
222 222 c.a_repo = org_repo
223 223 c.a_ref_name = org_ref_name
224 224 c.a_ref_type = org_ref_type
225 225 c.cs_repo = other_repo
226 226 c.cs_ref_name = other_ref_name
227 227 c.cs_ref_type = other_ref_type
228 228
229 229 c.cs_ranges, c.cs_ranges_org, c.ancestor = self._get_changesets(
230 230 org_repo.scm_instance.alias, org_repo.scm_instance, c.a_rev,
231 231 other_repo.scm_instance, c.cs_rev)
232 232 raw_ids = [x.raw_id for x in c.cs_ranges]
233 233 c.cs_comments = other_repo.get_comments(raw_ids)
234 234 c.statuses = other_repo.statuses(raw_ids)
235 235
236 236 revs = [ctx.revision for ctx in reversed(c.cs_ranges)]
237 237 c.jsdata = json.dumps(graph_data(c.cs_repo.scm_instance, revs))
238 238
239 239 if partial:
240 240 return render('compare/compare_cs.html')
241 241 if merge and c.ancestor:
242 242 # case we want a simple diff without incoming changesets,
243 243 # previewing what will be merged.
244 244 # Make the diff on the other repo (which is known to have other_rev)
245 245 log.debug('Using ancestor %s as rev1 instead of %s'
246 246 % (c.ancestor, c.a_rev))
247 247 rev1 = c.ancestor
248 248 org_repo = other_repo
249 249 else: # comparing tips, not necessarily linearly related
250 250 if merge:
251 251 log.error('Unable to find ancestor revision')
252 252 if org_repo != other_repo:
253 253 # TODO: we could do this by using hg unionrepo
254 254 log.error('cannot compare across repos %s and %s', org_repo, other_repo)
255 255 h.flash(_('Cannot compare repositories without using common ancestor'), category='error')
256 256 raise HTTPBadRequest
257 257 rev1 = c.a_rev
258 258
259 259 diff_limit = self.cut_off_limit if not c.fulldiff else None
260 260
261 261 log.debug('running diff between %s and %s in %s'
262 262 % (rev1, c.cs_rev, org_repo.scm_instance.path))
263 263 txtdiff = org_repo.scm_instance.get_diff(rev1=rev1, rev2=c.cs_rev,
264 264 ignore_whitespace=ignore_whitespace,
265 265 context=line_context)
266 266
267 267 diff_processor = diffs.DiffProcessor(txtdiff or '', format='gitdiff',
268 268 diff_limit=diff_limit)
269 269 _parsed = diff_processor.prepare()
270 270
271 271 c.limited_diff = False
272 272 if isinstance(_parsed, LimitedDiffContainer):
273 273 c.limited_diff = True
274 274
275 275 c.files = []
276 276 c.changes = {}
277 277 c.lines_added = 0
278 278 c.lines_deleted = 0
279 279 for f in _parsed:
280 280 st = f['stats']
281 281 if not st['binary']:
282 282 c.lines_added += st['added']
283 283 c.lines_deleted += st['deleted']
284 284 fid = h.FID('', f['filename'])
285 285 c.files.append([fid, f['operation'], f['filename'], f['stats']])
286 286 htmldiff = diff_processor.as_html(enable_comments=False, parsed_lines=[f])
287 287 c.changes[fid] = [f['operation'], f['filename'], htmldiff]
288 288
289 289 return render('compare/compare_diff.html')
@@ -1,470 +1,470 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 """
15 15 kallithea.lib.hooks
16 16 ~~~~~~~~~~~~~~~~~~~
17 17
18 18 Hooks run by Kallithea
19 19
20 20 This file was forked by the Kallithea project in July 2014.
21 21 Original author and date, and relevant copyright and licensing information is below:
22 22 :created_on: Aug 6, 2010
23 23 :author: marcink
24 24 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 25 :license: GPLv3, see LICENSE.md for more details.
26 26 """
27 27
28 28 import os
29 29 import sys
30 30 import time
31 31 import binascii
32 32
33 33 from kallithea.lib.vcs.utils.hgcompat import nullrev, revrange
34 34 from kallithea.lib import helpers as h
35 35 from kallithea.lib.utils import action_logger
36 36 from kallithea.lib.vcs.backends.base import EmptyChangeset
37 37 from kallithea.lib.exceptions import HTTPLockedRC, UserCreationError
38 38 from kallithea.lib.utils2 import safe_str, _extract_extras
39 39 from kallithea.model.db import Repository, User
40 40
41 41
42 42 def _get_scm_size(alias, root_path):
43 43
44 44 if not alias.startswith('.'):
45 45 alias += '.'
46 46
47 47 size_scm, size_root = 0, 0
48 48 for path, dirs, files in os.walk(safe_str(root_path)):
49 49 if path.find(alias) != -1:
50 50 for f in files:
51 51 try:
52 52 size_scm += os.path.getsize(os.path.join(path, f))
53 53 except OSError:
54 54 pass
55 55 else:
56 56 for f in files:
57 57 try:
58 58 size_root += os.path.getsize(os.path.join(path, f))
59 59 except OSError:
60 60 pass
61 61
62 62 size_scm_f = h.format_byte_size(size_scm)
63 63 size_root_f = h.format_byte_size(size_root)
64 64 size_total_f = h.format_byte_size(size_root + size_scm)
65 65
66 66 return size_scm_f, size_root_f, size_total_f
67 67
68 68
69 69 def repo_size(ui, repo, hooktype=None, **kwargs):
70 70 """
71 71 Presents size of repository after push
72 72
73 73 :param ui:
74 74 :param repo:
75 75 :param hooktype:
76 76 """
77 77
78 78 size_hg_f, size_root_f, size_total_f = _get_scm_size('.hg', repo.root)
79 79
80 80 last_cs = repo[len(repo) - 1]
81 81
82 82 msg = ('Repository size .hg:%s repo:%s total:%s\n'
83 83 'Last revision is now r%s:%s\n') % (
84 84 size_hg_f, size_root_f, size_total_f, last_cs.rev(), last_cs.hex()[:12]
85 85 )
86 86
87 87 sys.stdout.write(msg)
88 88
89 89
90 90 def pre_push(ui, repo, **kwargs):
91 91 # pre push function, currently used to ban pushing when
92 92 # repository is locked
93 93 ex = _extract_extras()
94 94
95 95 usr = User.get_by_username(ex.username)
96 96 if ex.locked_by[0] and usr.user_id != int(ex.locked_by[0]):
97 97 locked_by = User.get(ex.locked_by[0]).username
98 98 # this exception is interpreted in git/hg middlewares and based
99 99 # on that proper return code is server to client
100 100 _http_ret = HTTPLockedRC(ex.repository, locked_by)
101 101 if str(_http_ret.code).startswith('2'):
102 102 #2xx Codes don't raise exceptions
103 103 sys.stdout.write(_http_ret.title)
104 104 else:
105 105 raise _http_ret
106 106
107 107
108 108 def pre_pull(ui, repo, **kwargs):
109 109 # pre push function, currently used to ban pushing when
110 110 # repository is locked
111 111 ex = _extract_extras()
112 112 if ex.locked_by[0]:
113 113 locked_by = User.get(ex.locked_by[0]).username
114 114 # this exception is interpreted in git/hg middlewares and based
115 115 # on that proper return code is server to client
116 116 _http_ret = HTTPLockedRC(ex.repository, locked_by)
117 117 if str(_http_ret.code).startswith('2'):
118 118 #2xx Codes don't raise exceptions
119 119 sys.stdout.write(_http_ret.title)
120 120 else:
121 121 raise _http_ret
122 122
123 123
124 124 def log_pull_action(ui, repo, **kwargs):
125 125 """
126 126 Logs user last pull action
127 127
128 128 :param ui:
129 129 :param repo:
130 130 """
131 131 ex = _extract_extras()
132 132
133 133 user = User.get_by_username(ex.username)
134 134 action = 'pull'
135 135 action_logger(user, action, ex.repository, ex.ip, commit=True)
136 136 # extension hook call
137 137 from kallithea import EXTENSIONS
138 138 callback = getattr(EXTENSIONS, 'PULL_HOOK', None)
139 139 if callable(callback):
140 140 kw = {}
141 141 kw.update(ex)
142 142 callback(**kw)
143 143
144 144 if ex.make_lock is not None and ex.make_lock:
145 145 Repository.lock(Repository.get_by_repo_name(ex.repository), user.user_id)
146 146 #msg = 'Made lock on repo `%s`' % repository
147 147 #sys.stdout.write(msg)
148 148
149 149 if ex.locked_by[0]:
150 150 locked_by = User.get(ex.locked_by[0]).username
151 151 _http_ret = HTTPLockedRC(ex.repository, locked_by)
152 152 if str(_http_ret.code).startswith('2'):
153 153 #2xx Codes don't raise exceptions
154 154 sys.stdout.write(_http_ret.title)
155 155 return 0
156 156
157 157
158 158 def log_push_action(ui, repo, **kwargs):
159 159 """
160 160 Maps user last push action to new changeset id, from mercurial
161 161
162 162 :param ui:
163 163 :param repo: repo object containing the `ui` object
164 164 """
165 165
166 166 ex = _extract_extras()
167 167
168 168 action_tmpl = ex.action + ':%s'
169 169 revs = []
170 170 if ex.scm == 'hg':
171 171 node = kwargs['node']
172 172
173 173 def get_revs(repo, rev_opt):
174 174 if rev_opt:
175 175 revs = revrange(repo, rev_opt)
176 176
177 177 if len(revs) == 0:
178 178 return (nullrev, nullrev)
179 179 return max(revs), min(revs)
180 180 else:
181 181 return len(repo) - 1, 0
182 182
183 183 stop, start = get_revs(repo, [node + ':'])
184 184 _h = binascii.hexlify
185 185 revs = [_h(repo[r].node()) for r in xrange(start, stop + 1)]
186 186 elif ex.scm == 'git':
187 187 revs = kwargs.get('_git_revs', [])
188 188 if '_git_revs' in kwargs:
189 189 kwargs.pop('_git_revs')
190 190
191 191 action = action_tmpl % ','.join(revs)
192 192 action_logger(ex.username, action, ex.repository, ex.ip, commit=True)
193 193
194 194 # extension hook call
195 195 from kallithea import EXTENSIONS
196 196 callback = getattr(EXTENSIONS, 'PUSH_HOOK', None)
197 197 if callable(callback):
198 198 kw = {'pushed_revs': revs}
199 199 kw.update(ex)
200 200 callback(**kw)
201 201
202 202 if ex.make_lock is not None and not ex.make_lock:
203 203 Repository.unlock(Repository.get_by_repo_name(ex.repository))
204 204 msg = 'Released lock on repo `%s`\n' % ex.repository
205 205 sys.stdout.write(msg)
206 206
207 207 if ex.locked_by[0]:
208 208 locked_by = User.get(ex.locked_by[0]).username
209 209 _http_ret = HTTPLockedRC(ex.repository, locked_by)
210 210 if str(_http_ret.code).startswith('2'):
211 211 #2xx Codes don't raise exceptions
212 212 sys.stdout.write(_http_ret.title)
213 213
214 214 return 0
215 215
216 216
217 217 def log_create_repository(repository_dict, created_by, **kwargs):
218 218 """
219 219 Post create repository Hook.
220 220
221 221 :param repository: dict dump of repository object
222 222 :param created_by: username who created repository
223 223
224 224 available keys of repository_dict:
225 225
226 226 'repo_type',
227 227 'description',
228 228 'private',
229 229 'created_on',
230 230 'enable_downloads',
231 231 'repo_id',
232 232 'user_id',
233 233 'enable_statistics',
234 234 'clone_uri',
235 235 'fork_id',
236 236 'group_id',
237 237 'repo_name'
238 238
239 239 """
240 240 from kallithea import EXTENSIONS
241 241 callback = getattr(EXTENSIONS, 'CREATE_REPO_HOOK', None)
242 242 if callable(callback):
243 243 kw = {}
244 244 kw.update(repository_dict)
245 245 kw.update({'created_by': created_by})
246 246 kw.update(kwargs)
247 247 return callback(**kw)
248 248
249 249 return 0
250 250
251 251
252 252 def check_allowed_create_user(user_dict, created_by, **kwargs):
253 253 # pre create hooks
254 254 from kallithea import EXTENSIONS
255 255 callback = getattr(EXTENSIONS, 'PRE_CREATE_USER_HOOK', None)
256 256 if callable(callback):
257 257 allowed, reason = callback(created_by=created_by, **user_dict)
258 258 if not allowed:
259 259 raise UserCreationError(reason)
260 260
261 261
262 262 def log_create_user(user_dict, created_by, **kwargs):
263 263 """
264 264 Post create user Hook.
265 265
266 266 :param user_dict: dict dump of user object
267 267
268 268 available keys for user_dict:
269 269
270 270 'username',
271 271 'full_name_or_username',
272 272 'full_contact',
273 273 'user_id',
274 274 'name',
275 275 'firstname',
276 276 'short_contact',
277 277 'admin',
278 278 'lastname',
279 279 'ip_addresses',
280 280 'ldap_dn',
281 281 'email',
282 282 'api_key',
283 283 'last_login',
284 284 'full_name',
285 285 'active',
286 286 'password',
287 287 'emails',
288 288 'inherit_default_permissions'
289 289
290 290 """
291 291 from kallithea import EXTENSIONS
292 292 callback = getattr(EXTENSIONS, 'CREATE_USER_HOOK', None)
293 293 if callable(callback):
294 294 return callback(created_by=created_by, **user_dict)
295 295
296 296 return 0
297 297
298 298
299 299 def log_delete_repository(repository_dict, deleted_by, **kwargs):
300 300 """
301 301 Post delete repository Hook.
302 302
303 303 :param repository: dict dump of repository object
304 304 :param deleted_by: username who deleted the repository
305 305
306 306 available keys of repository_dict:
307 307
308 308 'repo_type',
309 309 'description',
310 310 'private',
311 311 'created_on',
312 312 'enable_downloads',
313 313 'repo_id',
314 314 'user_id',
315 315 'enable_statistics',
316 316 'clone_uri',
317 317 'fork_id',
318 318 'group_id',
319 319 'repo_name'
320 320
321 321 """
322 322 from kallithea import EXTENSIONS
323 323 callback = getattr(EXTENSIONS, 'DELETE_REPO_HOOK', None)
324 324 if callable(callback):
325 325 kw = {}
326 326 kw.update(repository_dict)
327 327 kw.update({'deleted_by': deleted_by,
328 328 'deleted_on': time.time()})
329 329 kw.update(kwargs)
330 330 return callback(**kw)
331 331
332 332 return 0
333 333
334 334
335 335 def log_delete_user(user_dict, deleted_by, **kwargs):
336 336 """
337 337 Post delete user Hook.
338 338
339 339 :param user_dict: dict dump of user object
340 340
341 341 available keys for user_dict:
342 342
343 343 'username',
344 344 'full_name_or_username',
345 345 'full_contact',
346 346 'user_id',
347 347 'name',
348 348 'firstname',
349 349 'short_contact',
350 350 'admin',
351 351 'lastname',
352 352 'ip_addresses',
353 353 'ldap_dn',
354 354 'email',
355 355 'api_key',
356 356 'last_login',
357 357 'full_name',
358 358 'active',
359 359 'password',
360 360 'emails',
361 361 'inherit_default_permissions'
362 362
363 363 """
364 364 from kallithea import EXTENSIONS
365 365 callback = getattr(EXTENSIONS, 'DELETE_USER_HOOK', None)
366 366 if callable(callback):
367 367 return callback(deleted_by=deleted_by, **user_dict)
368 368
369 369 return 0
370 370
371 371
372 372 handle_git_pre_receive = (lambda repo_path, revs, env:
373 373 handle_git_receive(repo_path, revs, env, hook_type='pre'))
374 374 handle_git_post_receive = (lambda repo_path, revs, env:
375 375 handle_git_receive(repo_path, revs, env, hook_type='post'))
376 376
377 377
378 378 def handle_git_receive(repo_path, revs, env, hook_type='post'):
379 379 """
380 380 A really hacky method that is run by git post-receive hook and logs
381 381 an push action together with pushed revisions. It's executed by subprocess
382 382 thus needs all info to be able to create a on the fly pylons environment,
383 383 connect to database and run the logging code. Hacky as sh*t but works.
384 384
385 385 :param repo_path:
386 386 :param revs:
387 387 :param env:
388 388 """
389 389 from paste.deploy import appconfig
390 390 from sqlalchemy import engine_from_config
391 391 from kallithea.config.environment import load_environment
392 392 from kallithea.model import init_model
393 393 from kallithea.model.db import Ui
394 394 from kallithea.lib.utils import make_ui
395 395 extras = _extract_extras(env)
396 396
397 397 path, ini_name = os.path.split(extras['config'])
398 398 conf = appconfig('config:%s' % ini_name, relative_to=path)
399 399 load_environment(conf.global_conf, conf.local_conf, test_env=False,
400 400 test_index=False)
401 401
402 402 engine = engine_from_config(conf, 'sqlalchemy.db1.')
403 403 init_model(engine)
404 404
405 405 baseui = make_ui('db')
406 406 # fix if it's not a bare repo
407 407 if repo_path.endswith(os.sep + '.git'):
408 408 repo_path = repo_path[:-5]
409 409
410 410 repo = Repository.get_by_full_path(repo_path)
411 411 if not repo:
412 412 raise OSError('Repository %s not found in database'
413 413 % (safe_str(repo_path)))
414 414
415 415 _hooks = dict(baseui.configitems('hooks')) or {}
416 416
417 417 if hook_type == 'pre':
418 418 repo = repo.scm_instance
419 419 else:
420 420 #post push shouldn't use the cached instance never
421 421 repo = repo.scm_instance_no_cache()
422 422
423 423 if hook_type == 'pre':
424 424 pre_push(baseui, repo)
425 425
426 426 # if push hook is enabled via web interface
427 427 elif hook_type == 'post' and _hooks.get(Ui.HOOK_PUSH):
428 428 rev_data = []
429 429 for l in revs:
430 430 old_rev, new_rev, ref = l.split(' ')
431 431 _ref_data = ref.split('/')
432 432 if _ref_data[1] in ['tags', 'heads']:
433 433 rev_data.append({'old_rev': old_rev,
434 434 'new_rev': new_rev,
435 435 'ref': ref,
436 436 'type': _ref_data[1],
437 437 'name': _ref_data[2].strip()})
438 438
439 439 git_revs = []
440 440
441 441 for push_ref in rev_data:
442 442 _type = push_ref['type']
443 443 if _type == 'heads':
444 444 if push_ref['old_rev'] == EmptyChangeset().raw_id:
445 445 # update the symbolic ref if we push new repo
446 446 if repo.is_empty():
447 447 repo._repo.refs.set_symbolic_ref('HEAD',
448 448 'refs/heads/%s' % push_ref['name'])
449 449
450 cmd = "for-each-ref --format='%(refname)' 'refs/heads/*'"
450 cmd = ['for-each-ref', '--format=%(refname)','refs/heads/*']
451 451 heads = repo.run_git_command(cmd)[0]
452 cmd = ['log', push_ref['new_rev'],
453 '--reverse', '--pretty=format:%H', '--not']
452 454 heads = heads.replace(push_ref['ref'], '')
453 heads = ' '.join(map(lambda c: c.strip('\n').strip(),
454 heads.splitlines()))
455 cmd = (('log %(new_rev)s' % push_ref) +
456 ' --reverse --pretty=format:"%H" --not ' + heads)
455 for l in heads.splitlines():
456 cmd.append(l.strip())
457 457 git_revs += repo.run_git_command(cmd)[0].splitlines()
458 458
459 459 elif push_ref['new_rev'] == EmptyChangeset().raw_id:
460 460 #delete branch case
461 461 git_revs += ['delete_branch=>%s' % push_ref['name']]
462 462 else:
463 cmd = (('log %(old_rev)s..%(new_rev)s' % push_ref) +
464 ' --reverse --pretty=format:"%H"')
463 cmd = ['log', '%(old_rev)s..%(new_rev)s' % push_ref,
464 '--reverse', '--pretty=format:%H']
465 465 git_revs += repo.run_git_command(cmd)[0].splitlines()
466 466
467 467 elif _type == 'tags':
468 468 git_revs += ['tag=>%s' % push_ref['name']]
469 469
470 470 log_push_action(baseui, repo, _git_revs=git_revs)
@@ -1,202 +1,197 b''
1 1 import os
2 2 import socket
3 3 import logging
4 4 import traceback
5 5
6 6 from webob import Request, Response, exc
7 7
8 8 import kallithea
9 9 from kallithea.lib.vcs import subprocessio
10 10
11 11 log = logging.getLogger(__name__)
12 12
13 13
14 14 class FileWrapper(object):
15 15
16 16 def __init__(self, fd, content_length):
17 17 self.fd = fd
18 18 self.content_length = content_length
19 19 self.remain = content_length
20 20
21 21 def read(self, size):
22 22 if size <= self.remain:
23 23 try:
24 24 data = self.fd.read(size)
25 25 except socket.error:
26 26 raise IOError(self)
27 27 self.remain -= size
28 28 elif self.remain:
29 29 data = self.fd.read(self.remain)
30 30 self.remain = 0
31 31 else:
32 32 data = None
33 33 return data
34 34
35 35 def __repr__(self):
36 36 return '<FileWrapper %s len: %s, read: %s>' % (
37 37 self.fd, self.content_length, self.content_length - self.remain
38 38 )
39 39
40 40
41 41 class GitRepository(object):
42 42 git_folder_signature = set(['config', 'head', 'info', 'objects', 'refs'])
43 43 commands = ['git-upload-pack', 'git-receive-pack']
44 44
45 45 def __init__(self, repo_name, content_path, extras):
46 46 files = set([f.lower() for f in os.listdir(content_path)])
47 47 if not (self.git_folder_signature.intersection(files)
48 48 == self.git_folder_signature):
49 49 raise OSError('%s missing git signature' % content_path)
50 50 self.content_path = content_path
51 51 self.valid_accepts = ['application/x-%s-result' %
52 52 c for c in self.commands]
53 53 self.repo_name = repo_name
54 54 self.extras = extras
55 55
56 56 def _get_fixedpath(self, path):
57 57 """
58 58 Small fix for repo_path
59 59
60 60 :param path:
61 61 """
62 62 return path.split(self.repo_name, 1)[-1].strip('/')
63 63
64 64 def inforefs(self, request, environ):
65 65 """
66 66 WSGI Response producer for HTTP GET Git Smart
67 67 HTTP /info/refs request.
68 68 """
69 69
70 70 git_command = request.GET.get('service')
71 71 if git_command not in self.commands:
72 72 log.debug('command %s not allowed' % git_command)
73 73 return exc.HTTPMethodNotAllowed()
74 74
75 75 # note to self:
76 76 # please, resist the urge to add '\n' to git capture and increment
77 77 # line count by 1.
78 78 # The code in Git client not only does NOT need '\n', but actually
79 79 # blows up if you sprinkle "flush" (0000) as "0001\n".
80 80 # It reads binary, per number of bytes specified.
81 81 # if you do add '\n' as part of data, count it.
82 82 server_advert = '# service=%s' % git_command
83 83 packet_len = str(hex(len(server_advert) + 4)[2:].rjust(4, '0')).lower()
84 84 _git_path = kallithea.CONFIG.get('git_path', 'git')
85 cmd = [_git_path, git_command[4:],
86 '--stateless-rpc', '--advertise-refs', self.content_path]
87 log.debug('handling cmd %s', cmd)
85 88 try:
86 out = subprocessio.SubprocessIOChunker(
87 r'%s %s --stateless-rpc --advertise-refs "%s"' % (
88 _git_path, git_command[4:], self.content_path),
89 starting_values=[
90 packet_len + server_advert + '0000'
91 ]
89 out = subprocessio.SubprocessIOChunker(cmd,
90 starting_values=[packet_len + server_advert + '0000']
92 91 )
93 92 except EnvironmentError, e:
94 93 log.error(traceback.format_exc())
95 94 raise exc.HTTPExpectationFailed()
96 95 resp = Response()
97 96 resp.content_type = 'application/x-%s-advertisement' % str(git_command)
98 97 resp.charset = None
99 98 resp.app_iter = out
100 99 return resp
101 100
102 101 def backend(self, request, environ):
103 102 """
104 103 WSGI Response producer for HTTP POST Git Smart HTTP requests.
105 104 Reads commands and data from HTTP POST's body.
106 105 returns an iterator obj with contents of git command's
107 106 response to stdout
108 107 """
109 108 _git_path = kallithea.CONFIG.get('git_path', 'git')
110 109 git_command = self._get_fixedpath(request.path_info)
111 110 if git_command not in self.commands:
112 111 log.debug('command %s not allowed' % git_command)
113 112 return exc.HTTPMethodNotAllowed()
114 113
115 114 if 'CONTENT_LENGTH' in environ:
116 115 inputstream = FileWrapper(environ['wsgi.input'],
117 116 request.content_length)
118 117 else:
119 118 inputstream = environ['wsgi.input']
120 119
121 try:
122 gitenv = os.environ
120 gitenv = dict(os.environ)
123 121 # forget all configs
124 122 gitenv['GIT_CONFIG_NOGLOBAL'] = '1'
125 opts = dict(
126 env=gitenv,
127 cwd=self.content_path,
128 )
129 cmd = r'%s %s --stateless-rpc "%s"' % (_git_path, git_command[4:],
130 self.content_path),
131 log.debug('handling cmd %s' % cmd)
123 cmd = [_git_path, git_command[4:], '--stateless-rpc', self.content_path]
124 log.debug('handling cmd %s', cmd)
125 try:
132 126 out = subprocessio.SubprocessIOChunker(
133 127 cmd,
134 128 inputstream=inputstream,
135 **opts
129 env=gitenv,
130 cwd=self.content_path,
136 131 )
137 132 except EnvironmentError, e:
138 133 log.error(traceback.format_exc())
139 134 raise exc.HTTPExpectationFailed()
140 135
141 136 if git_command in [u'git-receive-pack']:
142 137 # updating refs manually after each push.
143 138 # Needed for pre-1.7.0.4 git clients using regular HTTP mode.
144 139 from kallithea.lib.vcs import get_repo
145 140 from dulwich.server import update_server_info
146 141 repo = get_repo(self.content_path)
147 142 if repo:
148 143 update_server_info(repo._repo)
149 144
150 145 resp = Response()
151 146 resp.content_type = 'application/x-%s-result' % git_command.encode('utf8')
152 147 resp.charset = None
153 148 resp.app_iter = out
154 149 return resp
155 150
156 151 def __call__(self, environ, start_response):
157 152 request = Request(environ)
158 153 _path = self._get_fixedpath(request.path_info)
159 154 if _path.startswith('info/refs'):
160 155 app = self.inforefs
161 156 elif [a for a in self.valid_accepts if a in request.accept]:
162 157 app = self.backend
163 158 try:
164 159 resp = app(request, environ)
165 160 except exc.HTTPException, e:
166 161 resp = e
167 162 log.error(traceback.format_exc())
168 163 except Exception, e:
169 164 log.error(traceback.format_exc())
170 165 resp = exc.HTTPInternalServerError()
171 166 return resp(environ, start_response)
172 167
173 168
174 169 class GitDirectory(object):
175 170
176 171 def __init__(self, repo_root, repo_name, extras):
177 172 repo_location = os.path.join(repo_root, repo_name)
178 173 if not os.path.isdir(repo_location):
179 174 raise OSError(repo_location)
180 175
181 176 self.content_path = repo_location
182 177 self.repo_name = repo_name
183 178 self.repo_location = repo_location
184 179 self.extras = extras
185 180
186 181 def __call__(self, environ, start_response):
187 182 content_path = self.content_path
188 183 try:
189 184 app = GitRepository(self.repo_name, content_path, self.extras)
190 185 except (AssertionError, OSError):
191 186 content_path = os.path.join(content_path, '.git')
192 187 if os.path.isdir(content_path):
193 188 app = GitRepository(self.repo_name, content_path, self.extras)
194 189 else:
195 190 return exc.HTTPNotFound()(environ, start_response)
196 191 return app(environ, start_response)
197 192
198 193
199 194 def make_wsgi_app(repo_name, repo_root, extras):
200 195 from dulwich.web import LimitedInputFilter, GunzipFilter
201 196 app = GitDirectory(repo_root, repo_name, extras)
202 197 return GunzipFilter(LimitedInputFilter(app))
@@ -1,872 +1,872 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 """
15 15 kallithea.lib.utils
16 16 ~~~~~~~~~~~~~~~~~~~
17 17
18 18 Utilities library for Kallithea
19 19
20 20 This file was forked by the Kallithea project in July 2014.
21 21 Original author and date, and relevant copyright and licensing information is below:
22 22 :created_on: Apr 18, 2010
23 23 :author: marcink
24 24 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 25 :license: GPLv3, see LICENSE.md for more details.
26 26 """
27 27
28 28 import os
29 29 import re
30 30 import logging
31 31 import datetime
32 32 import traceback
33 33 import paste
34 34 import beaker
35 35 import tarfile
36 36 import shutil
37 37 import decorator
38 38 import warnings
39 39 from os.path import abspath
40 40 from os.path import dirname as dn, join as jn
41 41
42 42 from paste.script.command import Command, BadCommand
43 43
44 44 from webhelpers.text import collapse, remove_formatting, strip_tags
45 45 from beaker.cache import _cache_decorate
46 46
47 47 from kallithea import BRAND
48 48
49 49 from kallithea.lib.vcs.utils.hgcompat import ui, config
50 50 from kallithea.lib.vcs.utils.helpers import get_scm
51 51 from kallithea.lib.vcs.exceptions import VCSError
52 52
53 53 from kallithea.lib.caching_query import FromCache
54 54
55 55 from kallithea.model import meta
56 56 from kallithea.model.db import Repository, User, Ui, \
57 57 UserLog, RepoGroup, Setting, CacheInvalidation, UserGroup
58 58 from kallithea.model.meta import Session
59 59 from kallithea.model.repo_group import RepoGroupModel
60 60 from kallithea.lib.utils2 import safe_str, safe_unicode, get_current_authuser
61 61 from kallithea.lib.vcs.utils.fakemod import create_module
62 62
63 63 log = logging.getLogger(__name__)
64 64
65 65 REMOVED_REPO_PAT = re.compile(r'rm__\d{8}_\d{6}_\d{6}_.*')
66 66
67 67
68 68 def recursive_replace(str_, replace=' '):
69 69 """
70 70 Recursive replace of given sign to just one instance
71 71
72 72 :param str_: given string
73 73 :param replace: char to find and replace multiple instances
74 74
75 75 Examples::
76 76 >>> recursive_replace("Mighty---Mighty-Bo--sstones",'-')
77 77 'Mighty-Mighty-Bo-sstones'
78 78 """
79 79
80 80 if str_.find(replace * 2) == -1:
81 81 return str_
82 82 else:
83 83 str_ = str_.replace(replace * 2, replace)
84 84 return recursive_replace(str_, replace)
85 85
86 86
87 87 def repo_name_slug(value):
88 88 """
89 89 Return slug of name of repository
90 90 This function is called on each creation/modification
91 91 of repository to prevent bad names in repo
92 92 """
93 93
94 94 slug = remove_formatting(value)
95 95 slug = strip_tags(slug)
96 96
97 97 for c in """`?=[]\;'"<>,/~!@#$%^&*()+{}|: """:
98 98 slug = slug.replace(c, '-')
99 99 slug = recursive_replace(slug, '-')
100 100 slug = collapse(slug, '-')
101 101 return slug
102 102
103 103
104 104 #==============================================================================
105 105 # PERM DECORATOR HELPERS FOR EXTRACTING NAMES FOR PERM CHECKS
106 106 #==============================================================================
107 107 def get_repo_slug(request):
108 108 _repo = request.environ['pylons.routes_dict'].get('repo_name')
109 109 if _repo:
110 110 _repo = _repo.rstrip('/')
111 111 return _repo
112 112
113 113
114 114 def get_repo_group_slug(request):
115 115 _group = request.environ['pylons.routes_dict'].get('group_name')
116 116 if _group:
117 117 _group = _group.rstrip('/')
118 118 return _group
119 119
120 120
121 121 def get_user_group_slug(request):
122 122 _group = request.environ['pylons.routes_dict'].get('id')
123 123 _group = UserGroup.get(_group)
124 124 if _group:
125 125 return _group.users_group_name
126 126 return None
127 127
128 128
129 129 def _extract_id_from_repo_name(repo_name):
130 130 if repo_name.startswith('/'):
131 131 repo_name = repo_name.lstrip('/')
132 132 by_id_match = re.match(r'^_(\d{1,})', repo_name)
133 133 if by_id_match:
134 134 return by_id_match.groups()[0]
135 135
136 136
137 137 def get_repo_by_id(repo_name):
138 138 """
139 139 Extracts repo_name by id from special urls. Example url is _11/repo_name
140 140
141 141 :param repo_name:
142 142 :return: repo_name if matched else None
143 143 """
144 144 _repo_id = _extract_id_from_repo_name(repo_name)
145 145 if _repo_id:
146 146 from kallithea.model.db import Repository
147 147 repo = Repository.get(_repo_id)
148 148 if repo:
149 149 # TODO: return repo instead of reponame? or would that be a layering violation?
150 150 return repo.repo_name
151 151 return None
152 152
153 153
154 154 def action_logger(user, action, repo, ipaddr='', sa=None, commit=False):
155 155 """
156 156 Action logger for various actions made by users
157 157
158 158 :param user: user that made this action, can be a unique username string or
159 159 object containing user_id attribute
160 160 :param action: action to log, should be on of predefined unique actions for
161 161 easy translations
162 162 :param repo: string name of repository or object containing repo_id,
163 163 that action was made on
164 164 :param ipaddr: optional IP address from what the action was made
165 165 :param sa: optional sqlalchemy session
166 166
167 167 """
168 168
169 169 if not sa:
170 170 sa = meta.Session()
171 171 # if we don't get explicit IP address try to get one from registered user
172 172 # in tmpl context var
173 173 if not ipaddr:
174 174 ipaddr = getattr(get_current_authuser(), 'ip_addr', '')
175 175
176 176 if getattr(user, 'user_id', None):
177 177 user_obj = User.get(user.user_id)
178 178 elif isinstance(user, basestring):
179 179 user_obj = User.get_by_username(user)
180 180 else:
181 181 raise Exception('You have to provide a user object or a username')
182 182
183 183 if getattr(repo, 'repo_id', None):
184 184 repo_obj = Repository.get(repo.repo_id)
185 185 repo_name = repo_obj.repo_name
186 186 elif isinstance(repo, basestring):
187 187 repo_name = repo.lstrip('/')
188 188 repo_obj = Repository.get_by_repo_name(repo_name)
189 189 else:
190 190 repo_obj = None
191 191 repo_name = ''
192 192
193 193 user_log = UserLog()
194 194 user_log.user_id = user_obj.user_id
195 195 user_log.username = user_obj.username
196 196 user_log.action = safe_unicode(action)
197 197
198 198 user_log.repository = repo_obj
199 199 user_log.repository_name = repo_name
200 200
201 201 user_log.action_date = datetime.datetime.now()
202 202 user_log.user_ip = ipaddr
203 203 sa.add(user_log)
204 204
205 205 log.info('Logging action:%s on %s by user:%s ip:%s' %
206 206 (action, safe_unicode(repo), user_obj, ipaddr))
207 207 if commit:
208 208 sa.commit()
209 209
210 210
211 211 def get_filesystem_repos(path, recursive=False, skip_removed_repos=True):
212 212 """
213 213 Scans given path for repos and return (name,(type,path)) tuple
214 214
215 215 :param path: path to scan for repositories
216 216 :param recursive: recursive search and return names with subdirs in front
217 217 """
218 218
219 219 # remove ending slash for better results
220 220 path = path.rstrip(os.sep)
221 221 log.debug('now scanning in %s location recursive:%s...' % (path, recursive))
222 222
223 223 def _get_repos(p):
224 224 if not os.access(p, os.R_OK) or not os.access(p, os.X_OK):
225 225 log.warning('ignoring repo path without access: %s' % (p,))
226 226 return
227 227 if not os.access(p, os.W_OK):
228 228 log.warning('repo path without write access: %s' % (p,))
229 229 for dirpath in os.listdir(p):
230 230 if os.path.isfile(os.path.join(p, dirpath)):
231 231 continue
232 232 cur_path = os.path.join(p, dirpath)
233 233
234 234 # skip removed repos
235 235 if skip_removed_repos and REMOVED_REPO_PAT.match(dirpath):
236 236 continue
237 237
238 238 #skip .<somethin> dirs
239 239 if dirpath.startswith('.'):
240 240 continue
241 241
242 242 try:
243 243 scm_info = get_scm(cur_path)
244 244 yield scm_info[1].split(path, 1)[-1].lstrip(os.sep), scm_info
245 245 except VCSError:
246 246 if not recursive:
247 247 continue
248 248 #check if this dir containts other repos for recursive scan
249 249 rec_path = os.path.join(p, dirpath)
250 250 if not os.path.islink(rec_path) and os.path.isdir(rec_path):
251 251 for inner_scm in _get_repos(rec_path):
252 252 yield inner_scm
253 253
254 254 return _get_repos(path)
255 255
256 256
257 257 def is_valid_repo(repo_name, base_path, scm=None):
258 258 """
259 259 Returns True if given path is a valid repository False otherwise.
260 260 If scm param is given also compare if given scm is the same as expected
261 261 from scm parameter
262 262
263 263 :param repo_name:
264 264 :param base_path:
265 265 :param scm:
266 266
267 267 :return True: if given path is a valid repository
268 268 """
269 269 full_path = os.path.join(safe_str(base_path), safe_str(repo_name))
270 270
271 271 try:
272 272 scm_ = get_scm(full_path)
273 273 if scm:
274 274 return scm_[0] == scm
275 275 return True
276 276 except VCSError:
277 277 return False
278 278
279 279
280 280 def is_valid_repo_group(repo_group_name, base_path, skip_path_check=False):
281 281 """
282 282 Returns True if given path is a repository group False otherwise
283 283
284 284 :param repo_name:
285 285 :param base_path:
286 286 """
287 287 full_path = os.path.join(safe_str(base_path), safe_str(repo_group_name))
288 288
289 289 # check if it's not a repo
290 290 if is_valid_repo(repo_group_name, base_path):
291 291 return False
292 292
293 293 try:
294 294 # we need to check bare git repos at higher level
295 295 # since we might match branches/hooks/info/objects or possible
296 296 # other things inside bare git repo
297 297 get_scm(os.path.dirname(full_path))
298 298 return False
299 299 except VCSError:
300 300 pass
301 301
302 302 # check if it's a valid path
303 303 if skip_path_check or os.path.isdir(full_path):
304 304 return True
305 305
306 306 return False
307 307
308 308
309 309 def ask_ok(prompt, retries=4, complaint='Yes or no please!'):
310 310 while True:
311 311 ok = raw_input(prompt)
312 312 if ok in ('y', 'ye', 'yes'):
313 313 return True
314 314 if ok in ('n', 'no', 'nop', 'nope'):
315 315 return False
316 316 retries = retries - 1
317 317 if retries < 0:
318 318 raise IOError
319 319 print complaint
320 320
321 321 #propagated from mercurial documentation
322 322 ui_sections = ['alias', 'auth',
323 323 'decode/encode', 'defaults',
324 324 'diff', 'email',
325 325 'extensions', 'format',
326 326 'merge-patterns', 'merge-tools',
327 327 'hooks', 'http_proxy',
328 328 'smtp', 'patch',
329 329 'paths', 'profiling',
330 330 'server', 'trusted',
331 331 'ui', 'web', ]
332 332
333 333
334 334 def make_ui(read_from='file', path=None, checkpaths=True, clear_session=True):
335 335 """
336 336 A function that will read python rc files or database
337 337 and make an mercurial ui object from read options
338 338
339 339 :param path: path to mercurial config file
340 340 :param checkpaths: check the path
341 341 :param read_from: read from 'file' or 'db'
342 342 """
343 343
344 344 baseui = ui.ui()
345 345
346 346 # clean the baseui object
347 347 baseui._ocfg = config.config()
348 348 baseui._ucfg = config.config()
349 349 baseui._tcfg = config.config()
350 350
351 351 if read_from == 'file':
352 352 if not os.path.isfile(path):
353 353 log.debug('hgrc file is not present at %s, skipping...' % path)
354 354 return False
355 355 log.debug('reading hgrc from %s' % path)
356 356 cfg = config.config()
357 357 cfg.read(path)
358 358 for section in ui_sections:
359 359 for k, v in cfg.items(section):
360 360 log.debug('settings ui from file: [%s] %s=%s' % (section, k, v))
361 361 baseui.setconfig(safe_str(section), safe_str(k), safe_str(v))
362 362
363 363 elif read_from == 'db':
364 364 sa = meta.Session()
365 365 ret = sa.query(Ui).all()
366 366
367 367 hg_ui = ret
368 368 for ui_ in hg_ui:
369 369 if ui_.ui_active:
370 370 ui_val = safe_str(ui_.ui_value)
371 371 if ui_.ui_section == 'hooks' and BRAND != 'kallithea' and ui_val.startswith('python:' + BRAND + '.lib.hooks.'):
372 372 ui_val = ui_val.replace('python:' + BRAND + '.lib.hooks.', 'python:kallithea.lib.hooks.')
373 373 log.debug('settings ui from db: [%s] %s=%s', ui_.ui_section,
374 374 ui_.ui_key, ui_val)
375 375 baseui.setconfig(safe_str(ui_.ui_section), safe_str(ui_.ui_key),
376 376 ui_val)
377 377 if ui_.ui_key == 'push_ssl':
378 378 # force set push_ssl requirement to False, kallithea
379 379 # handles that
380 380 baseui.setconfig(safe_str(ui_.ui_section), safe_str(ui_.ui_key),
381 381 False)
382 382 if clear_session:
383 383 meta.Session.remove()
384 384
385 385 # prevent interactive questions for ssh password / passphrase
386 386 ssh = baseui.config('ui', 'ssh', default='ssh')
387 387 baseui.setconfig('ui', 'ssh', '%s -oBatchMode=yes -oIdentitiesOnly=yes' % ssh)
388 388
389 389 return baseui
390 390
391 391
392 392 def set_app_settings(config):
393 393 """
394 394 Updates pylons config with new settings from database
395 395
396 396 :param config:
397 397 """
398 398 hgsettings = Setting.get_app_settings()
399 399
400 400 for k, v in hgsettings.items():
401 401 config[k] = v
402 402
403 403
404 404 def set_vcs_config(config):
405 405 """
406 406 Patch VCS config with some Kallithea specific stuff
407 407
408 408 :param config: kallithea.CONFIG
409 409 """
410 410 import kallithea
411 411 from kallithea.lib.vcs import conf
412 412 from kallithea.lib.utils2 import aslist
413 413 conf.settings.BACKENDS = {
414 414 'hg': 'kallithea.lib.vcs.backends.hg.MercurialRepository',
415 415 'git': 'kallithea.lib.vcs.backends.git.GitRepository',
416 416 }
417 417
418 418 conf.settings.GIT_EXECUTABLE_PATH = config.get('git_path', 'git')
419 419 conf.settings.GIT_REV_FILTER = config.get('git_rev_filter', '--all').strip()
420 420 conf.settings.DEFAULT_ENCODINGS = aslist(config.get('default_encoding',
421 421 'utf8'), sep=',')
422 422
423 423
424 424 def map_groups(path):
425 425 """
426 426 Given a full path to a repository, create all nested groups that this
427 427 repo is inside. This function creates parent-child relationships between
428 428 groups and creates default perms for all new groups.
429 429
430 430 :param paths: full path to repository
431 431 """
432 432 sa = meta.Session()
433 433 groups = path.split(Repository.url_sep())
434 434 parent = None
435 435 group = None
436 436
437 437 # last element is repo in nested groups structure
438 438 groups = groups[:-1]
439 439 rgm = RepoGroupModel(sa)
440 440 owner = User.get_first_admin()
441 441 for lvl, group_name in enumerate(groups):
442 442 group_name = '/'.join(groups[:lvl] + [group_name])
443 443 group = RepoGroup.get_by_group_name(group_name)
444 444 desc = '%s group' % group_name
445 445
446 446 # skip folders that are now removed repos
447 447 if REMOVED_REPO_PAT.match(group_name):
448 448 break
449 449
450 450 if group is None:
451 451 log.debug('creating group level: %s group_name: %s'
452 452 % (lvl, group_name))
453 453 group = RepoGroup(group_name, parent)
454 454 group.group_description = desc
455 455 group.user = owner
456 456 sa.add(group)
457 457 perm_obj = rgm._create_default_perms(group)
458 458 sa.add(perm_obj)
459 459 sa.flush()
460 460
461 461 parent = group
462 462 return group
463 463
464 464
465 465 def repo2db_mapper(initial_repo_list, remove_obsolete=False,
466 466 install_git_hook=False, user=None):
467 467 """
468 468 maps all repos given in initial_repo_list, non existing repositories
469 469 are created, if remove_obsolete is True it also check for db entries
470 470 that are not in initial_repo_list and removes them.
471 471
472 472 :param initial_repo_list: list of repositories found by scanning methods
473 473 :param remove_obsolete: check for obsolete entries in database
474 474 :param install_git_hook: if this is True, also check and install githook
475 475 for a repo if missing
476 476 """
477 477 from kallithea.model.repo import RepoModel
478 478 from kallithea.model.scm import ScmModel
479 479 sa = meta.Session()
480 480 repo_model = RepoModel()
481 481 if user is None:
482 482 user = User.get_first_admin()
483 483 added = []
484 484
485 485 ##creation defaults
486 486 defs = Setting.get_default_repo_settings(strip_prefix=True)
487 487 enable_statistics = defs.get('repo_enable_statistics')
488 488 enable_locking = defs.get('repo_enable_locking')
489 489 enable_downloads = defs.get('repo_enable_downloads')
490 490 private = defs.get('repo_private')
491 491
492 492 for name, repo in initial_repo_list.items():
493 493 group = map_groups(name)
494 494 unicode_name = safe_unicode(name)
495 495 db_repo = repo_model.get_by_repo_name(unicode_name)
496 496 # found repo that is on filesystem not in Kallithea database
497 497 if not db_repo:
498 498 log.info('repository %s not found, creating now' % name)
499 499 added.append(name)
500 500 desc = (repo.description
501 501 if repo.description != 'unknown'
502 502 else '%s repository' % name)
503 503
504 504 new_repo = repo_model._create_repo(
505 505 repo_name=name,
506 506 repo_type=repo.alias,
507 507 description=desc,
508 508 repo_group=getattr(group, 'group_id', None),
509 509 owner=user,
510 510 enable_locking=enable_locking,
511 511 enable_downloads=enable_downloads,
512 512 enable_statistics=enable_statistics,
513 513 private=private,
514 514 state=Repository.STATE_CREATED
515 515 )
516 516 sa.commit()
517 517 # we added that repo just now, and make sure it has githook
518 518 # installed, and updated server info
519 519 if new_repo.repo_type == 'git':
520 520 git_repo = new_repo.scm_instance
521 521 ScmModel().install_git_hook(git_repo)
522 522 # update repository server-info
523 523 log.debug('Running update server info')
524 524 git_repo._update_server_info()
525 525 new_repo.update_changeset_cache()
526 526 elif install_git_hook:
527 527 if db_repo.repo_type == 'git':
528 528 ScmModel().install_git_hook(db_repo.scm_instance)
529 529
530 530 removed = []
531 531 # remove from database those repositories that are not in the filesystem
532 532 for repo in sa.query(Repository).all():
533 533 if repo.repo_name not in initial_repo_list.keys():
534 534 if remove_obsolete:
535 535 log.debug("Removing non-existing repository found in db `%s`" %
536 536 repo.repo_name)
537 537 try:
538 538 RepoModel(sa).delete(repo, forks='detach', fs_remove=False)
539 539 sa.commit()
540 540 except Exception:
541 541 #don't hold further removals on error
542 542 log.error(traceback.format_exc())
543 543 sa.rollback()
544 544 removed.append(repo.repo_name)
545 545 return added, removed
546 546
547 547
548 548 # set cache regions for beaker so celery can utilise it
549 549 def add_cache(settings):
550 550 cache_settings = {'regions': None}
551 551 for key in settings.keys():
552 552 for prefix in ['beaker.cache.', 'cache.']:
553 553 if key.startswith(prefix):
554 554 name = key.split(prefix)[1].strip()
555 555 cache_settings[name] = settings[key].strip()
556 556 if cache_settings['regions']:
557 557 for region in cache_settings['regions'].split(','):
558 558 region = region.strip()
559 559 region_settings = {}
560 560 for key, value in cache_settings.items():
561 561 if key.startswith(region):
562 562 region_settings[key.split('.')[1]] = value
563 563 region_settings['expire'] = int(region_settings.get('expire',
564 564 60))
565 565 region_settings.setdefault('lock_dir',
566 566 cache_settings.get('lock_dir'))
567 567 region_settings.setdefault('data_dir',
568 568 cache_settings.get('data_dir'))
569 569
570 570 if 'type' not in region_settings:
571 571 region_settings['type'] = cache_settings.get('type',
572 572 'memory')
573 573 beaker.cache.cache_regions[region] = region_settings
574 574
575 575
576 576 def load_rcextensions(root_path):
577 577 import kallithea
578 578 from kallithea.config import conf
579 579
580 580 path = os.path.join(root_path, 'rcextensions', '__init__.py')
581 581 if os.path.isfile(path):
582 582 rcext = create_module('rc', path)
583 583 EXT = kallithea.EXTENSIONS = rcext
584 584 log.debug('Found rcextensions now loading %s...' % rcext)
585 585
586 586 # Additional mappings that are not present in the pygments lexers
587 587 conf.LANGUAGES_EXTENSIONS_MAP.update(getattr(EXT, 'EXTRA_MAPPINGS', {}))
588 588
589 589 #OVERRIDE OUR EXTENSIONS FROM RC-EXTENSIONS (if present)
590 590
591 591 if getattr(EXT, 'INDEX_EXTENSIONS', []):
592 592 log.debug('settings custom INDEX_EXTENSIONS')
593 593 conf.INDEX_EXTENSIONS = getattr(EXT, 'INDEX_EXTENSIONS', [])
594 594
595 595 #ADDITIONAL MAPPINGS
596 596 log.debug('adding extra into INDEX_EXTENSIONS')
597 597 conf.INDEX_EXTENSIONS.extend(getattr(EXT, 'EXTRA_INDEX_EXTENSIONS', []))
598 598
599 599 # auto check if the module is not missing any data, set to default if is
600 600 # this will help autoupdate new feature of rcext module
601 601 #from kallithea.config import rcextensions
602 602 #for k in dir(rcextensions):
603 603 # if not k.startswith('_') and not hasattr(EXT, k):
604 604 # setattr(EXT, k, getattr(rcextensions, k))
605 605
606 606
607 607 def get_custom_lexer(extension):
608 608 """
609 609 returns a custom lexer if it's defined in rcextensions module, or None
610 610 if there's no custom lexer defined
611 611 """
612 612 import kallithea
613 613 from pygments import lexers
614 614 #check if we didn't define this extension as other lexer
615 615 if kallithea.EXTENSIONS and extension in kallithea.EXTENSIONS.EXTRA_LEXERS:
616 616 _lexer_name = kallithea.EXTENSIONS.EXTRA_LEXERS[extension]
617 617 return lexers.get_lexer_by_name(_lexer_name)
618 618
619 619
620 620 #==============================================================================
621 621 # TEST FUNCTIONS AND CREATORS
622 622 #==============================================================================
623 623 def create_test_index(repo_location, config, full_index):
624 624 """
625 625 Makes default test index
626 626
627 627 :param config: test config
628 628 :param full_index:
629 629 """
630 630
631 631 from kallithea.lib.indexers.daemon import WhooshIndexingDaemon
632 632 from kallithea.lib.pidlock import DaemonLock, LockHeld
633 633
634 634 repo_location = repo_location
635 635
636 636 index_location = os.path.join(config['app_conf']['index_dir'])
637 637 if not os.path.exists(index_location):
638 638 os.makedirs(index_location)
639 639
640 640 try:
641 641 l = DaemonLock(file_=jn(dn(index_location), 'make_index.lock'))
642 642 WhooshIndexingDaemon(index_location=index_location,
643 643 repo_location=repo_location)\
644 644 .run(full_index=full_index)
645 645 l.release()
646 646 except LockHeld:
647 647 pass
648 648
649 649
650 650 def create_test_env(repos_test_path, config):
651 651 """
652 652 Makes a fresh database and
653 653 install test repository into tmp dir
654 654 """
655 655 from kallithea.lib.db_manage import DbManage
656 656 from kallithea.tests import HG_REPO, GIT_REPO, TESTS_TMP_PATH
657 657
658 658 # PART ONE create db
659 659 dbconf = config['sqlalchemy.db1.url']
660 660 log.debug('making test db %s' % dbconf)
661 661
662 662 # create test dir if it doesn't exist
663 663 if not os.path.isdir(repos_test_path):
664 664 log.debug('Creating testdir %s' % repos_test_path)
665 665 os.makedirs(repos_test_path)
666 666
667 667 dbmanage = DbManage(log_sql=True, dbconf=dbconf, root=config['here'],
668 668 tests=True)
669 669 dbmanage.create_tables(override=True)
670 670 # for tests dynamically set new root paths based on generated content
671 671 dbmanage.create_settings(dbmanage.config_prompt(repos_test_path))
672 672 dbmanage.create_default_user()
673 673 dbmanage.admin_prompt()
674 674 dbmanage.create_permissions()
675 675 dbmanage.populate_default_permissions()
676 676 Session().commit()
677 677 # PART TWO make test repo
678 678 log.debug('making test vcs repositories')
679 679
680 680 idx_path = config['app_conf']['index_dir']
681 681 data_path = config['app_conf']['cache_dir']
682 682
683 683 #clean index and data
684 684 if idx_path and os.path.exists(idx_path):
685 685 log.debug('remove %s' % idx_path)
686 686 shutil.rmtree(idx_path)
687 687
688 688 if data_path and os.path.exists(data_path):
689 689 log.debug('remove %s' % data_path)
690 690 shutil.rmtree(data_path)
691 691
692 692 #CREATE DEFAULT TEST REPOS
693 693 cur_dir = dn(dn(abspath(__file__)))
694 694 tar = tarfile.open(jn(cur_dir, 'tests', 'fixtures', "vcs_test_hg.tar.gz"))
695 695 tar.extractall(jn(TESTS_TMP_PATH, HG_REPO))
696 696 tar.close()
697 697
698 698 cur_dir = dn(dn(abspath(__file__)))
699 699 tar = tarfile.open(jn(cur_dir, 'tests', 'fixtures', "vcs_test_git.tar.gz"))
700 700 tar.extractall(jn(TESTS_TMP_PATH, GIT_REPO))
701 701 tar.close()
702 702
703 703 #LOAD VCS test stuff
704 704 from kallithea.tests.vcs import setup_package
705 705 setup_package()
706 706
707 707
708 708 #==============================================================================
709 709 # PASTER COMMANDS
710 710 #==============================================================================
711 711 class BasePasterCommand(Command):
712 712 """
713 713 Abstract Base Class for paster commands.
714 714
715 715 The celery commands are somewhat aggressive about loading
716 716 celery.conf, and since our module sets the `CELERY_LOADER`
717 717 environment variable to our loader, we have to bootstrap a bit and
718 718 make sure we've had a chance to load the pylons config off of the
719 719 command line, otherwise everything fails.
720 720 """
721 721 min_args = 1
722 722 min_args_error = "Please provide a paster config file as an argument."
723 723 takes_config_file = 1
724 724 requires_config_file = True
725 725
726 726 def notify_msg(self, msg, log=False):
727 727 """Make a notification to user, additionally if logger is passed
728 728 it logs this action using given logger
729 729
730 730 :param msg: message that will be printed to user
731 731 :param log: logging instance, to use to additionally log this message
732 732
733 733 """
734 734 if log and isinstance(log, logging):
735 735 log(msg)
736 736
737 737 def run(self, args):
738 738 """
739 739 Overrides Command.run
740 740
741 741 Checks for a config file argument and loads it.
742 742 """
743 743 if len(args) < self.min_args:
744 744 raise BadCommand(
745 745 self.min_args_error % {'min_args': self.min_args,
746 746 'actual_args': len(args)})
747 747
748 748 # Decrement because we're going to lob off the first argument.
749 749 # @@ This is hacky
750 750 self.min_args -= 1
751 751 self.bootstrap_config(args[0])
752 752 self.update_parser()
753 753 return super(BasePasterCommand, self).run(args[1:])
754 754
755 755 def update_parser(self):
756 756 """
757 757 Abstract method. Allows for the class's parser to be updated
758 758 before the superclass's `run` method is called. Necessary to
759 759 allow options/arguments to be passed through to the underlying
760 760 celery command.
761 761 """
762 762 raise NotImplementedError("Abstract Method.")
763 763
764 764 def bootstrap_config(self, conf):
765 765 """
766 766 Loads the pylons configuration.
767 767 """
768 768 from pylons import config as pylonsconfig
769 769
770 770 self.path_to_ini_file = os.path.realpath(conf)
771 771 conf = paste.deploy.appconfig('config:' + self.path_to_ini_file)
772 772 pylonsconfig.init_app(conf.global_conf, conf.local_conf)
773 773
774 774 def _init_session(self):
775 775 """
776 776 Inits SqlAlchemy Session
777 777 """
778 778 logging.config.fileConfig(self.path_to_ini_file)
779 779 from pylons import config
780 780 from kallithea.model import init_model
781 781 from kallithea.lib.utils2 import engine_from_config
782 782
783 783 #get to remove repos !!
784 784 add_cache(config)
785 785 engine = engine_from_config(config, 'sqlalchemy.db1.')
786 786 init_model(engine)
787 787
788 788
789 789 def check_git_version():
790 790 """
791 791 Checks what version of git is installed in system, and issues a warning
792 792 if it's too old for Kallithea to work properly.
793 793 """
794 794 from kallithea import BACKENDS
795 795 from kallithea.lib.vcs.backends.git.repository import GitRepository
796 796 from kallithea.lib.vcs.conf import settings
797 797 from distutils.version import StrictVersion
798 798
799 799 if 'git' not in BACKENDS:
800 800 return None
801 801
802 stdout, stderr = GitRepository._run_git_command('--version', _bare=True,
802 stdout, stderr = GitRepository._run_git_command(['--version'], _bare=True,
803 803 _safe=True)
804 804
805 805 m = re.search("\d+.\d+.\d+", stdout)
806 806 if m:
807 807 ver = StrictVersion(m.group(0))
808 808 else:
809 809 ver = StrictVersion('0.0.0')
810 810
811 811 req_ver = StrictVersion('1.7.4')
812 812
813 813 log.debug('Git executable: "%s" version %s detected: %s'
814 814 % (settings.GIT_EXECUTABLE_PATH, ver, stdout))
815 815 if stderr:
816 816 log.warning('Error detecting git version: %r' % stderr)
817 817 elif ver < req_ver:
818 818 log.warning('Kallithea detected git version %s, which is too old '
819 819 'for the system to function properly. '
820 820 'Please upgrade to version %s or later.' % (ver, req_ver))
821 821 return ver
822 822
823 823
824 824 @decorator.decorator
825 825 def jsonify(func, *args, **kwargs):
826 826 """Action decorator that formats output for JSON
827 827
828 828 Given a function that will return content, this decorator will turn
829 829 the result into JSON, with a content-type of 'application/json' and
830 830 output it.
831 831
832 832 """
833 833 from pylons.decorators.util import get_pylons
834 834 from kallithea.lib.compat import json
835 835 pylons = get_pylons(args)
836 836 pylons.response.headers['Content-Type'] = 'application/json; charset=utf-8'
837 837 data = func(*args, **kwargs)
838 838 if isinstance(data, (list, tuple)):
839 839 msg = "JSON responses with Array envelopes are susceptible to " \
840 840 "cross-site data leak attacks, see " \
841 841 "http://wiki.pylonshq.com/display/pylonsfaq/Warnings"
842 842 warnings.warn(msg, Warning, 2)
843 843 log.warning(msg)
844 844 log.debug("Returning JSON wrapped action output")
845 845 return json.dumps(data, encoding='utf-8')
846 846
847 847
848 848 def conditional_cache(region, prefix, condition, func):
849 849 """
850 850
851 851 Conditional caching function use like::
852 852 def _c(arg):
853 853 #heavy computation function
854 854 return data
855 855
856 856 # denpending from condition the compute is wrapped in cache or not
857 857 compute = conditional_cache('short_term', 'cache_desc', codnition=True, func=func)
858 858 return compute(arg)
859 859
860 860 :param region: name of cache region
861 861 :param prefix: cache region prefix
862 862 :param condition: condition for cache to be triggered, and return data cached
863 863 :param func: wrapped heavy function to compute
864 864
865 865 """
866 866 wrapped = func
867 867 if condition:
868 868 log.debug('conditional_cache: True, wrapping call of '
869 869 'func: %s into %s region cache' % (region, func))
870 870 wrapped = _cache_decorate((prefix,), None, None, region)(func)
871 871
872 872 return wrapped
@@ -1,548 +1,548 b''
1 1 import re
2 2 from itertools import chain
3 3 from dulwich import objects
4 4 from subprocess import Popen, PIPE
5 5
6 6 from kallithea.lib.vcs.conf import settings
7 7 from kallithea.lib.vcs.backends.base import BaseChangeset, EmptyChangeset
8 8 from kallithea.lib.vcs.exceptions import (
9 9 RepositoryError, ChangesetError, NodeDoesNotExistError, VCSError,
10 10 ChangesetDoesNotExistError, ImproperArchiveTypeError
11 11 )
12 12 from kallithea.lib.vcs.nodes import (
13 13 FileNode, DirNode, NodeKind, RootNode, RemovedFileNode, SubModuleNode,
14 14 ChangedFileNodesGenerator, AddedFileNodesGenerator, RemovedFileNodesGenerator
15 15 )
16 16 from kallithea.lib.vcs.utils import (
17 17 safe_unicode, safe_str, safe_int, date_fromtimestamp
18 18 )
19 19 from kallithea.lib.vcs.utils.lazy import LazyProperty
20 20
21 21
22 22 class GitChangeset(BaseChangeset):
23 23 """
24 24 Represents state of the repository at single revision.
25 25 """
26 26
27 27 def __init__(self, repository, revision):
28 28 self._stat_modes = {}
29 29 self.repository = repository
30 30 revision = safe_str(revision)
31 31 try:
32 32 commit = self.repository._repo[revision]
33 33 if isinstance(commit, objects.Tag):
34 34 revision = safe_str(commit.object[1])
35 35 commit = self.repository._repo.get_object(commit.object[1])
36 36 except KeyError:
37 37 raise RepositoryError("Cannot get object with id %s" % revision)
38 38 self.raw_id = revision
39 39 self.id = self.raw_id
40 40 self.short_id = self.raw_id[:12]
41 41 self._commit = commit
42 42 self._tree_id = commit.tree
43 43 self._committer_property = 'committer'
44 44 self._author_property = 'author'
45 45 self._date_property = 'commit_time'
46 46 self._date_tz_property = 'commit_timezone'
47 47 self.revision = repository.revisions.index(revision)
48 48
49 49 self.nodes = {}
50 50 self._paths = {}
51 51
52 52 @LazyProperty
53 53 def message(self):
54 54 return safe_unicode(self._commit.message)
55 55
56 56 @LazyProperty
57 57 def committer(self):
58 58 return safe_unicode(getattr(self._commit, self._committer_property))
59 59
60 60 @LazyProperty
61 61 def author(self):
62 62 return safe_unicode(getattr(self._commit, self._author_property))
63 63
64 64 @LazyProperty
65 65 def date(self):
66 66 return date_fromtimestamp(getattr(self._commit, self._date_property),
67 67 getattr(self._commit, self._date_tz_property))
68 68
69 69 @LazyProperty
70 70 def _timestamp(self):
71 71 return getattr(self._commit, self._date_property)
72 72
73 73 @LazyProperty
74 74 def status(self):
75 75 """
76 76 Returns modified, added, removed, deleted files for current changeset
77 77 """
78 78 return self.changed, self.added, self.removed
79 79
80 80 @LazyProperty
81 81 def tags(self):
82 82 _tags = []
83 83 for tname, tsha in self.repository.tags.iteritems():
84 84 if tsha == self.raw_id:
85 85 _tags.append(tname)
86 86 return _tags
87 87
88 88 @LazyProperty
89 89 def branch(self):
90 90
91 91 heads = self.repository._heads(reverse=False)
92 92
93 93 ref = heads.get(self.raw_id)
94 94 if ref:
95 95 return safe_unicode(ref)
96 96
97 97 def _fix_path(self, path):
98 98 """
99 99 Paths are stored without trailing slash so we need to get rid off it if
100 100 needed.
101 101 """
102 102 if path.endswith('/'):
103 103 path = path.rstrip('/')
104 104 return path
105 105
106 106 def _get_id_for_path(self, path):
107 107 path = safe_str(path)
108 108 # FIXME: Please, spare a couple of minutes and make those codes cleaner;
109 109 if not path in self._paths:
110 110 path = path.strip('/')
111 111 # set root tree
112 112 tree = self.repository._repo[self._tree_id]
113 113 if path == '':
114 114 self._paths[''] = tree.id
115 115 return tree.id
116 116 splitted = path.split('/')
117 117 dirs, name = splitted[:-1], splitted[-1]
118 118 curdir = ''
119 119
120 120 # initially extract things from root dir
121 121 for item, stat, id in tree.iteritems():
122 122 if curdir:
123 123 name = '/'.join((curdir, item))
124 124 else:
125 125 name = item
126 126 self._paths[name] = id
127 127 self._stat_modes[name] = stat
128 128
129 129 for dir in dirs:
130 130 if curdir:
131 131 curdir = '/'.join((curdir, dir))
132 132 else:
133 133 curdir = dir
134 134 dir_id = None
135 135 for item, stat, id in tree.iteritems():
136 136 if dir == item:
137 137 dir_id = id
138 138 if dir_id:
139 139 # Update tree
140 140 tree = self.repository._repo[dir_id]
141 141 if not isinstance(tree, objects.Tree):
142 142 raise ChangesetError('%s is not a directory' % curdir)
143 143 else:
144 144 raise ChangesetError('%s have not been found' % curdir)
145 145
146 146 # cache all items from the given traversed tree
147 147 for item, stat, id in tree.iteritems():
148 148 if curdir:
149 149 name = '/'.join((curdir, item))
150 150 else:
151 151 name = item
152 152 self._paths[name] = id
153 153 self._stat_modes[name] = stat
154 154 if not path in self._paths:
155 155 raise NodeDoesNotExistError("There is no file nor directory "
156 156 "at the given path '%s' at revision %s"
157 157 % (path, safe_str(self.short_id)))
158 158 return self._paths[path]
159 159
160 160 def _get_kind(self, path):
161 161 obj = self.repository._repo[self._get_id_for_path(path)]
162 162 if isinstance(obj, objects.Blob):
163 163 return NodeKind.FILE
164 164 elif isinstance(obj, objects.Tree):
165 165 return NodeKind.DIR
166 166
167 167 def _get_filectx(self, path):
168 168 path = self._fix_path(path)
169 169 if self._get_kind(path) != NodeKind.FILE:
170 170 raise ChangesetError("File does not exist for revision %s at "
171 171 " '%s'" % (self.raw_id, path))
172 172 return path
173 173
174 174 def _get_file_nodes(self):
175 175 return chain(*(t[2] for t in self.walk()))
176 176
177 177 @LazyProperty
178 178 def parents(self):
179 179 """
180 180 Returns list of parents changesets.
181 181 """
182 182 return [self.repository.get_changeset(parent)
183 183 for parent in self._commit.parents]
184 184
185 185 @LazyProperty
186 186 def children(self):
187 187 """
188 188 Returns list of children changesets.
189 189 """
190 190 rev_filter = settings.GIT_REV_FILTER
191 191 so, se = self.repository.run_git_command(
192 "rev-list %s --children" % (rev_filter)
192 ['rev-list', rev_filter, '--children']
193 193 )
194 194
195 195 children = []
196 196 pat = re.compile(r'^%s' % self.raw_id)
197 197 for l in so.splitlines():
198 198 if pat.match(l):
199 199 childs = l.split(' ')[1:]
200 200 children.extend(childs)
201 201 return [self.repository.get_changeset(cs) for cs in children]
202 202
203 203 def next(self, branch=None):
204 204 if branch and self.branch != branch:
205 205 raise VCSError('Branch option used on changeset not belonging '
206 206 'to that branch')
207 207
208 208 cs = self
209 209 while True:
210 210 try:
211 211 next_ = cs.revision + 1
212 212 next_rev = cs.repository.revisions[next_]
213 213 except IndexError:
214 214 raise ChangesetDoesNotExistError
215 215 cs = cs.repository.get_changeset(next_rev)
216 216
217 217 if not branch or branch == cs.branch:
218 218 return cs
219 219
220 220 def prev(self, branch=None):
221 221 if branch and self.branch != branch:
222 222 raise VCSError('Branch option used on changeset not belonging '
223 223 'to that branch')
224 224
225 225 cs = self
226 226 while True:
227 227 try:
228 228 prev_ = cs.revision - 1
229 229 if prev_ < 0:
230 230 raise IndexError
231 231 prev_rev = cs.repository.revisions[prev_]
232 232 except IndexError:
233 233 raise ChangesetDoesNotExistError
234 234 cs = cs.repository.get_changeset(prev_rev)
235 235
236 236 if not branch or branch == cs.branch:
237 237 return cs
238 238
239 239 def diff(self, ignore_whitespace=True, context=3):
240 240 rev1 = self.parents[0] if self.parents else self.repository.EMPTY_CHANGESET
241 241 rev2 = self
242 242 return ''.join(self.repository.get_diff(rev1, rev2,
243 243 ignore_whitespace=ignore_whitespace,
244 244 context=context))
245 245
246 246 def get_file_mode(self, path):
247 247 """
248 248 Returns stat mode of the file at the given ``path``.
249 249 """
250 250 # ensure path is traversed
251 251 path = safe_str(path)
252 252 self._get_id_for_path(path)
253 253 return self._stat_modes[path]
254 254
255 255 def get_file_content(self, path):
256 256 """
257 257 Returns content of the file at given ``path``.
258 258 """
259 259 id = self._get_id_for_path(path)
260 260 blob = self.repository._repo[id]
261 261 return blob.as_pretty_string()
262 262
263 263 def get_file_size(self, path):
264 264 """
265 265 Returns size of the file at given ``path``.
266 266 """
267 267 id = self._get_id_for_path(path)
268 268 blob = self.repository._repo[id]
269 269 return blob.raw_length()
270 270
271 271 def get_file_changeset(self, path):
272 272 """
273 273 Returns last commit of the file at the given ``path``.
274 274 """
275 275 return self.get_file_history(path, limit=1)[0]
276 276
277 277 def get_file_history(self, path, limit=None):
278 278 """
279 279 Returns history of file as reversed list of ``Changeset`` objects for
280 280 which file at given ``path`` has been modified.
281 281
282 282 TODO: This function now uses os underlying 'git' and 'grep' commands
283 283 which is generally not good. Should be replaced with algorithm
284 284 iterating commits.
285 285 """
286 286 self._get_filectx(path)
287 287 cs_id = safe_str(self.id)
288 288 f_path = safe_str(path)
289 289
290 290 if limit:
291 cmd = 'log -n %s --pretty="format: %%H" -s %s -- "%s"' % (
292 safe_int(limit, 0), cs_id, f_path)
291 cmd = ['log', '-n', str(safe_int(limit, 0)),
292 '--pretty=format:%H', '-s', cs_id, '--', f_path]
293 293
294 294 else:
295 cmd = 'log --pretty="format: %%H" -s %s -- "%s"' % (
296 cs_id, f_path)
295 cmd = ['log',
296 '--pretty=format:%H', '-s', cs_id, '--', f_path]
297 297 so, se = self.repository.run_git_command(cmd)
298 298 ids = re.findall(r'[0-9a-fA-F]{40}', so)
299 299 return [self.repository.get_changeset(sha) for sha in ids]
300 300
301 301 def get_file_history_2(self, path):
302 302 """
303 303 Returns history of file as reversed list of ``Changeset`` objects for
304 304 which file at given ``path`` has been modified.
305 305
306 306 """
307 307 self._get_filectx(path)
308 308 from dulwich.walk import Walker
309 309 include = [self.id]
310 310 walker = Walker(self.repository._repo.object_store, include,
311 311 paths=[path], max_entries=1)
312 312 return [self.repository.get_changeset(sha)
313 313 for sha in (x.commit.id for x in walker)]
314 314
315 315 def get_file_annotate(self, path):
316 316 """
317 317 Returns a generator of four element tuples with
318 318 lineno, sha, changeset lazy loader and line
319 319
320 320 TODO: This function now uses os underlying 'git' command which is
321 321 generally not good. Should be replaced with algorithm iterating
322 322 commits.
323 323 """
324 cmd = 'blame -l --root -r %s -- "%s"' % (self.id, path)
324 cmd = ['blame', '-l', '--root', '-r', self.id, '--', path]
325 325 # -l ==> outputs long shas (and we need all 40 characters)
326 326 # --root ==> doesn't put '^' character for boundaries
327 327 # -r sha ==> blames for the given revision
328 328 so, se = self.repository.run_git_command(cmd)
329 329
330 330 for i, blame_line in enumerate(so.split('\n')[:-1]):
331 331 ln_no = i + 1
332 332 sha, line = re.split(r' ', blame_line, 1)
333 333 yield (ln_no, sha, lambda: self.repository.get_changeset(sha), line)
334 334
335 335 def fill_archive(self, stream=None, kind='tgz', prefix=None,
336 336 subrepos=False):
337 337 """
338 338 Fills up given stream.
339 339
340 340 :param stream: file like object.
341 341 :param kind: one of following: ``zip``, ``tgz`` or ``tbz2``.
342 342 Default: ``tgz``.
343 343 :param prefix: name of root directory in archive.
344 344 Default is repository name and changeset's raw_id joined with dash
345 345 (``repo-tip.<KIND>``).
346 346 :param subrepos: include subrepos in this archive.
347 347
348 348 :raise ImproperArchiveTypeError: If given kind is wrong.
349 349 :raise VcsError: If given stream is None
350 350
351 351 """
352 352 allowed_kinds = settings.ARCHIVE_SPECS.keys()
353 353 if kind not in allowed_kinds:
354 354 raise ImproperArchiveTypeError('Archive kind not supported use one'
355 355 'of %s', allowed_kinds)
356 356
357 357 if prefix is None:
358 358 prefix = '%s-%s' % (self.repository.name, self.short_id)
359 359 elif prefix.startswith('/'):
360 360 raise VCSError("Prefix cannot start with leading slash")
361 361 elif prefix.strip() == '':
362 362 raise VCSError("Prefix cannot be empty")
363 363
364 364 if kind == 'zip':
365 365 frmt = 'zip'
366 366 else:
367 367 frmt = 'tar'
368 368 _git_path = settings.GIT_EXECUTABLE_PATH
369 369 cmd = '%s archive --format=%s --prefix=%s/ %s' % (_git_path,
370 370 frmt, prefix, self.raw_id)
371 371 if kind == 'tgz':
372 372 cmd += ' | gzip -9'
373 373 elif kind == 'tbz2':
374 374 cmd += ' | bzip2 -9'
375 375
376 376 if stream is None:
377 377 raise VCSError('You need to pass in a valid stream for filling'
378 378 ' with archival data')
379 379 popen = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=True,
380 380 cwd=self.repository.path)
381 381
382 382 buffer_size = 1024 * 8
383 383 chunk = popen.stdout.read(buffer_size)
384 384 while chunk:
385 385 stream.write(chunk)
386 386 chunk = popen.stdout.read(buffer_size)
387 387 # Make sure all descriptors would be read
388 388 popen.communicate()
389 389
390 390 def get_nodes(self, path):
391 391 if self._get_kind(path) != NodeKind.DIR:
392 392 raise ChangesetError("Directory does not exist for revision %s at "
393 393 " '%s'" % (self.revision, path))
394 394 path = self._fix_path(path)
395 395 id = self._get_id_for_path(path)
396 396 tree = self.repository._repo[id]
397 397 dirnodes = []
398 398 filenodes = []
399 399 als = self.repository.alias
400 400 for name, stat, id in tree.iteritems():
401 401 if objects.S_ISGITLINK(stat):
402 402 dirnodes.append(SubModuleNode(name, url=None, changeset=id,
403 403 alias=als))
404 404 continue
405 405
406 406 obj = self.repository._repo.get_object(id)
407 407 if path != '':
408 408 obj_path = '/'.join((path, name))
409 409 else:
410 410 obj_path = name
411 411 if obj_path not in self._stat_modes:
412 412 self._stat_modes[obj_path] = stat
413 413 if isinstance(obj, objects.Tree):
414 414 dirnodes.append(DirNode(obj_path, changeset=self))
415 415 elif isinstance(obj, objects.Blob):
416 416 filenodes.append(FileNode(obj_path, changeset=self, mode=stat))
417 417 else:
418 418 raise ChangesetError("Requested object should be Tree "
419 419 "or Blob, is %r" % type(obj))
420 420 nodes = dirnodes + filenodes
421 421 for node in nodes:
422 422 if not node.path in self.nodes:
423 423 self.nodes[node.path] = node
424 424 nodes.sort()
425 425 return nodes
426 426
427 427 def get_node(self, path):
428 428 if isinstance(path, unicode):
429 429 path = path.encode('utf-8')
430 430 path = self._fix_path(path)
431 431 if not path in self.nodes:
432 432 try:
433 433 id_ = self._get_id_for_path(path)
434 434 except ChangesetError:
435 435 raise NodeDoesNotExistError("Cannot find one of parents' "
436 436 "directories for a given path: %s" % path)
437 437
438 438 _GL = lambda m: m and objects.S_ISGITLINK(m)
439 439 if _GL(self._stat_modes.get(path)):
440 440 node = SubModuleNode(path, url=None, changeset=id_,
441 441 alias=self.repository.alias)
442 442 else:
443 443 obj = self.repository._repo.get_object(id_)
444 444
445 445 if isinstance(obj, objects.Tree):
446 446 if path == '':
447 447 node = RootNode(changeset=self)
448 448 else:
449 449 node = DirNode(path, changeset=self)
450 450 node._tree = obj
451 451 elif isinstance(obj, objects.Blob):
452 452 node = FileNode(path, changeset=self)
453 453 node._blob = obj
454 454 else:
455 455 raise NodeDoesNotExistError("There is no file nor directory "
456 456 "at the given path '%s' at revision %s"
457 457 % (path, self.short_id))
458 458 # cache node
459 459 self.nodes[path] = node
460 460 return self.nodes[path]
461 461
462 462 @LazyProperty
463 463 def affected_files(self):
464 464 """
465 465 Gets a fast accessible file changes for given changeset
466 466 """
467 467 added, modified, deleted = self._changes_cache
468 468 return list(added.union(modified).union(deleted))
469 469
470 470 @LazyProperty
471 471 def _diff_name_status(self):
472 472 output = []
473 473 for parent in self.parents:
474 cmd = 'diff --name-status %s %s --encoding=utf8' % (parent.raw_id,
475 self.raw_id)
474 cmd = ['diff', '--name-status', parent.raw_id, self.raw_id,
475 '--encoding=utf8']
476 476 so, se = self.repository.run_git_command(cmd)
477 477 output.append(so.strip())
478 478 return '\n'.join(output)
479 479
480 480 @LazyProperty
481 481 def _changes_cache(self):
482 482 added = set()
483 483 modified = set()
484 484 deleted = set()
485 485 _r = self.repository._repo
486 486
487 487 parents = self.parents
488 488 if not self.parents:
489 489 parents = [EmptyChangeset()]
490 490 for parent in parents:
491 491 if isinstance(parent, EmptyChangeset):
492 492 oid = None
493 493 else:
494 494 oid = _r[parent.raw_id].tree
495 495 changes = _r.object_store.tree_changes(oid, _r[self.raw_id].tree)
496 496 for (oldpath, newpath), (_, _), (_, _) in changes:
497 497 if newpath and oldpath:
498 498 modified.add(newpath)
499 499 elif newpath and not oldpath:
500 500 added.add(newpath)
501 501 elif not newpath and oldpath:
502 502 deleted.add(oldpath)
503 503 return added, modified, deleted
504 504
505 505 def _get_paths_for_status(self, status):
506 506 """
507 507 Returns sorted list of paths for given ``status``.
508 508
509 509 :param status: one of: *added*, *modified* or *deleted*
510 510 """
511 511 added, modified, deleted = self._changes_cache
512 512 return sorted({
513 513 'added': list(added),
514 514 'modified': list(modified),
515 515 'deleted': list(deleted)}[status]
516 516 )
517 517
518 518 @LazyProperty
519 519 def added(self):
520 520 """
521 521 Returns list of added ``FileNode`` objects.
522 522 """
523 523 if not self.parents:
524 524 return list(self._get_file_nodes())
525 525 return AddedFileNodesGenerator([n for n in
526 526 self._get_paths_for_status('added')], self)
527 527
528 528 @LazyProperty
529 529 def changed(self):
530 530 """
531 531 Returns list of modified ``FileNode`` objects.
532 532 """
533 533 if not self.parents:
534 534 return []
535 535 return ChangedFileNodesGenerator([n for n in
536 536 self._get_paths_for_status('modified')], self)
537 537
538 538 @LazyProperty
539 539 def removed(self):
540 540 """
541 541 Returns list of removed ``FileNode`` objects.
542 542 """
543 543 if not self.parents:
544 544 return []
545 545 return RemovedFileNodesGenerator([n for n in
546 546 self._get_paths_for_status('deleted')], self)
547 547
548 548 extra = {}
@@ -1,759 +1,732 b''
1 1 # -*- coding: utf-8 -*-
2 2 """
3 3 vcs.backends.git.repository
4 4 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
5 5
6 6 Git repository implementation.
7 7
8 8 :created_on: Apr 8, 2010
9 9 :copyright: (c) 2010-2011 by Marcin Kuzminski, Lukasz Balcerzak.
10 10 """
11 11
12 12 import os
13 13 import re
14 14 import time
15 15 import errno
16 16 import urllib
17 17 import urllib2
18 18 import logging
19 19 import posixpath
20 20 import string
21 import sys
22 if sys.platform == "win32":
23 from subprocess import list2cmdline
24 def quote(s):
25 return list2cmdline([s])
26 else:
27 try:
28 # Python <=2.7
29 from pipes import quote
30 except ImportError:
31 # Python 3.3+
32 from shlex import quote
33 21
34 22 from dulwich.objects import Tag
35 23 from dulwich.repo import Repo, NotGitRepository
36 24 from dulwich.config import ConfigFile
37 25
38 26 from kallithea.lib.vcs import subprocessio
39 27 from kallithea.lib.vcs.backends.base import BaseRepository, CollectionGenerator
40 28 from kallithea.lib.vcs.conf import settings
41 29
42 30 from kallithea.lib.vcs.exceptions import (
43 31 BranchDoesNotExistError, ChangesetDoesNotExistError, EmptyRepositoryError,
44 32 RepositoryError, TagAlreadyExistError, TagDoesNotExistError
45 33 )
46 34 from kallithea.lib.vcs.utils import safe_unicode, makedate, date_fromtimestamp
47 35 from kallithea.lib.vcs.utils.lazy import LazyProperty
48 36 from kallithea.lib.vcs.utils.ordered_dict import OrderedDict
49 37 from kallithea.lib.vcs.utils.paths import abspath, get_user_home
50 38
51 39 from kallithea.lib.vcs.utils.hgcompat import (
52 40 hg_url, httpbasicauthhandler, httpdigestauthhandler
53 41 )
54 42
55 43 from .changeset import GitChangeset
56 44 from .inmemory import GitInMemoryChangeset
57 45 from .workdir import GitWorkdir
58 46
59 47 SHA_PATTERN = re.compile(r'^[[0-9a-fA-F]{12}|[0-9a-fA-F]{40}]$')
60 48
61 49 log = logging.getLogger(__name__)
62 50
63 51
64 52 class GitRepository(BaseRepository):
65 53 """
66 54 Git repository backend.
67 55 """
68 56 DEFAULT_BRANCH_NAME = 'master'
69 57 scm = 'git'
70 58
71 59 def __init__(self, repo_path, create=False, src_url=None,
72 60 update_after_clone=False, bare=False):
73 61
74 62 self.path = abspath(repo_path)
75 63 repo = self._get_repo(create, src_url, update_after_clone, bare)
76 64 self.bare = repo.bare
77 65
78 66 @property
79 67 def _config_files(self):
80 68 return [
81 69 self.bare and abspath(self.path, 'config')
82 70 or abspath(self.path, '.git', 'config'),
83 71 abspath(get_user_home(), '.gitconfig'),
84 72 ]
85 73
86 74 @property
87 75 def _repo(self):
88 76 return Repo(self.path)
89 77
90 78 @property
91 79 def head(self):
92 80 try:
93 81 return self._repo.head()
94 82 except KeyError:
95 83 return None
96 84
97 85 @property
98 86 def _empty(self):
99 87 """
100 88 Checks if repository is empty ie. without any changesets
101 89 """
102 90
103 91 try:
104 92 self.revisions[0]
105 93 except (KeyError, IndexError):
106 94 return True
107 95 return False
108 96
109 97 @LazyProperty
110 98 def revisions(self):
111 99 """
112 100 Returns list of revisions' ids, in ascending order. Being lazy
113 101 attribute allows external tools to inject shas from cache.
114 102 """
115 103 return self._get_all_revisions()
116 104
117 105 @classmethod
118 106 def _run_git_command(cls, cmd, **opts):
119 107 """
120 108 Runs given ``cmd`` as git command and returns tuple
121 109 (stdout, stderr).
122 110
123 111 :param cmd: git command to be executed
124 112 :param opts: env options to pass into Subprocess command
125 113 """
126 114
127 115 if '_bare' in opts:
128 116 _copts = []
129 117 del opts['_bare']
130 118 else:
131 119 _copts = ['-c', 'core.quotepath=false', ]
132 120 safe_call = False
133 121 if '_safe' in opts:
134 122 #no exc on failure
135 123 del opts['_safe']
136 124 safe_call = True
137 125
138 _str_cmd = False
139 if isinstance(cmd, basestring):
140 cmd = [cmd]
141 _str_cmd = True
126 assert isinstance(cmd, list), cmd
142 127
143 128 gitenv = os.environ
144 129 # need to clean fix GIT_DIR !
145 130 if 'GIT_DIR' in gitenv:
146 131 del gitenv['GIT_DIR']
147 132 gitenv['GIT_CONFIG_NOGLOBAL'] = '1'
148 133
149 134 _git_path = settings.GIT_EXECUTABLE_PATH
150 135 cmd = [_git_path] + _copts + cmd
151 if _str_cmd:
152 cmd = ' '.join(cmd)
153 136
154 137 try:
155 138 _opts = dict(
156 139 env=gitenv,
157 shell=True,
140 shell=False,
158 141 )
159 142 _opts.update(opts)
160 143 p = subprocessio.SubprocessIOChunker(cmd, **_opts)
161 144 except (EnvironmentError, OSError), err:
162 145 tb_err = ("Couldn't run git command (%s).\n"
163 146 "Original error was:%s\n" % (cmd, err))
164 147 log.error(tb_err)
165 148 if safe_call:
166 149 return '', err
167 150 else:
168 151 raise RepositoryError(tb_err)
169 152
170 153 return ''.join(p.output), ''.join(p.error)
171 154
172 155 def run_git_command(self, cmd):
173 156 opts = {}
174 157 if os.path.isdir(self.path):
175 158 opts['cwd'] = self.path
176 159 return self._run_git_command(cmd, **opts)
177 160
178 161 @classmethod
179 162 def _check_url(cls, url):
180 163 """
181 164 Function will check given url and try to verify if it's a valid
182 165 link. Sometimes it may happened that git will issue basic
183 166 auth request that can cause whole API to hang when used from python
184 167 or other external calls.
185 168
186 169 On failures it'll raise urllib2.HTTPError, exception is also thrown
187 170 when the return code is non 200
188 171 """
189 172
190 173 # check first if it's not an local url
191 174 if os.path.isdir(url) or url.startswith('file:'):
192 175 return True
193 176
194 177 if '+' in url[:url.find('://')]:
195 178 url = url[url.find('+') + 1:]
196 179
197 180 handlers = []
198 181 url_obj = hg_url(url)
199 182 test_uri, authinfo = url_obj.authinfo()
200 183 url_obj.passwd = '*****'
201 184 cleaned_uri = str(url_obj)
202 185
203 186 if not test_uri.endswith('info/refs'):
204 187 test_uri = test_uri.rstrip('/') + '/info/refs'
205 188
206 189 if authinfo:
207 190 #create a password manager
208 191 passmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
209 192 passmgr.add_password(*authinfo)
210 193
211 194 handlers.extend((httpbasicauthhandler(passmgr),
212 195 httpdigestauthhandler(passmgr)))
213 196
214 197 o = urllib2.build_opener(*handlers)
215 198 o.addheaders = [('User-Agent', 'git/1.7.8.0')] # fake some git
216 199
217 200 q = {"service": 'git-upload-pack'}
218 201 qs = '?%s' % urllib.urlencode(q)
219 202 cu = "%s%s" % (test_uri, qs)
220 203 req = urllib2.Request(cu, None, {})
221 204
222 205 try:
223 206 resp = o.open(req)
224 207 if resp.code != 200:
225 208 raise Exception('Return Code is not 200')
226 209 except Exception, e:
227 210 # means it cannot be cloned
228 211 raise urllib2.URLError("[%s] org_exc: %s" % (cleaned_uri, e))
229 212
230 213 # now detect if it's proper git repo
231 214 gitdata = resp.read()
232 215 if not 'service=git-upload-pack' in gitdata:
233 216 raise urllib2.URLError(
234 217 "url [%s] does not look like an git" % (cleaned_uri))
235 218
236 219 return True
237 220
238 221 def _get_repo(self, create, src_url=None, update_after_clone=False,
239 222 bare=False):
240 223 if create and os.path.exists(self.path):
241 224 raise RepositoryError("Location already exist")
242 225 if src_url and not create:
243 226 raise RepositoryError("Create should be set to True if src_url is "
244 227 "given (clone operation creates repository)")
245 228 try:
246 229 if create and src_url:
247 230 GitRepository._check_url(src_url)
248 231 self.clone(src_url, update_after_clone, bare)
249 232 return Repo(self.path)
250 233 elif create:
251 234 os.makedirs(self.path)
252 235 if bare:
253 236 return Repo.init_bare(self.path)
254 237 else:
255 238 return Repo.init(self.path)
256 239 else:
257 240 return self._repo
258 241 except (NotGitRepository, OSError), err:
259 242 raise RepositoryError(err)
260 243
261 244 def _get_all_revisions(self):
262 245 # we must check if this repo is not empty, since later command
263 246 # fails if it is. And it's cheaper to ask than throw the subprocess
264 247 # errors
265 248 try:
266 249 self._repo.head()
267 250 except KeyError:
268 251 return []
269 252
270 253 rev_filter = settings.GIT_REV_FILTER
271 cmd = 'rev-list %s --reverse --date-order' % (rev_filter)
254 cmd = ['rev-list', rev_filter, '--reverse', '--date-order']
272 255 try:
273 256 so, se = self.run_git_command(cmd)
274 257 except RepositoryError:
275 258 # Can be raised for empty repositories
276 259 return []
277 260 return so.splitlines()
278 261
279 262 def _get_all_revisions2(self):
280 263 #alternate implementation using dulwich
281 264 includes = [x[1][0] for x in self._parsed_refs.iteritems()
282 265 if x[1][1] != 'T']
283 266 return [c.commit.id for c in self._repo.get_walker(include=includes)]
284 267
285 268 def _get_revision(self, revision):
286 269 """
287 270 For git backend we always return integer here. This way we ensure
288 271 that changeset's revision attribute would become integer.
289 272 """
290 273
291 274 is_null = lambda o: len(o) == revision.count('0')
292 275
293 276 if self._empty:
294 277 raise EmptyRepositoryError("There are no changesets yet")
295 278
296 279 if revision in (None, '', 'tip', 'HEAD', 'head', -1):
297 280 return self.revisions[-1]
298 281
299 282 is_bstr = isinstance(revision, (str, unicode))
300 283 if ((is_bstr and revision.isdigit() and len(revision) < 12)
301 284 or isinstance(revision, int) or is_null(revision)):
302 285 try:
303 286 revision = self.revisions[int(revision)]
304 287 except IndexError:
305 288 msg = ("Revision %s does not exist for %s" % (revision, self))
306 289 raise ChangesetDoesNotExistError(msg)
307 290
308 291 elif is_bstr:
309 292 # get by branch/tag name
310 293 _ref_revision = self._parsed_refs.get(revision)
311 294 if _ref_revision: # and _ref_revision[1] in ['H', 'RH', 'T']:
312 295 return _ref_revision[0]
313 296
314 297 _tags_shas = self.tags.values()
315 298 # maybe it's a tag ? we don't have them in self.revisions
316 299 if revision in _tags_shas:
317 300 return _tags_shas[_tags_shas.index(revision)]
318 301
319 302 elif not SHA_PATTERN.match(revision) or revision not in self.revisions:
320 303 msg = ("Revision %s does not exist for %s" % (revision, self))
321 304 raise ChangesetDoesNotExistError(msg)
322 305
323 306 # Ensure we return full id
324 307 if not SHA_PATTERN.match(str(revision)):
325 308 raise ChangesetDoesNotExistError("Given revision %s not recognized"
326 309 % revision)
327 310 return revision
328 311
329 312 def get_ref_revision(self, ref_type, ref_name):
330 313 """
331 314 Returns ``MercurialChangeset`` object representing repository's
332 315 changeset at the given ``revision``.
333 316 """
334 317 return self._get_revision(ref_name)
335 318
336 319 def _get_archives(self, archive_name='tip'):
337 320
338 321 for i in [('zip', '.zip'), ('gz', '.tar.gz'), ('bz2', '.tar.bz2')]:
339 322 yield {"type": i[0], "extension": i[1], "node": archive_name}
340 323
341 324 def _get_url(self, url):
342 325 """
343 326 Returns normalized url. If schema is not given, would fall to
344 327 filesystem (``file:///``) schema.
345 328 """
346 329 url = str(url)
347 330 if url != 'default' and not '://' in url:
348 331 url = ':///'.join(('file', url))
349 332 return url
350 333
351 334 def get_hook_location(self):
352 335 """
353 336 returns absolute path to location where hooks are stored
354 337 """
355 338 loc = os.path.join(self.path, 'hooks')
356 339 if not self.bare:
357 340 loc = os.path.join(self.path, '.git', 'hooks')
358 341 return loc
359 342
360 343 @LazyProperty
361 344 def name(self):
362 345 return os.path.basename(self.path)
363 346
364 347 @LazyProperty
365 348 def last_change(self):
366 349 """
367 350 Returns last change made on this repository as datetime object
368 351 """
369 352 return date_fromtimestamp(self._get_mtime(), makedate()[1])
370 353
371 354 def _get_mtime(self):
372 355 try:
373 356 return time.mktime(self.get_changeset().date.timetuple())
374 357 except RepositoryError:
375 358 idx_loc = '' if self.bare else '.git'
376 359 # fallback to filesystem
377 360 in_path = os.path.join(self.path, idx_loc, "index")
378 361 he_path = os.path.join(self.path, idx_loc, "HEAD")
379 362 if os.path.exists(in_path):
380 363 return os.stat(in_path).st_mtime
381 364 else:
382 365 return os.stat(he_path).st_mtime
383 366
384 367 @LazyProperty
385 368 def description(self):
386 369 undefined_description = u'unknown'
387 370 _desc = self._repo.get_description()
388 371 return safe_unicode(_desc or undefined_description)
389 372
390 373 @LazyProperty
391 374 def contact(self):
392 375 undefined_contact = u'Unknown'
393 376 return undefined_contact
394 377
395 378 @property
396 379 def branches(self):
397 380 if not self.revisions:
398 381 return {}
399 382 sortkey = lambda ctx: ctx[0]
400 383 _branches = [(x[0], x[1][0])
401 384 for x in self._parsed_refs.iteritems() if x[1][1] == 'H']
402 385 return OrderedDict(sorted(_branches, key=sortkey, reverse=False))
403 386
404 387 @LazyProperty
405 388 def closed_branches(self):
406 389 return {}
407 390
408 391 @LazyProperty
409 392 def tags(self):
410 393 return self._get_tags()
411 394
412 395 def _get_tags(self):
413 396 if not self.revisions:
414 397 return {}
415 398
416 399 sortkey = lambda ctx: ctx[0]
417 400 _tags = [(x[0], x[1][0])
418 401 for x in self._parsed_refs.iteritems() if x[1][1] == 'T']
419 402 return OrderedDict(sorted(_tags, key=sortkey, reverse=True))
420 403
421 404 def tag(self, name, user, revision=None, message=None, date=None,
422 405 **kwargs):
423 406 """
424 407 Creates and returns a tag for the given ``revision``.
425 408
426 409 :param name: name for new tag
427 410 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
428 411 :param revision: changeset id for which new tag would be created
429 412 :param message: message of the tag's commit
430 413 :param date: date of tag's commit
431 414
432 415 :raises TagAlreadyExistError: if tag with same name already exists
433 416 """
434 417 if name in self.tags:
435 418 raise TagAlreadyExistError("Tag %s already exists" % name)
436 419 changeset = self.get_changeset(revision)
437 420 message = message or "Added tag %s for commit %s" % (name,
438 421 changeset.raw_id)
439 422 self._repo.refs["refs/tags/%s" % name] = changeset._commit.id
440 423
441 424 self._parsed_refs = self._get_parsed_refs()
442 425 self.tags = self._get_tags()
443 426 return changeset
444 427
445 428 def remove_tag(self, name, user, message=None, date=None):
446 429 """
447 430 Removes tag with the given ``name``.
448 431
449 432 :param name: name of the tag to be removed
450 433 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
451 434 :param message: message of the tag's removal commit
452 435 :param date: date of tag's removal commit
453 436
454 437 :raises TagDoesNotExistError: if tag with given name does not exists
455 438 """
456 439 if name not in self.tags:
457 440 raise TagDoesNotExistError("Tag %s does not exist" % name)
458 441 tagpath = posixpath.join(self._repo.refs.path, 'refs', 'tags', name)
459 442 try:
460 443 os.remove(tagpath)
461 444 self._parsed_refs = self._get_parsed_refs()
462 445 self.tags = self._get_tags()
463 446 except OSError, e:
464 447 raise RepositoryError(e.strerror)
465 448
466 449 @LazyProperty
467 450 def bookmarks(self):
468 451 """
469 452 Gets bookmarks for this repository
470 453 """
471 454 return {}
472 455
473 456 @LazyProperty
474 457 def _parsed_refs(self):
475 458 return self._get_parsed_refs()
476 459
477 460 def _get_parsed_refs(self):
478 461 # cache the property
479 462 _repo = self._repo
480 463 refs = _repo.get_refs()
481 464 keys = [('refs/heads/', 'H'),
482 465 ('refs/remotes/origin/', 'RH'),
483 466 ('refs/tags/', 'T')]
484 467 _refs = {}
485 468 for ref, sha in refs.iteritems():
486 469 for k, type_ in keys:
487 470 if ref.startswith(k):
488 471 _key = ref[len(k):]
489 472 if type_ == 'T':
490 473 obj = _repo.get_object(sha)
491 474 if isinstance(obj, Tag):
492 475 sha = _repo.get_object(sha).object[1]
493 476 _refs[_key] = [sha, type_]
494 477 break
495 478 return _refs
496 479
497 480 def _heads(self, reverse=False):
498 481 refs = self._repo.get_refs()
499 482 heads = {}
500 483
501 484 for key, val in refs.items():
502 485 for ref_key in ['refs/heads/', 'refs/remotes/origin/']:
503 486 if key.startswith(ref_key):
504 487 n = key[len(ref_key):]
505 488 if n not in ['HEAD']:
506 489 heads[n] = val
507 490
508 491 return heads if reverse else dict((y, x) for x, y in heads.iteritems())
509 492
510 493 def get_changeset(self, revision=None):
511 494 """
512 495 Returns ``GitChangeset`` object representing commit from git repository
513 496 at the given revision or head (most recent commit) if None given.
514 497 """
515 498 if isinstance(revision, GitChangeset):
516 499 return revision
517 500 revision = self._get_revision(revision)
518 501 changeset = GitChangeset(repository=self, revision=revision)
519 502 return changeset
520 503
521 504 def get_changesets(self, start=None, end=None, start_date=None,
522 505 end_date=None, branch_name=None, reverse=False):
523 506 """
524 507 Returns iterator of ``GitChangeset`` objects from start to end (both
525 508 are inclusive), in ascending date order (unless ``reverse`` is set).
526 509
527 510 :param start: changeset ID, as str; first returned changeset
528 511 :param end: changeset ID, as str; last returned changeset
529 512 :param start_date: if specified, changesets with commit date less than
530 513 ``start_date`` would be filtered out from returned set
531 514 :param end_date: if specified, changesets with commit date greater than
532 515 ``end_date`` would be filtered out from returned set
533 516 :param branch_name: if specified, changesets not reachable from given
534 517 branch would be filtered out from returned set
535 518 :param reverse: if ``True``, returned generator would be reversed
536 519 (meaning that returned changesets would have descending date order)
537 520
538 521 :raise BranchDoesNotExistError: If given ``branch_name`` does not
539 522 exist.
540 523 :raise ChangesetDoesNotExistError: If changeset for given ``start`` or
541 524 ``end`` could not be found.
542 525
543 526 """
544 527 if branch_name and branch_name not in self.branches:
545 528 raise BranchDoesNotExistError("Branch '%s' not found" \
546 529 % branch_name)
547 530 # actually we should check now if it's not an empty repo to not spaw
548 531 # subprocess commands
549 532 if self._empty:
550 533 raise EmptyRepositoryError("There are no changesets yet")
551 534
552 535 # %H at format means (full) commit hash, initial hashes are retrieved
553 536 # in ascending date order
554 cmd_template = 'log --date-order --reverse --pretty=format:"%H"'
555 cmd_params = {}
537 cmd = ['log', '--date-order', '--reverse', '--pretty=format:%H']
556 538 if start_date:
557 cmd_template += ' --since "$since"'
558 cmd_params['since'] = start_date.strftime('%m/%d/%y %H:%M:%S')
539 cmd += ['--since', start_date.strftime('%m/%d/%y %H:%M:%S')]
559 540 if end_date:
560 cmd_template += ' --until "$until"'
561 cmd_params['until'] = end_date.strftime('%m/%d/%y %H:%M:%S')
541 cmd += ['--until', end_date.strftime('%m/%d/%y %H:%M:%S')]
562 542 if branch_name:
563 cmd_template += ' $branch_name'
564 cmd_params['branch_name'] = branch_name
543 cmd.append(branch_name)
565 544 else:
566 rev_filter = settings.GIT_REV_FILTER
567 cmd_template += ' %s' % (rev_filter)
545 cmd.append(settings.GIT_REV_FILTER)
568 546
569 cmd = string.Template(cmd_template).safe_substitute(**cmd_params)
570 547 revs = self.run_git_command(cmd)[0].splitlines()
571 548 start_pos = 0
572 549 end_pos = len(revs)
573 550 if start:
574 551 _start = self._get_revision(start)
575 552 try:
576 553 start_pos = revs.index(_start)
577 554 except ValueError:
578 555 pass
579 556
580 557 if end is not None:
581 558 _end = self._get_revision(end)
582 559 try:
583 560 end_pos = revs.index(_end)
584 561 except ValueError:
585 562 pass
586 563
587 564 if None not in [start, end] and start_pos > end_pos:
588 565 raise RepositoryError('start cannot be after end')
589 566
590 567 if end_pos is not None:
591 568 end_pos += 1
592 569
593 570 revs = revs[start_pos:end_pos]
594 571 if reverse:
595 572 revs = reversed(revs)
596 573 return CollectionGenerator(self, revs)
597 574
598 575 def get_diff(self, rev1, rev2, path=None, ignore_whitespace=False,
599 576 context=3):
600 577 """
601 578 Returns (git like) *diff*, as plain text. Shows changes introduced by
602 579 ``rev2`` since ``rev1``.
603 580
604 581 :param rev1: Entry point from which diff is shown. Can be
605 582 ``self.EMPTY_CHANGESET`` - in this case, patch showing all
606 583 the changes since empty state of the repository until ``rev2``
607 584 :param rev2: Until which revision changes should be shown.
608 585 :param ignore_whitespace: If set to ``True``, would not show whitespace
609 586 changes. Defaults to ``False``.
610 587 :param context: How many lines before/after changed lines should be
611 588 shown. Defaults to ``3``.
612 589 """
613 590 flags = ['-U%s' % context, '--full-index', '--binary', '-p', '-M', '--abbrev=40']
614 591 if ignore_whitespace:
615 592 flags.append('-w')
616 593
617 594 if hasattr(rev1, 'raw_id'):
618 595 rev1 = getattr(rev1, 'raw_id')
619 596
620 597 if hasattr(rev2, 'raw_id'):
621 598 rev2 = getattr(rev2, 'raw_id')
622 599
623 600 if rev1 == self.EMPTY_CHANGESET:
624 601 rev2 = self.get_changeset(rev2).raw_id
625 cmd = ' '.join(['show'] + flags + [rev2])
602 cmd = ['show'] + flags + [rev2]
626 603 else:
627 604 rev1 = self.get_changeset(rev1).raw_id
628 605 rev2 = self.get_changeset(rev2).raw_id
629 cmd = ' '.join(['diff'] + flags + [rev1, rev2])
606 cmd = ['diff'] + flags + [rev1, rev2]
630 607
631 608 if path:
632 cmd += ' -- "%s"' % path
609 cmd += ['--', path]
633 610
634 611 stdout, stderr = self.run_git_command(cmd)
635 612 # TODO: don't ignore stderr
636 613 # If we used 'show' command, strip first few lines (until actual diff
637 614 # starts)
638 615 if rev1 == self.EMPTY_CHANGESET:
639 616 parts = stdout.split('\ndiff ', 1)
640 617 if len(parts) > 1:
641 618 stdout = 'diff ' + parts[1]
642 619 return stdout
643 620
644 621 @LazyProperty
645 622 def in_memory_changeset(self):
646 623 """
647 624 Returns ``GitInMemoryChangeset`` object for this repository.
648 625 """
649 626 return GitInMemoryChangeset(self)
650 627
651 628 def clone(self, url, update_after_clone=True, bare=False):
652 629 """
653 630 Tries to clone changes from external location.
654 631
655 632 :param update_after_clone: If set to ``False``, git won't checkout
656 633 working directory
657 634 :param bare: If set to ``True``, repository would be cloned into
658 635 *bare* git repository (no working directory at all).
659 636 """
660 637 url = self._get_url(url)
661 638 cmd = ['clone', '-q']
662 639 if bare:
663 640 cmd.append('--bare')
664 641 elif not update_after_clone:
665 642 cmd.append('--no-checkout')
666 cmd += ['--', quote(url), quote(self.path)]
667 cmd = ' '.join(cmd)
643 cmd += ['--', url, self.path]
668 644 # If error occurs run_git_command raises RepositoryError already
669 645 self.run_git_command(cmd)
670 646
671 647 def pull(self, url):
672 648 """
673 649 Tries to pull changes from external location.
674 650 """
675 651 url = self._get_url(url)
676 cmd = ['pull', "--ff-only", quote(url)]
677 cmd = ' '.join(cmd)
652 cmd = ['pull', '--ff-only', url]
678 653 # If error occurs run_git_command raises RepositoryError already
679 654 self.run_git_command(cmd)
680 655
681 656 def fetch(self, url):
682 657 """
683 658 Tries to pull changes from external location.
684 659 """
685 660 url = self._get_url(url)
686 so, se = self.run_git_command('ls-remote -h %s' % quote(url))
687 refs = []
661 so, se = self.run_git_command(['ls-remote', '-h', url])
662 cmd = ['fetch', url, '--']
688 663 for line in (x for x in so.splitlines()):
689 664 sha, ref = line.split('\t')
690 refs.append(ref)
691 refs = ' '.join(('+%s:%s' % (r, r) for r in refs))
692 cmd = '''fetch %s -- %s''' % (quote(url), refs)
665 cmd.append('+%s:%s' % (ref, ref))
693 666 self.run_git_command(cmd)
694 667
695 668 def _update_server_info(self):
696 669 """
697 670 runs gits update-server-info command in this repo instance
698 671 """
699 672 from dulwich.server import update_server_info
700 673 try:
701 674 update_server_info(self._repo)
702 675 except OSError, e:
703 676 if e.errno != errno.ENOENT:
704 677 raise
705 678 # Workaround for dulwich crashing on for example its own dulwich/tests/data/repos/simple_merge.git/info/refs.lock
706 679 log.error('Ignoring error running update-server-info: %s', e)
707 680
708 681 @LazyProperty
709 682 def workdir(self):
710 683 """
711 684 Returns ``Workdir`` instance for this repository.
712 685 """
713 686 return GitWorkdir(self)
714 687
715 688 def get_config_value(self, section, name, config_file=None):
716 689 """
717 690 Returns configuration value for a given [``section``] and ``name``.
718 691
719 692 :param section: Section we want to retrieve value from
720 693 :param name: Name of configuration we want to retrieve
721 694 :param config_file: A path to file which should be used to retrieve
722 695 configuration from (might also be a list of file paths)
723 696 """
724 697 if config_file is None:
725 698 config_file = []
726 699 elif isinstance(config_file, basestring):
727 700 config_file = [config_file]
728 701
729 702 def gen_configs():
730 703 for path in config_file + self._config_files:
731 704 try:
732 705 yield ConfigFile.from_path(path)
733 706 except (IOError, OSError, ValueError):
734 707 continue
735 708
736 709 for config in gen_configs():
737 710 try:
738 711 return config.get(section, name)
739 712 except KeyError:
740 713 continue
741 714 return None
742 715
743 716 def get_user_name(self, config_file=None):
744 717 """
745 718 Returns user's name from global configuration file.
746 719
747 720 :param config_file: A path to file which should be used to retrieve
748 721 configuration from (might also be a list of file paths)
749 722 """
750 723 return self.get_config_value('user', 'name', config_file)
751 724
752 725 def get_user_email(self, config_file=None):
753 726 """
754 727 Returns user's email from global configuration file.
755 728
756 729 :param config_file: A path to file which should be used to retrieve
757 730 configuration from (might also be a list of file paths)
758 731 """
759 732 return self.get_config_value('user', 'email', config_file)
@@ -1,427 +1,425 b''
1 1 """
2 2 Module provides a class allowing to wrap communication over subprocess.Popen
3 3 input, output, error streams into a meaningfull, non-blocking, concurrent
4 4 stream processor exposing the output data as an iterator fitting to be a
5 5 return value passed by a WSGI applicaiton to a WSGI server per PEP 3333.
6 6
7 7 Copyright (c) 2011 Daniel Dotsenko <dotsa[at]hotmail.com>
8 8
9 9 This file is part of git_http_backend.py Project.
10 10
11 11 git_http_backend.py Project is free software: you can redistribute it and/or
12 12 modify it under the terms of the GNU Lesser General Public License as
13 13 published by the Free Software Foundation, either version 2.1 of the License,
14 14 or (at your option) any later version.
15 15
16 16 git_http_backend.py Project is distributed in the hope that it will be useful,
17 17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 19 GNU Lesser General Public License for more details.
20 20
21 21 You should have received a copy of the GNU Lesser General Public License
22 22 along with git_http_backend.py Project.
23 23 If not, see <http://www.gnu.org/licenses/>.
24 24 """
25 25 import os
26 26 import subprocess
27 27 from kallithea.lib.vcs.utils.compat import deque, Event, Thread, _bytes, _bytearray
28 28
29 29
30 30 class StreamFeeder(Thread):
31 31 """
32 32 Normal writing into pipe-like is blocking once the buffer is filled.
33 33 This thread allows a thread to seep data from a file-like into a pipe
34 34 without blocking the main thread.
35 35 We close inpipe once the end of the source stream is reached.
36 36 """
37 37
38 38 def __init__(self, source):
39 39 super(StreamFeeder, self).__init__()
40 40 self.daemon = True
41 41 filelike = False
42 42 self.bytes = _bytes()
43 43 if type(source) in (type(''), _bytes, _bytearray): # string-like
44 44 self.bytes = _bytes(source)
45 45 else: # can be either file pointer or file-like
46 46 if type(source) in (int, long): # file pointer it is
47 47 ## converting file descriptor (int) stdin into file-like
48 48 source = os.fdopen(source, 'rb', 16384)
49 49 # let's see if source is file-like by now
50 50 filelike = hasattr(source, 'read')
51 51 if not filelike and not self.bytes:
52 52 raise TypeError("StreamFeeder's source object must be a readable "
53 53 "file-like, a file descriptor, or a string-like.")
54 54 self.source = source
55 55 self.readiface, self.writeiface = os.pipe()
56 56
57 57 def run(self):
58 58 t = self.writeiface
59 59 if self.bytes:
60 60 os.write(t, self.bytes)
61 61 else:
62 62 s = self.source
63 63 b = s.read(4096)
64 64 while b:
65 65 os.write(t, b)
66 66 b = s.read(4096)
67 67 os.close(t)
68 68
69 69 @property
70 70 def output(self):
71 71 return self.readiface
72 72
73 73
74 74 class InputStreamChunker(Thread):
75 75 def __init__(self, source, target, buffer_size, chunk_size):
76 76
77 77 super(InputStreamChunker, self).__init__()
78 78
79 79 self.daemon = True # die die die.
80 80
81 81 self.source = source
82 82 self.target = target
83 83 self.chunk_count_max = int(buffer_size / chunk_size) + 1
84 84 self.chunk_size = chunk_size
85 85
86 86 self.data_added = Event()
87 87 self.data_added.clear()
88 88
89 89 self.keep_reading = Event()
90 90 self.keep_reading.set()
91 91
92 92 self.EOF = Event()
93 93 self.EOF.clear()
94 94
95 95 self.go = Event()
96 96 self.go.set()
97 97
98 98 def stop(self):
99 99 self.go.clear()
100 100 self.EOF.set()
101 101 try:
102 102 # this is not proper, but is done to force the reader thread let
103 103 # go of the input because, if successful, .close() will send EOF
104 104 # down the pipe.
105 105 self.source.close()
106 106 except:
107 107 pass
108 108
109 109 def run(self):
110 110 s = self.source
111 111 t = self.target
112 112 cs = self.chunk_size
113 113 ccm = self.chunk_count_max
114 114 kr = self.keep_reading
115 115 da = self.data_added
116 116 go = self.go
117 117
118 118 try:
119 119 b = s.read(cs)
120 120 except ValueError:
121 121 b = ''
122 122
123 123 while b and go.is_set():
124 124 if len(t) > ccm:
125 125 kr.clear()
126 126 kr.wait(2)
127 127 # # this only works on 2.7.x and up
128 128 # if not kr.wait(10):
129 129 # raise Exception("Timed out while waiting for input to be read.")
130 130 # instead we'll use this
131 131 if len(t) > ccm + 3:
132 132 raise IOError(
133 133 "Timed out while waiting for input from subprocess.")
134 134 t.append(b)
135 135 da.set()
136 136 try:
137 137 b = s.read(cs)
138 138 except ValueError: # probably "I/O operation on closed file"
139 139 b = ''
140 140
141 141 self.EOF.set()
142 142 da.set() # for cases when done but there was no input.
143 143
144 144
145 145 class BufferedGenerator(object):
146 146 """
147 147 Class behaves as a non-blocking, buffered pipe reader.
148 148 Reads chunks of data (through a thread)
149 149 from a blocking pipe, and attaches these to an array (Deque) of chunks.
150 150 Reading is halted in the thread when max chunks is internally buffered.
151 151 The .next() may operate in blocking or non-blocking fashion by yielding
152 152 '' if no data is ready
153 153 to be sent or by not returning until there is some data to send
154 154 When we get EOF from underlying source pipe we raise the marker to raise
155 155 StopIteration after the last chunk of data is yielded.
156 156 """
157 157
158 158 def __init__(self, source, buffer_size=65536, chunk_size=4096,
159 159 starting_values=[], bottomless=False):
160 160
161 161 if bottomless:
162 162 maxlen = int(buffer_size / chunk_size)
163 163 else:
164 164 maxlen = None
165 165
166 166 self.data = deque(starting_values, maxlen)
167 167 self.worker = InputStreamChunker(source, self.data, buffer_size,
168 168 chunk_size)
169 169 if starting_values:
170 170 self.worker.data_added.set()
171 171 self.worker.start()
172 172
173 173 ####################
174 174 # Generator's methods
175 175 ####################
176 176
177 177 def __iter__(self):
178 178 return self
179 179
180 180 def next(self):
181 181 while not len(self.data) and not self.worker.EOF.is_set():
182 182 self.worker.data_added.clear()
183 183 self.worker.data_added.wait(0.2)
184 184 if len(self.data):
185 185 self.worker.keep_reading.set()
186 186 return _bytes(self.data.popleft())
187 187 elif self.worker.EOF.is_set():
188 188 raise StopIteration
189 189
190 190 def throw(self, type, value=None, traceback=None):
191 191 if not self.worker.EOF.is_set():
192 192 raise type(value)
193 193
194 194 def start(self):
195 195 self.worker.start()
196 196
197 197 def stop(self):
198 198 self.worker.stop()
199 199
200 200 def close(self):
201 201 try:
202 202 self.worker.stop()
203 203 self.throw(GeneratorExit)
204 204 except (GeneratorExit, StopIteration):
205 205 pass
206 206
207 207 def __del__(self):
208 208 self.close()
209 209
210 210 ####################
211 211 # Threaded reader's infrastructure.
212 212 ####################
213 213 @property
214 214 def input(self):
215 215 return self.worker.w
216 216
217 217 @property
218 218 def data_added_event(self):
219 219 return self.worker.data_added
220 220
221 221 @property
222 222 def data_added(self):
223 223 return self.worker.data_added.is_set()
224 224
225 225 @property
226 226 def reading_paused(self):
227 227 return not self.worker.keep_reading.is_set()
228 228
229 229 @property
230 230 def done_reading_event(self):
231 231 """
232 232 Done_reading does not mean that the iterator's buffer is empty.
233 233 Iterator might have done reading from underlying source, but the read
234 234 chunks might still be available for serving through .next() method.
235 235
236 236 :returns: An Event class instance.
237 237 """
238 238 return self.worker.EOF
239 239
240 240 @property
241 241 def done_reading(self):
242 242 """
243 243 Done_reading does not mean that the iterator's buffer is empty.
244 244 Iterator might have done reading from underlying source, but the read
245 245 chunks might still be available for serving through .next() method.
246 246
247 247 :returns: An Bool value.
248 248 """
249 249 return self.worker.EOF.is_set()
250 250
251 251 @property
252 252 def length(self):
253 253 """
254 254 returns int.
255 255
256 256 This is the length of the queue of chunks, not the length of
257 257 the combined contents in those chunks.
258 258
259 259 __len__() cannot be meaningfully implemented because this
260 260 reader is just flying through a bottomless pit content and
261 261 can only know the length of what it already saw.
262 262
263 263 If __len__() on WSGI server per PEP 3333 returns a value,
264 264 the response's length will be set to that. In order not to
265 265 confuse WSGI PEP3333 servers, we will not implement __len__
266 266 at all.
267 267 """
268 268 return len(self.data)
269 269
270 270 def prepend(self, x):
271 271 self.data.appendleft(x)
272 272
273 273 def append(self, x):
274 274 self.data.append(x)
275 275
276 276 def extend(self, o):
277 277 self.data.extend(o)
278 278
279 279 def __getitem__(self, i):
280 280 return self.data[i]
281 281
282 282
283 283 class SubprocessIOChunker(object):
284 284 """
285 285 Processor class wrapping handling of subprocess IO.
286 286
287 287 In a way, this is a "communicate()" replacement with a twist.
288 288
289 289 - We are multithreaded. Writing in and reading out, err are all sep threads.
290 290 - We support concurrent (in and out) stream processing.
291 291 - The output is not a stream. It's a queue of read string (bytes, not unicode)
292 292 chunks. The object behaves as an iterable. You can "for chunk in obj:" us.
293 293 - We are non-blocking in more respects than communicate()
294 294 (reading from subprocess out pauses when internal buffer is full, but
295 295 does not block the parent calling code. On the flip side, reading from
296 296 slow-yielding subprocess may block the iteration until data shows up. This
297 297 does not block the parallel inpipe reading occurring parallel thread.)
298 298
299 299 The purpose of the object is to allow us to wrap subprocess interactions into
300 300 an iterable that can be passed to a WSGI server as the application's return
301 301 value. Because of stream-processing-ability, WSGI does not have to read ALL
302 302 of the subprocess's output and buffer it, before handing it to WSGI server for
303 303 HTTP response. Instead, the class initializer reads just a bit of the stream
304 304 to figure out if error occurred or likely to occur and if not, just hands the
305 305 further iteration over subprocess output to the server for completion of HTTP
306 306 response.
307 307
308 308 The real or perceived subprocess error is trapped and raised as one of
309 309 EnvironmentError family of exceptions
310 310
311 311 Example usage:
312 312 # try:
313 313 # answer = SubprocessIOChunker(
314 314 # cmd,
315 315 # input,
316 316 # buffer_size = 65536,
317 317 # chunk_size = 4096
318 318 # )
319 319 # except (EnvironmentError) as e:
320 320 # print str(e)
321 321 # raise e
322 322 #
323 323 # return answer
324 324
325 325
326 326 """
327 327
328 328 def __init__(self, cmd, inputstream=None, buffer_size=65536,
329 329 chunk_size=4096, starting_values=[], **kwargs):
330 330 """
331 331 Initializes SubprocessIOChunker
332 332
333 333 :param cmd: A Subprocess.Popen style "cmd". Can be string or array of strings
334 334 :param inputstream: (Default: None) A file-like, string, or file pointer.
335 335 :param buffer_size: (Default: 65536) A size of total buffer per stream in bytes.
336 336 :param chunk_size: (Default: 4096) A max size of a chunk. Actual chunk may be smaller.
337 337 :param starting_values: (Default: []) An array of strings to put in front of output que.
338 338 """
339 339
340 340 if inputstream:
341 341 input_streamer = StreamFeeder(inputstream)
342 342 input_streamer.start()
343 343 inputstream = input_streamer.output
344 344
345 _shell = kwargs.get('shell', True)
346 if isinstance(cmd, (list, tuple)):
347 cmd = ' '.join(cmd)
345 # Note: fragile cmd mangling has been removed for use in Kallithea
346 assert isinstance(cmd, list), cmd
348 347
349 kwargs['shell'] = _shell
350 348 _p = subprocess.Popen(cmd, bufsize=-1,
351 349 stdin=inputstream,
352 350 stdout=subprocess.PIPE,
353 351 stderr=subprocess.PIPE,
354 352 **kwargs)
355 353
356 354 bg_out = BufferedGenerator(_p.stdout, buffer_size, chunk_size,
357 355 starting_values)
358 356 bg_err = BufferedGenerator(_p.stderr, 16000, 1, bottomless=True)
359 357
360 358 while not bg_out.done_reading and not bg_out.reading_paused and not bg_err.length:
361 359 # doing this until we reach either end of file, or end of buffer.
362 360 bg_out.data_added_event.wait(1)
363 361 bg_out.data_added_event.clear()
364 362
365 363 # at this point it's still ambiguous if we are done reading or just full buffer.
366 364 # Either way, if error (returned by ended process, or implied based on
367 365 # presence of stuff in stderr output) we error out.
368 366 # Else, we are happy.
369 367 _returncode = _p.poll()
370 368 if _returncode or (_returncode is None and bg_err.length):
371 369 try:
372 370 _p.terminate()
373 371 except Exception:
374 372 pass
375 373 bg_out.stop()
376 374 out = ''.join(bg_out)
377 375 bg_err.stop()
378 376 err = ''.join(bg_err)
379 377 if (err.strip() == 'fatal: The remote end hung up unexpectedly' and
380 378 out.startswith('0034shallow ')):
381 379 # hack inspired by https://github.com/schacon/grack/pull/7
382 380 bg_out = iter([out])
383 381 _p = None
384 382 elif err:
385 383 raise EnvironmentError(
386 384 "Subprocess exited due to an error:\n" + err)
387 385 else:
388 386 raise EnvironmentError(
389 387 "Subprocess exited with non 0 ret code:%s" % _returncode)
390 388 self.process = _p
391 389 self.output = bg_out
392 390 self.error = bg_err
393 391 self.inputstream = inputstream
394 392
395 393 def __iter__(self):
396 394 return self
397 395
398 396 def next(self):
399 397 if self.process and self.process.poll():
400 398 err = '%s' % ''.join(self.error)
401 399 raise EnvironmentError("Subprocess exited due to an error:\n" + err)
402 400 return self.output.next()
403 401
404 402 def throw(self, type, value=None, traceback=None):
405 403 if self.output.length or not self.output.done_reading:
406 404 raise type(value)
407 405
408 406 def close(self):
409 407 try:
410 408 self.process.terminate()
411 409 except:
412 410 pass
413 411 try:
414 412 self.output.close()
415 413 except:
416 414 pass
417 415 try:
418 416 self.error.close()
419 417 except:
420 418 pass
421 419 try:
422 420 os.close(self.inputstream)
423 421 except:
424 422 pass
425 423
426 424 def __del__(self):
427 425 self.close()
@@ -1,758 +1,758 b''
1 1 from __future__ import with_statement
2 2
3 3 import os
4 4 import sys
5 5 import mock
6 6 import datetime
7 7 import urllib2
8 8 from kallithea.lib.vcs.backends.git import GitRepository, GitChangeset
9 9 from kallithea.lib.vcs.exceptions import RepositoryError, VCSError, NodeDoesNotExistError
10 10 from kallithea.lib.vcs.nodes import NodeKind, FileNode, DirNode, NodeState
11 11 from kallithea.lib.vcs.utils.compat import unittest
12 12 from kallithea.tests.vcs.base import _BackendTestMixin
13 13 from kallithea.tests.vcs.conf import TEST_GIT_REPO, TEST_GIT_REPO_CLONE, get_new_dir
14 14
15 15
16 16 class GitRepositoryTest(unittest.TestCase):
17 17
18 18 def __check_for_existing_repo(self):
19 19 if os.path.exists(TEST_GIT_REPO_CLONE):
20 20 self.fail('Cannot test git clone repo as location %s already '
21 21 'exists. You should manually remove it first.'
22 22 % TEST_GIT_REPO_CLONE)
23 23
24 24 def setUp(self):
25 25 self.repo = GitRepository(TEST_GIT_REPO)
26 26
27 27 def test_wrong_repo_path(self):
28 28 wrong_repo_path = '/tmp/errorrepo'
29 29 self.assertRaises(RepositoryError, GitRepository, wrong_repo_path)
30 30
31 31 def test_git_cmd_injection(self):
32 32 repo_inject_path = TEST_GIT_REPO + '; echo "Cake";'
33 33 with self.assertRaises(urllib2.URLError):
34 34 # Should fail because URL will contain the parts after ; too
35 35 urlerror_fail_repo = GitRepository(get_new_dir('injection-repo'), src_url=repo_inject_path, update_after_clone=True, create=True)
36 36
37 37 with self.assertRaises(RepositoryError):
38 38 # Should fail on direct clone call, which as of this writing does not happen outside of class
39 39 clone_fail_repo = GitRepository(get_new_dir('injection-repo'), create=True)
40 40 clone_fail_repo.clone(repo_inject_path, update_after_clone=True,)
41 41
42 42 # Verify correct quoting of evil characters that should work on posix file systems
43 43 if sys.platform == 'win32':
44 44 # windows does not allow '"' in dir names
45 45 tricky_path = get_new_dir("tricky-path-repo-$'`")
46 46 else:
47 47 tricky_path = get_new_dir("tricky-path-repo-$'\"`")
48 48 successfully_cloned = GitRepository(tricky_path, src_url=TEST_GIT_REPO, update_after_clone=True, create=True)
49 49 # Repo should have been created
50 50 self.assertFalse(successfully_cloned._repo.bare)
51 51
52 52 if sys.platform == 'win32':
53 53 # windows does not allow '"' in dir names
54 54 tricky_path_2 = get_new_dir("tricky-path-2-repo-$'`")
55 55 else:
56 56 tricky_path_2 = get_new_dir("tricky-path-2-repo-$'\"`")
57 57 successfully_cloned2 = GitRepository(tricky_path_2, src_url=tricky_path, bare=True, create=True)
58 58 # Repo should have been created and thus used correct quoting for clone
59 59 self.assertTrue(successfully_cloned2._repo.bare)
60 60
61 61 # Should pass because URL has been properly quoted
62 62 successfully_cloned.pull(tricky_path_2)
63 63 successfully_cloned2.fetch(tricky_path)
64 64
65 65 def test_repo_create_with_spaces_in_path(self):
66 66 repo_path = get_new_dir("path with spaces")
67 67 repo = GitRepository(repo_path, src_url=None, bare=True, create=True)
68 68 # Repo should have been created
69 69 self.assertTrue(repo._repo.bare)
70 70
71 71 def test_repo_clone(self):
72 72 self.__check_for_existing_repo()
73 73 repo = GitRepository(TEST_GIT_REPO)
74 74 repo_clone = GitRepository(TEST_GIT_REPO_CLONE,
75 75 src_url=TEST_GIT_REPO, create=True, update_after_clone=True)
76 76 self.assertEqual(len(repo.revisions), len(repo_clone.revisions))
77 77 # Checking hashes of changesets should be enough
78 78 for changeset in repo.get_changesets():
79 79 raw_id = changeset.raw_id
80 80 self.assertEqual(raw_id, repo_clone.get_changeset(raw_id).raw_id)
81 81
82 82 def test_repo_clone_with_spaces_in_path(self):
83 83 repo_path = get_new_dir("path with spaces")
84 84 successfully_cloned = GitRepository(repo_path, src_url=TEST_GIT_REPO, update_after_clone=True, create=True)
85 85 # Repo should have been created
86 86 self.assertFalse(successfully_cloned._repo.bare)
87 87
88 88 successfully_cloned.pull(TEST_GIT_REPO)
89 89 self.repo.fetch(repo_path)
90 90
91 91 def test_repo_clone_without_create(self):
92 92 self.assertRaises(RepositoryError, GitRepository,
93 93 TEST_GIT_REPO_CLONE + '_wo_create', src_url=TEST_GIT_REPO)
94 94
95 95 def test_repo_clone_with_update(self):
96 96 repo = GitRepository(TEST_GIT_REPO)
97 97 clone_path = TEST_GIT_REPO_CLONE + '_with_update'
98 98 repo_clone = GitRepository(clone_path,
99 99 create=True, src_url=TEST_GIT_REPO, update_after_clone=True)
100 100 self.assertEqual(len(repo.revisions), len(repo_clone.revisions))
101 101
102 102 #check if current workdir was updated
103 103 fpath = os.path.join(clone_path, 'MANIFEST.in')
104 104 self.assertEqual(True, os.path.isfile(fpath),
105 105 'Repo was cloned and updated but file %s could not be found'
106 106 % fpath)
107 107
108 108 def test_repo_clone_without_update(self):
109 109 repo = GitRepository(TEST_GIT_REPO)
110 110 clone_path = TEST_GIT_REPO_CLONE + '_without_update'
111 111 repo_clone = GitRepository(clone_path,
112 112 create=True, src_url=TEST_GIT_REPO, update_after_clone=False)
113 113 self.assertEqual(len(repo.revisions), len(repo_clone.revisions))
114 114 #check if current workdir was *NOT* updated
115 115 fpath = os.path.join(clone_path, 'MANIFEST.in')
116 116 # Make sure it's not bare repo
117 117 self.assertFalse(repo_clone._repo.bare)
118 118 self.assertEqual(False, os.path.isfile(fpath),
119 119 'Repo was cloned and updated but file %s was found'
120 120 % fpath)
121 121
122 122 def test_repo_clone_into_bare_repo(self):
123 123 repo = GitRepository(TEST_GIT_REPO)
124 124 clone_path = TEST_GIT_REPO_CLONE + '_bare.git'
125 125 repo_clone = GitRepository(clone_path, create=True,
126 126 src_url=repo.path, bare=True)
127 127 self.assertTrue(repo_clone._repo.bare)
128 128
129 129 def test_create_repo_is_not_bare_by_default(self):
130 130 repo = GitRepository(get_new_dir('not-bare-by-default'), create=True)
131 131 self.assertFalse(repo._repo.bare)
132 132
133 133 def test_create_bare_repo(self):
134 134 repo = GitRepository(get_new_dir('bare-repo'), create=True, bare=True)
135 135 self.assertTrue(repo._repo.bare)
136 136
137 137 def test_revisions(self):
138 138 # there are 112 revisions (by now)
139 139 # so we can assume they would be available from now on
140 140 subset = set([
141 141 'c1214f7e79e02fc37156ff215cd71275450cffc3',
142 142 '38b5fe81f109cb111f549bfe9bb6b267e10bc557',
143 143 'fa6600f6848800641328adbf7811fd2372c02ab2',
144 144 '102607b09cdd60e2793929c4f90478be29f85a17',
145 145 '49d3fd156b6f7db46313fac355dca1a0b94a0017',
146 146 '2d1028c054665b962fa3d307adfc923ddd528038',
147 147 'd7e0d30fbcae12c90680eb095a4f5f02505ce501',
148 148 'ff7ca51e58c505fec0dd2491de52c622bb7a806b',
149 149 'dd80b0f6cf5052f17cc738c2951c4f2070200d7f',
150 150 '8430a588b43b5d6da365400117c89400326e7992',
151 151 'd955cd312c17b02143c04fa1099a352b04368118',
152 152 'f67b87e5c629c2ee0ba58f85197e423ff28d735b',
153 153 'add63e382e4aabc9e1afdc4bdc24506c269b7618',
154 154 'f298fe1189f1b69779a4423f40b48edf92a703fc',
155 155 'bd9b619eb41994cac43d67cf4ccc8399c1125808',
156 156 '6e125e7c890379446e98980d8ed60fba87d0f6d1',
157 157 'd4a54db9f745dfeba6933bf5b1e79e15d0af20bd',
158 158 '0b05e4ed56c802098dfc813cbe779b2f49e92500',
159 159 '191caa5b2c81ed17c0794bf7bb9958f4dcb0b87e',
160 160 '45223f8f114c64bf4d6f853e3c35a369a6305520',
161 161 'ca1eb7957a54bce53b12d1a51b13452f95bc7c7e',
162 162 'f5ea29fc42ef67a2a5a7aecff10e1566699acd68',
163 163 '27d48942240f5b91dfda77accd2caac94708cc7d',
164 164 '622f0eb0bafd619d2560c26f80f09e3b0b0d78af',
165 165 'e686b958768ee96af8029fe19c6050b1a8dd3b2b'])
166 166 self.assertTrue(subset.issubset(set(self.repo.revisions)))
167 167
168 168
169 169
170 170 def test_slicing(self):
171 171 #4 1 5 10 95
172 172 for sfrom, sto, size in [(0, 4, 4), (1, 2, 1), (10, 15, 5),
173 173 (10, 20, 10), (5, 100, 95)]:
174 174 revs = list(self.repo[sfrom:sto])
175 175 self.assertEqual(len(revs), size)
176 176 self.assertEqual(revs[0], self.repo.get_changeset(sfrom))
177 177 self.assertEqual(revs[-1], self.repo.get_changeset(sto - 1))
178 178
179 179
180 180 def test_branches(self):
181 181 # TODO: Need more tests here
182 182 # Removed (those are 'remotes' branches for cloned repo)
183 183 #self.assertTrue('master' in self.repo.branches)
184 184 #self.assertTrue('gittree' in self.repo.branches)
185 185 #self.assertTrue('web-branch' in self.repo.branches)
186 186 for name, id in self.repo.branches.items():
187 187 self.assertTrue(isinstance(
188 188 self.repo.get_changeset(id), GitChangeset))
189 189
190 190 def test_tags(self):
191 191 # TODO: Need more tests here
192 192 self.assertTrue('v0.1.1' in self.repo.tags)
193 193 self.assertTrue('v0.1.2' in self.repo.tags)
194 194 for name, id in self.repo.tags.items():
195 195 self.assertTrue(isinstance(
196 196 self.repo.get_changeset(id), GitChangeset))
197 197
198 198 def _test_single_changeset_cache(self, revision):
199 199 chset = self.repo.get_changeset(revision)
200 200 self.assertTrue(revision in self.repo.changesets)
201 201 self.assertTrue(chset is self.repo.changesets[revision])
202 202
203 203 def test_initial_changeset(self):
204 204 id = self.repo.revisions[0]
205 205 init_chset = self.repo.get_changeset(id)
206 206 self.assertEqual(init_chset.message, 'initial import\n')
207 207 self.assertEqual(init_chset.author,
208 208 'Marcin Kuzminski <marcin@python-blog.com>')
209 209 for path in ('vcs/__init__.py',
210 210 'vcs/backends/BaseRepository.py',
211 211 'vcs/backends/__init__.py'):
212 212 self.assertTrue(isinstance(init_chset.get_node(path), FileNode))
213 213 for path in ('', 'vcs', 'vcs/backends'):
214 214 self.assertTrue(isinstance(init_chset.get_node(path), DirNode))
215 215
216 216 self.assertRaises(NodeDoesNotExistError, init_chset.get_node, path='foobar')
217 217
218 218 node = init_chset.get_node('vcs/')
219 219 self.assertTrue(hasattr(node, 'kind'))
220 220 self.assertEqual(node.kind, NodeKind.DIR)
221 221
222 222 node = init_chset.get_node('vcs')
223 223 self.assertTrue(hasattr(node, 'kind'))
224 224 self.assertEqual(node.kind, NodeKind.DIR)
225 225
226 226 node = init_chset.get_node('vcs/__init__.py')
227 227 self.assertTrue(hasattr(node, 'kind'))
228 228 self.assertEqual(node.kind, NodeKind.FILE)
229 229
230 230 def test_not_existing_changeset(self):
231 231 self.assertRaises(RepositoryError, self.repo.get_changeset,
232 232 'f' * 40)
233 233
234 234 def test_changeset10(self):
235 235
236 236 chset10 = self.repo.get_changeset(self.repo.revisions[9])
237 237 README = """===
238 238 VCS
239 239 ===
240 240
241 241 Various Version Control System management abstraction layer for Python.
242 242
243 243 Introduction
244 244 ------------
245 245
246 246 TODO: To be written...
247 247
248 248 """
249 249 node = chset10.get_node('README.rst')
250 250 self.assertEqual(node.kind, NodeKind.FILE)
251 251 self.assertEqual(node.content, README)
252 252
253 253
254 254 class GitChangesetTest(unittest.TestCase):
255 255
256 256 def setUp(self):
257 257 self.repo = GitRepository(TEST_GIT_REPO)
258 258
259 259 def test_default_changeset(self):
260 260 tip = self.repo.get_changeset()
261 261 self.assertEqual(tip, self.repo.get_changeset(None))
262 262 self.assertEqual(tip, self.repo.get_changeset('tip'))
263 263
264 264 def test_root_node(self):
265 265 tip = self.repo.get_changeset()
266 266 self.assertTrue(tip.root is tip.get_node(''))
267 267
268 268 def test_lazy_fetch(self):
269 269 """
270 270 Test if changeset's nodes expands and are cached as we walk through
271 271 the revision. This test is somewhat hard to write as order of tests
272 272 is a key here. Written by running command after command in a shell.
273 273 """
274 274 hex = '2a13f185e4525f9d4b59882791a2d397b90d5ddc'
275 275 self.assertTrue(hex in self.repo.revisions)
276 276 chset = self.repo.get_changeset(hex)
277 277 self.assertTrue(len(chset.nodes) == 0)
278 278 root = chset.root
279 279 self.assertTrue(len(chset.nodes) == 1)
280 280 self.assertTrue(len(root.nodes) == 8)
281 281 # accessing root.nodes updates chset.nodes
282 282 self.assertTrue(len(chset.nodes) == 9)
283 283
284 284 docs = root.get_node('docs')
285 285 # we haven't yet accessed anything new as docs dir was already cached
286 286 self.assertTrue(len(chset.nodes) == 9)
287 287 self.assertTrue(len(docs.nodes) == 8)
288 288 # accessing docs.nodes updates chset.nodes
289 289 self.assertTrue(len(chset.nodes) == 17)
290 290
291 291 self.assertTrue(docs is chset.get_node('docs'))
292 292 self.assertTrue(docs is root.nodes[0])
293 293 self.assertTrue(docs is root.dirs[0])
294 294 self.assertTrue(docs is chset.get_node('docs'))
295 295
296 296 def test_nodes_with_changeset(self):
297 297 hex = '2a13f185e4525f9d4b59882791a2d397b90d5ddc'
298 298 chset = self.repo.get_changeset(hex)
299 299 root = chset.root
300 300 docs = root.get_node('docs')
301 301 self.assertTrue(docs is chset.get_node('docs'))
302 302 api = docs.get_node('api')
303 303 self.assertTrue(api is chset.get_node('docs/api'))
304 304 index = api.get_node('index.rst')
305 305 self.assertTrue(index is chset.get_node('docs/api/index.rst'))
306 306 self.assertTrue(index is chset.get_node('docs')\
307 307 .get_node('api')\
308 308 .get_node('index.rst'))
309 309
310 310 def test_branch_and_tags(self):
311 311 """
312 312 rev0 = self.repo.revisions[0]
313 313 chset0 = self.repo.get_changeset(rev0)
314 314 self.assertEqual(chset0.branch, 'master')
315 315 self.assertEqual(chset0.tags, [])
316 316
317 317 rev10 = self.repo.revisions[10]
318 318 chset10 = self.repo.get_changeset(rev10)
319 319 self.assertEqual(chset10.branch, 'master')
320 320 self.assertEqual(chset10.tags, [])
321 321
322 322 rev44 = self.repo.revisions[44]
323 323 chset44 = self.repo.get_changeset(rev44)
324 324 self.assertEqual(chset44.branch, 'web-branch')
325 325
326 326 tip = self.repo.get_changeset('tip')
327 327 self.assertTrue('tip' in tip.tags)
328 328 """
329 329 # Those tests would fail - branches are now going
330 330 # to be changed at main API in order to support git backend
331 331 pass
332 332
333 333 def _test_slices(self, limit, offset):
334 334 count = self.repo.count()
335 335 changesets = self.repo.get_changesets(limit=limit, offset=offset)
336 336 idx = 0
337 337 for changeset in changesets:
338 338 rev = offset + idx
339 339 idx += 1
340 340 rev_id = self.repo.revisions[rev]
341 341 if idx > limit:
342 342 self.fail("Exceeded limit already (getting revision %s, "
343 343 "there are %s total revisions, offset=%s, limit=%s)"
344 344 % (rev_id, count, offset, limit))
345 345 self.assertEqual(changeset, self.repo.get_changeset(rev_id))
346 346 result = list(self.repo.get_changesets(limit=limit, offset=offset))
347 347 start = offset
348 348 end = limit and offset + limit or None
349 349 sliced = list(self.repo[start:end])
350 350 self.failUnlessEqual(result, sliced,
351 351 msg="Comparison failed for limit=%s, offset=%s"
352 352 "(get_changeset returned: %s and sliced: %s"
353 353 % (limit, offset, result, sliced))
354 354
355 355 def _test_file_size(self, revision, path, size):
356 356 node = self.repo.get_changeset(revision).get_node(path)
357 357 self.assertTrue(node.is_file())
358 358 self.assertEqual(node.size, size)
359 359
360 360 def test_file_size(self):
361 361 to_check = (
362 362 ('c1214f7e79e02fc37156ff215cd71275450cffc3',
363 363 'vcs/backends/BaseRepository.py', 502),
364 364 ('d7e0d30fbcae12c90680eb095a4f5f02505ce501',
365 365 'vcs/backends/hg.py', 854),
366 366 ('6e125e7c890379446e98980d8ed60fba87d0f6d1',
367 367 'setup.py', 1068),
368 368
369 369 ('d955cd312c17b02143c04fa1099a352b04368118',
370 370 'vcs/backends/base.py', 2921),
371 371 ('ca1eb7957a54bce53b12d1a51b13452f95bc7c7e',
372 372 'vcs/backends/base.py', 3936),
373 373 ('f50f42baeed5af6518ef4b0cb2f1423f3851a941',
374 374 'vcs/backends/base.py', 6189),
375 375 )
376 376 for revision, path, size in to_check:
377 377 self._test_file_size(revision, path, size)
378 378
379 379 def test_file_history(self):
380 380 # we can only check if those revisions are present in the history
381 381 # as we cannot update this test every time file is changed
382 382 files = {
383 383 'setup.py': [
384 384 '54386793436c938cff89326944d4c2702340037d',
385 385 '51d254f0ecf5df2ce50c0b115741f4cf13985dab',
386 386 '998ed409c795fec2012b1c0ca054d99888b22090',
387 387 '5e0eb4c47f56564395f76333f319d26c79e2fb09',
388 388 '0115510b70c7229dbc5dc49036b32e7d91d23acd',
389 389 '7cb3fd1b6d8c20ba89e2264f1c8baebc8a52d36e',
390 390 '2a13f185e4525f9d4b59882791a2d397b90d5ddc',
391 391 '191caa5b2c81ed17c0794bf7bb9958f4dcb0b87e',
392 392 'ff7ca51e58c505fec0dd2491de52c622bb7a806b',
393 393 ],
394 394 'vcs/nodes.py': [
395 395 '33fa3223355104431402a888fa77a4e9956feb3e',
396 396 'fa014c12c26d10ba682fadb78f2a11c24c8118e1',
397 397 'e686b958768ee96af8029fe19c6050b1a8dd3b2b',
398 398 'ab5721ca0a081f26bf43d9051e615af2cc99952f',
399 399 'c877b68d18e792a66b7f4c529ea02c8f80801542',
400 400 '4313566d2e417cb382948f8d9d7c765330356054',
401 401 '6c2303a793671e807d1cfc70134c9ca0767d98c2',
402 402 '54386793436c938cff89326944d4c2702340037d',
403 403 '54000345d2e78b03a99d561399e8e548de3f3203',
404 404 '1c6b3677b37ea064cb4b51714d8f7498f93f4b2b',
405 405 '2d03ca750a44440fb5ea8b751176d1f36f8e8f46',
406 406 '2a08b128c206db48c2f0b8f70df060e6db0ae4f8',
407 407 '30c26513ff1eb8e5ce0e1c6b477ee5dc50e2f34b',
408 408 'ac71e9503c2ca95542839af0ce7b64011b72ea7c',
409 409 '12669288fd13adba2a9b7dd5b870cc23ffab92d2',
410 410 '5a0c84f3e6fe3473e4c8427199d5a6fc71a9b382',
411 411 '12f2f5e2b38e6ff3fbdb5d722efed9aa72ecb0d5',
412 412 '5eab1222a7cd4bfcbabc218ca6d04276d4e27378',
413 413 'f50f42baeed5af6518ef4b0cb2f1423f3851a941',
414 414 'd7e390a45f6aa96f04f5e7f583ad4f867431aa25',
415 415 'f15c21f97864b4f071cddfbf2750ec2e23859414',
416 416 'e906ef056cf539a4e4e5fc8003eaf7cf14dd8ade',
417 417 'ea2b108b48aa8f8c9c4a941f66c1a03315ca1c3b',
418 418 '84dec09632a4458f79f50ddbbd155506c460b4f9',
419 419 '0115510b70c7229dbc5dc49036b32e7d91d23acd',
420 420 '2a13f185e4525f9d4b59882791a2d397b90d5ddc',
421 421 '3bf1c5868e570e39569d094f922d33ced2fa3b2b',
422 422 'b8d04012574729d2c29886e53b1a43ef16dd00a1',
423 423 '6970b057cffe4aab0a792aa634c89f4bebf01441',
424 424 'dd80b0f6cf5052f17cc738c2951c4f2070200d7f',
425 425 'ff7ca51e58c505fec0dd2491de52c622bb7a806b',
426 426 ],
427 427 'vcs/backends/git.py': [
428 428 '4cf116ad5a457530381135e2f4c453e68a1b0105',
429 429 '9a751d84d8e9408e736329767387f41b36935153',
430 430 'cb681fb539c3faaedbcdf5ca71ca413425c18f01',
431 431 '428f81bb652bcba8d631bce926e8834ff49bdcc6',
432 432 '180ab15aebf26f98f714d8c68715e0f05fa6e1c7',
433 433 '2b8e07312a2e89e92b90426ab97f349f4bce2a3a',
434 434 '50e08c506174d8645a4bb517dd122ac946a0f3bf',
435 435 '54000345d2e78b03a99d561399e8e548de3f3203',
436 436 ],
437 437 }
438 438 for path, revs in files.items():
439 439 node = self.repo.get_changeset(revs[0]).get_node(path)
440 440 node_revs = [chset.raw_id for chset in node.history]
441 441 self.assertTrue(set(revs).issubset(set(node_revs)),
442 442 "We assumed that %s is subset of revisions for which file %s "
443 443 "has been changed, and history of that node returned: %s"
444 444 % (revs, path, node_revs))
445 445
446 446 def test_file_annotate(self):
447 447 files = {
448 448 'vcs/backends/__init__.py': {
449 449 'c1214f7e79e02fc37156ff215cd71275450cffc3': {
450 450 'lines_no': 1,
451 451 'changesets': [
452 452 'c1214f7e79e02fc37156ff215cd71275450cffc3',
453 453 ],
454 454 },
455 455 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647': {
456 456 'lines_no': 21,
457 457 'changesets': [
458 458 '49d3fd156b6f7db46313fac355dca1a0b94a0017',
459 459 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
460 460 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
461 461 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
462 462 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
463 463 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
464 464 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
465 465 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
466 466 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
467 467 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
468 468 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
469 469 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
470 470 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
471 471 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
472 472 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
473 473 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
474 474 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
475 475 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
476 476 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
477 477 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
478 478 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
479 479 ],
480 480 },
481 481 'e29b67bd158580fc90fc5e9111240b90e6e86064': {
482 482 'lines_no': 32,
483 483 'changesets': [
484 484 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
485 485 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
486 486 '5eab1222a7cd4bfcbabc218ca6d04276d4e27378',
487 487 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
488 488 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
489 489 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
490 490 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
491 491 '54000345d2e78b03a99d561399e8e548de3f3203',
492 492 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
493 493 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
494 494 '78c3f0c23b7ee935ec276acb8b8212444c33c396',
495 495 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
496 496 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
497 497 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
498 498 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
499 499 '2a13f185e4525f9d4b59882791a2d397b90d5ddc',
500 500 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
501 501 '78c3f0c23b7ee935ec276acb8b8212444c33c396',
502 502 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
503 503 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
504 504 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
505 505 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
506 506 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
507 507 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
508 508 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
509 509 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
510 510 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
511 511 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
512 512 '992f38217b979d0b0987d0bae3cc26dac85d9b19',
513 513 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
514 514 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
515 515 '16fba1ae9334d79b66d7afed2c2dfbfa2ae53647',
516 516 ],
517 517 },
518 518 },
519 519 }
520 520
521 521 for fname, revision_dict in files.items():
522 522 for rev, data in revision_dict.items():
523 523 cs = self.repo.get_changeset(rev)
524 524
525 525 l1_1 = [x[1] for x in cs.get_file_annotate(fname)]
526 526 l1_2 = [x[2]().raw_id for x in cs.get_file_annotate(fname)]
527 527 self.assertEqual(l1_1, l1_2)
528 528 l1 = l1_1
529 529 l2 = files[fname][rev]['changesets']
530 530 self.assertTrue(l1 == l2 , "The lists of revision for %s@rev %s"
531 531 "from annotation list should match each other, "
532 532 "got \n%s \nvs \n%s " % (fname, rev, l1, l2))
533 533
534 534 def test_files_state(self):
535 535 """
536 536 Tests state of FileNodes.
537 537 """
538 538 node = self.repo\
539 539 .get_changeset('e6ea6d16e2f26250124a1f4b4fe37a912f9d86a0')\
540 540 .get_node('vcs/utils/diffs.py')
541 541 self.assertTrue(node.state, NodeState.ADDED)
542 542 self.assertTrue(node.added)
543 543 self.assertFalse(node.changed)
544 544 self.assertFalse(node.not_changed)
545 545 self.assertFalse(node.removed)
546 546
547 547 node = self.repo\
548 548 .get_changeset('33fa3223355104431402a888fa77a4e9956feb3e')\
549 549 .get_node('.hgignore')
550 550 self.assertTrue(node.state, NodeState.CHANGED)
551 551 self.assertFalse(node.added)
552 552 self.assertTrue(node.changed)
553 553 self.assertFalse(node.not_changed)
554 554 self.assertFalse(node.removed)
555 555
556 556 node = self.repo\
557 557 .get_changeset('e29b67bd158580fc90fc5e9111240b90e6e86064')\
558 558 .get_node('setup.py')
559 559 self.assertTrue(node.state, NodeState.NOT_CHANGED)
560 560 self.assertFalse(node.added)
561 561 self.assertFalse(node.changed)
562 562 self.assertTrue(node.not_changed)
563 563 self.assertFalse(node.removed)
564 564
565 565 # If node has REMOVED state then trying to fetch it would raise
566 566 # ChangesetError exception
567 567 chset = self.repo.get_changeset(
568 568 'fa6600f6848800641328adbf7811fd2372c02ab2')
569 569 path = 'vcs/backends/BaseRepository.py'
570 570 self.assertRaises(NodeDoesNotExistError, chset.get_node, path)
571 571 # but it would be one of ``removed`` (changeset's attribute)
572 572 self.assertTrue(path in [rf.path for rf in chset.removed])
573 573
574 574 chset = self.repo.get_changeset(
575 575 '54386793436c938cff89326944d4c2702340037d')
576 576 changed = ['setup.py', 'tests/test_nodes.py', 'vcs/backends/hg.py',
577 577 'vcs/nodes.py']
578 578 self.assertEqual(set(changed), set([f.path for f in chset.changed]))
579 579
580 580 def test_commit_message_is_unicode(self):
581 581 for cs in self.repo:
582 582 self.assertEqual(type(cs.message), unicode)
583 583
584 584 def test_changeset_author_is_unicode(self):
585 585 for cs in self.repo:
586 586 self.assertEqual(type(cs.author), unicode)
587 587
588 588 def test_repo_files_content_is_unicode(self):
589 589 changeset = self.repo.get_changeset()
590 590 for node in changeset.get_node('/'):
591 591 if node.is_file():
592 592 self.assertEqual(type(node.content), unicode)
593 593
594 594 def test_wrong_path(self):
595 595 # There is 'setup.py' in the root dir but not there:
596 596 path = 'foo/bar/setup.py'
597 597 tip = self.repo.get_changeset()
598 598 self.assertRaises(VCSError, tip.get_node, path)
599 599
600 600 def test_author_email(self):
601 601 self.assertEqual('marcin@python-blog.com',
602 602 self.repo.get_changeset('c1214f7e79e02fc37156ff215cd71275450cffc3')\
603 603 .author_email)
604 604 self.assertEqual('lukasz.balcerzak@python-center.pl',
605 605 self.repo.get_changeset('ff7ca51e58c505fec0dd2491de52c622bb7a806b')\
606 606 .author_email)
607 607 self.assertEqual('none@none',
608 608 self.repo.get_changeset('8430a588b43b5d6da365400117c89400326e7992')\
609 609 .author_email)
610 610
611 611 def test_author_username(self):
612 612 self.assertEqual('Marcin Kuzminski',
613 613 self.repo.get_changeset('c1214f7e79e02fc37156ff215cd71275450cffc3')\
614 614 .author_name)
615 615 self.assertEqual('Lukasz Balcerzak',
616 616 self.repo.get_changeset('ff7ca51e58c505fec0dd2491de52c622bb7a806b')\
617 617 .author_name)
618 618 self.assertEqual('marcink',
619 619 self.repo.get_changeset('8430a588b43b5d6da365400117c89400326e7992')\
620 620 .author_name)
621 621
622 622
623 623 class GitSpecificTest(unittest.TestCase):
624 624
625 625 def test_error_is_raised_for_added_if_diff_name_status_is_wrong(self):
626 626 repo = mock.MagicMock()
627 627 changeset = GitChangeset(repo, 'foobar')
628 628 changeset._diff_name_status = 'foobar'
629 629 with self.assertRaises(VCSError):
630 630 changeset.added
631 631
632 632 def test_error_is_raised_for_changed_if_diff_name_status_is_wrong(self):
633 633 repo = mock.MagicMock()
634 634 changeset = GitChangeset(repo, 'foobar')
635 635 changeset._diff_name_status = 'foobar'
636 636 with self.assertRaises(VCSError):
637 637 changeset.added
638 638
639 639 def test_error_is_raised_for_removed_if_diff_name_status_is_wrong(self):
640 640 repo = mock.MagicMock()
641 641 changeset = GitChangeset(repo, 'foobar')
642 642 changeset._diff_name_status = 'foobar'
643 643 with self.assertRaises(VCSError):
644 644 changeset.added
645 645
646 646
647 647 class GitSpecificWithRepoTest(_BackendTestMixin, unittest.TestCase):
648 648 backend_alias = 'git'
649 649
650 650 @classmethod
651 651 def _get_commits(cls):
652 652 return [
653 653 {
654 654 'message': 'Initial',
655 655 'author': 'Joe Doe <joe.doe@example.com>',
656 656 'date': datetime.datetime(2010, 1, 1, 20),
657 657 'added': [
658 658 FileNode('foobar/static/js/admin/base.js', content='base'),
659 659 FileNode('foobar/static/admin', content='admin',
660 660 mode=0120000), # this is a link
661 661 FileNode('foo', content='foo'),
662 662 ],
663 663 },
664 664 {
665 665 'message': 'Second',
666 666 'author': 'Joe Doe <joe.doe@example.com>',
667 667 'date': datetime.datetime(2010, 1, 1, 22),
668 668 'added': [
669 669 FileNode('foo2', content='foo2'),
670 670 ],
671 671 },
672 672 ]
673 673
674 674 def test_paths_slow_traversing(self):
675 675 cs = self.repo.get_changeset()
676 676 self.assertEqual(cs.get_node('foobar').get_node('static').get_node('js')
677 677 .get_node('admin').get_node('base.js').content, 'base')
678 678
679 679 def test_paths_fast_traversing(self):
680 680 cs = self.repo.get_changeset()
681 681 self.assertEqual(cs.get_node('foobar/static/js/admin/base.js').content,
682 682 'base')
683 683
684 684 def test_workdir_get_branch(self):
685 self.repo.run_git_command('checkout -b production')
685 self.repo.run_git_command(['checkout', '-b', 'production'])
686 686 # Regression test: one of following would fail if we don't check
687 687 # .git/HEAD file
688 self.repo.run_git_command('checkout production')
688 self.repo.run_git_command(['checkout', 'production'])
689 689 self.assertEqual(self.repo.workdir.get_branch(), 'production')
690 self.repo.run_git_command('checkout master')
690 self.repo.run_git_command(['checkout', 'master'])
691 691 self.assertEqual(self.repo.workdir.get_branch(), 'master')
692 692
693 693 def test_get_diff_runs_git_command_with_hashes(self):
694 694 self.repo.run_git_command = mock.Mock(return_value=['', ''])
695 695 self.repo.get_diff(0, 1)
696 696 self.repo.run_git_command.assert_called_once_with(
697 'diff -U%s --full-index --binary -p -M --abbrev=40 %s %s' %
698 (3, self.repo._get_revision(0), self.repo._get_revision(1)))
697 ['diff', '-U3', '--full-index', '--binary', '-p', '-M', '--abbrev=40',
698 self.repo._get_revision(0), self.repo._get_revision(1)])
699 699
700 700 def test_get_diff_runs_git_command_with_str_hashes(self):
701 701 self.repo.run_git_command = mock.Mock(return_value=['', ''])
702 702 self.repo.get_diff(self.repo.EMPTY_CHANGESET, 1)
703 703 self.repo.run_git_command.assert_called_once_with(
704 'show -U%s --full-index --binary -p -M --abbrev=40 %s' %
705 (3, self.repo._get_revision(1)))
704 ['show', '-U3', '--full-index', '--binary', '-p', '-M', '--abbrev=40',
705 self.repo._get_revision(1)])
706 706
707 707 def test_get_diff_runs_git_command_with_path_if_its_given(self):
708 708 self.repo.run_git_command = mock.Mock(return_value=['', ''])
709 709 self.repo.get_diff(0, 1, 'foo')
710 710 self.repo.run_git_command.assert_called_once_with(
711 'diff -U%s --full-index --binary -p -M --abbrev=40 %s %s -- "foo"'
712 % (3, self.repo._get_revision(0), self.repo._get_revision(1)))
711 ['diff', '-U3', '--full-index', '--binary', '-p', '-M', '--abbrev=40',
712 self.repo._get_revision(0), self.repo._get_revision(1), '--', 'foo'])
713 713
714 714
715 715 class GitRegressionTest(_BackendTestMixin, unittest.TestCase):
716 716 backend_alias = 'git'
717 717
718 718 @classmethod
719 719 def _get_commits(cls):
720 720 return [
721 721 {
722 722 'message': 'Initial',
723 723 'author': 'Joe Doe <joe.doe@example.com>',
724 724 'date': datetime.datetime(2010, 1, 1, 20),
725 725 'added': [
726 726 FileNode('bot/__init__.py', content='base'),
727 727 FileNode('bot/templates/404.html', content='base'),
728 728 FileNode('bot/templates/500.html', content='base'),
729 729 ],
730 730 },
731 731 {
732 732 'message': 'Second',
733 733 'author': 'Joe Doe <joe.doe@example.com>',
734 734 'date': datetime.datetime(2010, 1, 1, 22),
735 735 'added': [
736 736 FileNode('bot/build/migrations/1.py', content='foo2'),
737 737 FileNode('bot/build/migrations/2.py', content='foo2'),
738 738 FileNode('bot/build/static/templates/f.html', content='foo2'),
739 739 FileNode('bot/build/static/templates/f1.html', content='foo2'),
740 740 FileNode('bot/build/templates/err.html', content='foo2'),
741 741 FileNode('bot/build/templates/err2.html', content='foo2'),
742 742 ],
743 743 },
744 744 ]
745 745
746 746 def test_similar_paths(self):
747 747 cs = self.repo.get_changeset()
748 748 paths = lambda *n:[x.path for x in n]
749 749 self.assertEqual(paths(*cs.get_nodes('bot')), ['bot/build', 'bot/templates', 'bot/__init__.py'])
750 750 self.assertEqual(paths(*cs.get_nodes('bot/build')), ['bot/build/migrations', 'bot/build/static', 'bot/build/templates'])
751 751 self.assertEqual(paths(*cs.get_nodes('bot/build/static')), ['bot/build/static/templates'])
752 752 # this get_nodes below causes troubles !
753 753 self.assertEqual(paths(*cs.get_nodes('bot/build/static/templates')), ['bot/build/static/templates/f.html', 'bot/build/static/templates/f1.html'])
754 754 self.assertEqual(paths(*cs.get_nodes('bot/build/templates')), ['bot/build/templates/err.html', 'bot/build/templates/err2.html'])
755 755 self.assertEqual(paths(*cs.get_nodes('bot/templates/')), ['bot/templates/404.html', 'bot/templates/500.html'])
756 756
757 757 if __name__ == '__main__':
758 758 unittest.main()
General Comments 0
You need to be logged in to leave comments. Login now