##// END OF EJS Templates
modernize: python3 updates
super-admin -
r5096:a0018795 default
parent child Browse files
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -1,71 +1,69 b''
1
2
3 1 # Copyright (C) 2015-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import atexit
22 20 import logging
23 21 import os
24 22 import signal
25 23 import sys
26 24
27 25 import rhodecode
28 26
29 27 log = logging.getLogger(__name__)
30 28
31 29 cache_keys_by_pid = set()
32 30
33 31
34 32 def sigHandler(signo, frame):
35 33 """
36 34 signals trigger sys.exit so there's a single handler to cleanup the code.
37 35 """
38 36 if rhodecode.is_test:
39 37 return
40 38
41 39 sys.exit(0)
42 40
43 41
44 42 def free_cache_keys(*args):
45 43 from rhodecode.model.db import CacheKey, Session
46 44
47 45 if rhodecode.is_test:
48 46 return
49 47
50 48 ssh_cmd = os.environ.get('RC_CMD_SSH_WRAPPER')
51 49 if ssh_cmd:
52 50 return
53 51
54 52 if cache_keys_by_pid:
55 53 try:
56 54 for cache_proc in set(cache_keys_by_pid):
57 like_expression = '{}%'.format(cache_proc)
55 like_expression = f'{cache_proc}%'
58 56 qry = CacheKey.query().filter(CacheKey.cache_key.like(like_expression))
59 57 count = qry.count()
60 58 log.info('Clearing %s: %s cache keys, total: %s', cache_proc, len(cache_keys_by_pid), count)
61 59 qry.delete(synchronize_session='fetch')
62 60 cache_keys_by_pid.remove(cache_proc)
63 61 Session().commit()
64 62 except Exception:
65 63 log.exception('Failed to clear keys, exiting gracefully')
66 64
67 65 atexit.register(free_cache_keys)
68 66
69 67 signal.signal(signal.SIGTERM, sigHandler)
70 68 signal.signal(signal.SIGINT, sigHandler)
71 69
@@ -1,405 +1,405 b''
1 1 # Copyright (C) 2015-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
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 Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 import functools
20 20 import logging
21 21 import os
22 22 import threading
23 23 import time
24 24
25 25 import decorator
26 26 from dogpile.cache import CacheRegion
27 27
28 28 import rhodecode
29 29 from rhodecode.lib.hash_utils import sha1
30 30 from rhodecode.lib.str_utils import safe_bytes
31 31 from rhodecode.lib.type_utils import str2bool
32 32
33 33 from . import region_meta, cache_key_meta
34 34
35 35 log = logging.getLogger(__name__)
36 36
37 37
38 38 def isCython(func):
39 39 """
40 40 Private helper that checks if a function is a cython function.
41 41 """
42 42 return func.__class__.__name__ == 'cython_function_or_method'
43 43
44 44
45 45 class RhodeCodeCacheRegion(CacheRegion):
46 46
47 47 def __repr__(self):
48 48 return f'{self.__class__}(name={self.name})'
49 49
50 50 def conditional_cache_on_arguments(
51 51 self, namespace=None,
52 52 expiration_time=None,
53 53 should_cache_fn=None,
54 54 to_str=str,
55 55 function_key_generator=None,
56 56 condition=True):
57 57 """
58 58 Custom conditional decorator, that will not touch any dogpile internals if
59 59 condition isn't meet. This works a bit different from should_cache_fn
60 60 And it's faster in cases we don't ever want to compute cached values
61 61 """
62 62 expiration_time_is_callable = callable(expiration_time)
63 63 if not namespace:
64 64 namespace = getattr(self, '_default_namespace', None)
65 65
66 66 if function_key_generator is None:
67 67 function_key_generator = self.function_key_generator
68 68
69 69 def get_or_create_for_user_func(func_key_generator, user_func, *arg, **kw):
70 70
71 71 if not condition:
72 72 log.debug('Calling un-cached method:%s', user_func.__name__)
73 73 start = time.time()
74 74 result = user_func(*arg, **kw)
75 75 total = time.time() - start
76 76 log.debug('un-cached method:%s took %.4fs', user_func.__name__, total)
77 77 return result
78 78
79 79 key = func_key_generator(*arg, **kw)
80 80
81 81 timeout = expiration_time() if expiration_time_is_callable \
82 82 else expiration_time
83 83
84 84 log.debug('Calling cached method:`%s`', user_func.__name__)
85 85 return self.get_or_create(key, user_func, timeout, should_cache_fn, (arg, kw))
86 86
87 87 def cache_decorator(user_func):
88 88 if to_str is str:
89 89 # backwards compatible
90 90 key_generator = function_key_generator(namespace, user_func)
91 91 else:
92 92 key_generator = function_key_generator(namespace, user_func, to_str=to_str)
93 93
94 94 def refresh(*arg, **kw):
95 95 """
96 96 Like invalidate, but regenerates the value instead
97 97 """
98 98 key = key_generator(*arg, **kw)
99 99 value = user_func(*arg, **kw)
100 100 self.set(key, value)
101 101 return value
102 102
103 103 def invalidate(*arg, **kw):
104 104 key = key_generator(*arg, **kw)
105 105 self.delete(key)
106 106
107 107 def set_(value, *arg, **kw):
108 108 key = key_generator(*arg, **kw)
109 109 self.set(key, value)
110 110
111 111 def get(*arg, **kw):
112 112 key = key_generator(*arg, **kw)
113 113 return self.get(key)
114 114
115 115 user_func.set = set_
116 116 user_func.invalidate = invalidate
117 117 user_func.get = get
118 118 user_func.refresh = refresh
119 119 user_func.key_generator = key_generator
120 120 user_func.original = user_func
121 121
122 122 # Use `decorate` to preserve the signature of :param:`user_func`.
123 123 return decorator.decorate(user_func, functools.partial(
124 124 get_or_create_for_user_func, key_generator))
125 125
126 126 return cache_decorator
127 127
128 128
129 129 def make_region(*arg, **kw):
130 130 return RhodeCodeCacheRegion(*arg, **kw)
131 131
132 132
133 133 def get_default_cache_settings(settings, prefixes=None):
134 134 prefixes = prefixes or []
135 135 cache_settings = {}
136 136 for key in settings.keys():
137 137 for prefix in prefixes:
138 138 if key.startswith(prefix):
139 139 name = key.split(prefix)[1].strip()
140 140 val = settings[key]
141 141 if isinstance(val, str):
142 142 val = val.strip()
143 143 cache_settings[name] = val
144 144 return cache_settings
145 145
146 146
147 147 def compute_key_from_params(*args):
148 148 """
149 149 Helper to compute key from given params to be used in cache manager
150 150 """
151 151 return sha1(safe_bytes("_".join(map(str, args))))
152 152
153 153
154 154 def custom_key_generator(backend, namespace, fn):
155 155 func_name = fn.__name__
156 156
157 157 def generate_key(*args):
158 158 backend_pref = getattr(backend, 'key_prefix', None) or 'backend_prefix'
159 159 namespace_pref = namespace or 'default_namespace'
160 160 arg_key = compute_key_from_params(*args)
161 161 final_key = f"{backend_pref}:{namespace_pref}:{func_name}_{arg_key}"
162 162
163 163 return final_key
164 164
165 165 return generate_key
166 166
167 167
168 168 def backend_key_generator(backend):
169 169 """
170 170 Special wrapper that also sends over the backend to the key generator
171 171 """
172 172 def wrapper(namespace, fn):
173 173 return custom_key_generator(backend, namespace, fn)
174 174 return wrapper
175 175
176 176
177 177 def get_or_create_region(region_name, region_namespace: str = None, use_async_runner=False):
178 178 from .backends import FileNamespaceBackend
179 179 from . import async_creation_runner
180 180
181 181 region_obj = region_meta.dogpile_cache_regions.get(region_name)
182 182 if not region_obj:
183 183 reg_keys = list(region_meta.dogpile_cache_regions.keys())
184 raise EnvironmentError(f'Region `{region_name}` not in configured: {reg_keys}.')
184 raise OSError(f'Region `{region_name}` not in configured: {reg_keys}.')
185 185
186 186 region_uid_name = f'{region_name}:{region_namespace}'
187 187
188 188 if isinstance(region_obj.actual_backend, FileNamespaceBackend):
189 189 if not region_namespace:
190 190 raise ValueError(f'{FileNamespaceBackend} used requires to specify region_namespace param')
191 191
192 192 region_exist = region_meta.dogpile_cache_regions.get(region_namespace)
193 193 if region_exist:
194 194 log.debug('Using already configured region: %s', region_namespace)
195 195 return region_exist
196 196
197 197 expiration_time = region_obj.expiration_time
198 198
199 199 cache_dir = region_meta.dogpile_config_defaults['cache_dir']
200 200 namespace_cache_dir = cache_dir
201 201
202 202 # we default the namespace_cache_dir to our default cache dir.
203 203 # however if this backend is configured with filename= param, we prioritize that
204 204 # so all caches within that particular region, even those namespaced end up in the same path
205 205 if region_obj.actual_backend.filename:
206 206 namespace_cache_dir = os.path.dirname(region_obj.actual_backend.filename)
207 207
208 208 if not os.path.isdir(namespace_cache_dir):
209 209 os.makedirs(namespace_cache_dir)
210 210 new_region = make_region(
211 211 name=region_uid_name,
212 212 function_key_generator=backend_key_generator(region_obj.actual_backend)
213 213 )
214 214
215 215 namespace_filename = os.path.join(
216 216 namespace_cache_dir, f"{region_name}_{region_namespace}.cache_db")
217 217 # special type that allows 1db per namespace
218 218 new_region.configure(
219 219 backend='dogpile.cache.rc.file_namespace',
220 220 expiration_time=expiration_time,
221 221 arguments={"filename": namespace_filename}
222 222 )
223 223
224 224 # create and save in region caches
225 225 log.debug('configuring new region: %s', region_uid_name)
226 226 region_obj = region_meta.dogpile_cache_regions[region_namespace] = new_region
227 227
228 228 region_obj._default_namespace = region_namespace
229 229 if use_async_runner:
230 230 region_obj.async_creation_runner = async_creation_runner
231 231 return region_obj
232 232
233 233
234 234 def clear_cache_namespace(cache_region: str | RhodeCodeCacheRegion, cache_namespace_uid: str, method: str):
235 235 from . import CLEAR_DELETE, CLEAR_INVALIDATE
236 236
237 237 if not isinstance(cache_region, RhodeCodeCacheRegion):
238 238 cache_region = get_or_create_region(cache_region, cache_namespace_uid)
239 239 log.debug('clearing cache region: %s with method=%s', cache_region, method)
240 240
241 241 num_affected_keys = None
242 242
243 243 if method == CLEAR_INVALIDATE:
244 244 # NOTE: The CacheRegion.invalidate() method’s default mode of
245 245 # operation is to set a timestamp local to this CacheRegion in this Python process only.
246 246 # It does not impact other Python processes or regions as the timestamp is only stored locally in memory.
247 247 cache_region.invalidate(hard=True)
248 248
249 249 if method == CLEAR_DELETE:
250 250 cache_keys = cache_region.backend.list_keys(prefix=cache_namespace_uid)
251 251 num_affected_keys = len(cache_keys)
252 252 if num_affected_keys:
253 253 cache_region.delete_multi(cache_keys)
254 254
255 255 return num_affected_keys
256 256
257 257
258 258 class ActiveRegionCache(object):
259 259 def __init__(self, context, cache_data):
260 260 self.context = context
261 261 self.cache_data = cache_data
262 262
263 263 def should_invalidate(self):
264 264 return False
265 265
266 266
267 267 class FreshRegionCache(object):
268 268 def __init__(self, context, cache_data):
269 269 self.context = context
270 270 self.cache_data = cache_data
271 271
272 272 def should_invalidate(self):
273 273 return True
274 274
275 275
276 276 class InvalidationContext(object):
277 277 """
278 278 usage::
279 279
280 280 from rhodecode.lib import rc_cache
281 281
282 282 cache_namespace_uid = CacheKey.SOME_NAMESPACE.format(1)
283 283 region = rc_cache.get_or_create_region('cache_perms', cache_namespace_uid)
284 284
285 285 @region.conditional_cache_on_arguments(namespace=cache_namespace_uid, condition=True)
286 286 def heavy_compute(cache_name, param1, param2):
287 287 print('COMPUTE {}, {}, {}'.format(cache_name, param1, param2))
288 288
289 289 # invalidation namespace is shared namespace key for all process caches
290 290 # we use it to send a global signal
291 291 invalidation_namespace = 'repo_cache:1'
292 292
293 293 inv_context_manager = rc_cache.InvalidationContext(
294 294 uid=cache_namespace_uid, invalidation_namespace=invalidation_namespace)
295 295 with inv_context_manager as invalidation_context:
296 296 args = ('one', 'two')
297 297 # re-compute and store cache if we get invalidate signal
298 298 if invalidation_context.should_invalidate():
299 299 result = heavy_compute.refresh(*args)
300 300 else:
301 301 result = heavy_compute(*args)
302 302
303 303 compute_time = inv_context_manager.compute_time
304 304 log.debug('result computed in %.4fs', compute_time)
305 305
306 306 # To send global invalidation signal, simply run
307 307 CacheKey.set_invalidate(invalidation_namespace)
308 308
309 309 """
310 310
311 311 def __repr__(self):
312 312 return f'<InvalidationContext:{self.cache_key}[{self.uid}]>'
313 313
314 314 def __init__(self, uid, invalidation_namespace='',
315 315 raise_exception=False, thread_scoped=None):
316 316 self.uid = uid
317 317 self.invalidation_namespace = invalidation_namespace
318 318 self.raise_exception = raise_exception
319 319 self.proc_id = rhodecode.CONFIG.get('instance_id') or 'DEFAULT'
320 320 self.thread_id = 'global'
321 321
322 322 if thread_scoped is None:
323 323 # if we set "default" we can override this via .ini settings
324 324 thread_scoped = rhodecode.ConfigGet().get_bool('cache_thread_scoped')
325 325
326 326 # Append the thread id to the cache key if this invalidation context
327 327 # should be scoped to the current thread.
328 328 if thread_scoped is True:
329 329 self.thread_id = threading.current_thread().ident
330 330
331 331 self.cache_key = compute_key_from_params(uid)
332 332 self.cache_key = 'proc:{}|thread:{}|params:{}'.format(
333 333 self.proc_id, self.thread_id, self.cache_key)
334 self.proc_key = 'proc:{}'.format(self.proc_id)
334 self.proc_key = f'proc:{self.proc_id}'
335 335 self.compute_time = 0
336 336
337 337 def get_or_create_cache_obj(self, cache_type, invalidation_namespace=''):
338 338 from rhodecode.model.db import CacheKey
339 339
340 340 invalidation_namespace = invalidation_namespace or self.invalidation_namespace
341 341 # fetch all cache keys for this namespace and convert them to a map to find if we
342 342 # have specific cache_key object registered. We do this because we want to have
343 343 # all consistent cache_state_uid for newly registered objects
344 344 cache_obj_map = CacheKey.get_namespace_map(invalidation_namespace)
345 345 cache_obj = cache_obj_map.get(self.cache_key)
346 346 log.debug('Fetched cache obj %s using %s cache key.', cache_obj, self.cache_key)
347 347
348 348 if not cache_obj:
349 349 new_cache_args = invalidation_namespace
350 350 first_cache_obj = next(iter(cache_obj_map.values())) if cache_obj_map else None
351 351 cache_state_uid = None
352 352 if first_cache_obj:
353 353 cache_state_uid = first_cache_obj.cache_state_uid
354 354 cache_obj = CacheKey(self.cache_key, cache_args=new_cache_args,
355 355 cache_state_uid=cache_state_uid)
356 356 cache_key_meta.cache_keys_by_pid.add(self.proc_key)
357 357
358 358 return cache_obj
359 359
360 360 def __enter__(self):
361 361 """
362 362 Test if current object is valid, and return CacheRegion function
363 363 that does invalidation and calculation
364 364 """
365 365 log.debug('Entering cache invalidation check context: %s', self.invalidation_namespace)
366 366 # register or get a new key based on uid
367 367 self.cache_obj = self.get_or_create_cache_obj(cache_type=self.uid)
368 368 cache_data = self.cache_obj.get_dict()
369 369 self._start_time = time.time()
370 370 if self.cache_obj.cache_active:
371 371 # means our cache obj is existing and marked as it's
372 372 # cache is not outdated, we return ActiveRegionCache
373 373 self.skip_cache_active_change = True
374 374
375 375 return ActiveRegionCache(context=self, cache_data=cache_data)
376 376
377 377 # the key is either not existing or set to False, we return
378 378 # the real invalidator which re-computes value. We additionally set
379 379 # the flag to actually update the Database objects
380 380 self.skip_cache_active_change = False
381 381 return FreshRegionCache(context=self, cache_data=cache_data)
382 382
383 383 def __exit__(self, exc_type, exc_val, exc_tb):
384 384 from rhodecode.model.db import IntegrityError, Session
385 385
386 386 # save compute time
387 387 self.compute_time = time.time() - self._start_time
388 388
389 389 if self.skip_cache_active_change:
390 390 return
391 391
392 392 try:
393 393 self.cache_obj.cache_active = True
394 394 Session().add(self.cache_obj)
395 395 Session().commit()
396 396 except IntegrityError:
397 397 # if we catch integrity error, it means we inserted this object
398 398 # assumption is that's really an edge race-condition case and
399 399 # it's safe is to skip it
400 400 Session().rollback()
401 401 except Exception:
402 402 log.exception('Failed to commit on cache key update')
403 403 Session().rollback()
404 404 if self.raise_exception:
405 405 raise
@@ -1,107 +1,105 b''
1
2
3 1 # Copyright (C) 2016-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import sys
22 20 import logging
23 21
24 22 import click
25 23
26 24 from rhodecode.lib.pyramid_utils import bootstrap
27 25 from rhodecode.model.db import Session, User, Repository
28 26 from rhodecode.model.user import UserModel
29 27 from rhodecode.apps.file_store import utils as store_utils
30 28
31 29 log = logging.getLogger(__name__)
32 30
33 31
34 32 @click.command()
35 33 @click.argument('ini_path', type=click.Path(exists=True))
36 34 @click.option(
37 35 '--filename',
38 36 required=True,
39 37 help='Filename for artifact.')
40 38 @click.option(
41 39 '--file-path',
42 40 required=True,
43 41 type=click.Path(exists=True, dir_okay=False, readable=True),
44 42 help='Path to a file to be added as artifact')
45 43 @click.option(
46 44 '--repo-id',
47 45 required=True,
48 46 type=int,
49 47 help='ID of repository to add this artifact to.')
50 48 @click.option(
51 49 '--user-id',
52 50 default=None,
53 51 type=int,
54 52 help='User ID for creator of artifact. '
55 53 'Default would be first super admin.')
56 54 @click.option(
57 55 '--description',
58 56 default=None,
59 57 type=str,
60 58 help='Add description to this artifact')
61 59 def main(ini_path, filename, file_path, repo_id, user_id, description):
62 60 return command(ini_path, filename, file_path, repo_id, user_id, description)
63 61
64 62
65 63 def command(ini_path, filename, file_path, repo_id, user_id, description):
66 64 with bootstrap(ini_path, env={'RC_CMD_SETUP_RC': '1'}) as env:
67 65 try:
68 66 from rc_ee.api.views.store_api import _store_file
69 67 except ImportError:
70 68 click.secho('ERROR: Unable to import store_api. '
71 69 'store_api is only available in EE edition of RhodeCode',
72 70 fg='red')
73 71 sys.exit(-1)
74 72
75 73 request = env['request']
76 74
77 75 repo = Repository.get(repo_id)
78 76 if not repo:
79 click.secho('ERROR: Unable to find repository with id `{}`'.format(repo_id),
77 click.secho(f'ERROR: Unable to find repository with id `{repo_id}`',
80 78 fg='red')
81 79 sys.exit(-1)
82 80
83 81 # if we don't give user, or it's "DEFAULT" user we pick super-admin
84 82 if user_id is not None or user_id == 1:
85 83 db_user = User.get(user_id)
86 84 else:
87 85 db_user = User.get_first_super_admin()
88 86
89 87 if not db_user:
90 click.secho('ERROR: Unable to find user with id/username `{}`'.format(user_id),
88 click.secho(f'ERROR: Unable to find user with id/username `{user_id}`',
91 89 fg='red')
92 90 sys.exit(-1)
93 91
94 92 auth_user = db_user.AuthUser(ip_addr='127.0.0.1')
95 93
96 94 storage = store_utils.get_file_storage(request.registry.settings)
97 95
98 96 with open(file_path, 'rb') as f:
99 click.secho('Adding new artifact from path: `{}`'.format(file_path),
97 click.secho(f'Adding new artifact from path: `{file_path}`',
100 98 fg='green')
101 99
102 100 file_data = _store_file(
103 101 storage, auth_user, filename, content=None, check_acl=True,
104 102 file_obj=f, description=description,
105 103 scope_repo_id=repo.repo_id)
106 click.secho('File Data: {}'.format(file_data),
104 click.secho(f'File Data: {file_data}',
107 105 fg='green')
@@ -1,59 +1,57 b''
1
2
3 1 # Copyright (C) 2016-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import click
22 20
23 21 from rhodecode.lib.pyramid_utils import bootstrap
24 22 import pyramid.paster
25 23
26 24 # imports, used in ipython shell
27 25 import os
28 26 import sys
29 27 import time
30 28 import shutil
31 29 import datetime
32 30 from rhodecode.model.db import *
33 31
34 32 welcome_banner = """Welcome to RhodeCode iShell.
35 33 Type `exit` to exit the shell.
36 34 iShell is interactive shell to interact directly with the
37 35 internal RhodeCode APIs. You can rescue your lost password,
38 36 or reset some user/system settings.
39 37 """
40 38
41 39
42 40 @click.command()
43 41 @click.argument('ini_path', type=click.Path(exists=True))
44 42 def main(ini_path):
45 43 pyramid.paster.setup_logging(ini_path)
46 44
47 45 with bootstrap(ini_path) as env:
48 46
49 47 try:
50 48 from IPython import embed
51 49 from traitlets.config import Config
52 50 cfg = Config()
53 51 cfg.InteractiveShellEmbed.confirm_exit = False
54 52 embed(config=cfg, banner1=welcome_banner)
55 53 except ImportError:
56 54 print('ipython installation required for ishell')
57 55 sys.exit(-1)
58 56
59 57
@@ -1,125 +1,123 b''
1
2
3 1 # Copyright (C) 2016-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18 import logging
21 19
22 20 import click
23 21 import pyramid.paster
24 22
25 23 from rhodecode.lib.pyramid_utils import bootstrap, get_app_config
26 24 from rhodecode.lib.db_manage import DbManage
27 25 from rhodecode.lib.utils2 import get_encryption_key
28 26 from rhodecode.model.db import Session
29 27
30 28
31 29 log = logging.getLogger(__name__)
32 30
33 31
34 32 @click.command()
35 33 @click.argument('ini_path', type=click.Path(exists=True))
36 34 @click.option(
37 35 '--force-yes/--force-no', default=None,
38 36 help="Force yes/no to every question")
39 37 @click.option(
40 38 '--user',
41 39 default=None,
42 40 help='Initial super-admin username')
43 41 @click.option(
44 42 '--email',
45 43 default=None,
46 44 help='Initial super-admin email address.')
47 45 @click.option(
48 46 '--password',
49 47 default=None,
50 48 help='Initial super-admin password. Minimum 6 chars.')
51 49 @click.option(
52 50 '--api-key',
53 51 help='Initial API key for the admin user')
54 52 @click.option(
55 53 '--repos',
56 54 default=None,
57 55 help='Absolute path to storage location. This is storage for all '
58 56 'existing and future repositories, and repository groups.')
59 57 @click.option(
60 58 '--public-access/--no-public-access',
61 59 default=None,
62 60 help='Enable public access on this installation. '
63 61 'Default is public access enabled.')
64 62 @click.option(
65 63 '--skip-existing-db',
66 64 default=False,
67 65 is_flag=True,
68 66 help='Do not destroy and re-initialize the database if it already exist.')
69 67 @click.option(
70 68 '--apply-license-key',
71 69 default=False,
72 70 is_flag=True,
73 71 help='Get the license key from a license file or ENV and apply during DB creation.')
74 72 def main(ini_path, force_yes, user, email, password, api_key, repos,
75 73 public_access, skip_existing_db, apply_license_key):
76 74 return command(ini_path, force_yes, user, email, password, api_key,
77 75 repos, public_access, skip_existing_db, apply_license_key)
78 76
79 77
80 78 def command(ini_path, force_yes, user, email, password, api_key, repos,
81 79 public_access, skip_existing_db, apply_license_key):
82 80 # mapping of old parameters to new CLI from click
83 81 options = dict(
84 82 username=user,
85 83 email=email,
86 84 password=password,
87 85 api_key=api_key,
88 86 repos_location=repos,
89 87 force_ask=force_yes,
90 88 public_access=public_access
91 89 )
92 90 pyramid.paster.setup_logging(ini_path)
93 91
94 92 config = get_app_config(ini_path)
95 93
96 94 db_uri = config['sqlalchemy.db1.url']
97 95 enc_key = get_encryption_key(config)
98 96 dbmanage = DbManage(log_sql=True, dbconf=db_uri, root='.',
99 97 tests=False, cli_args=options, enc_key=enc_key)
100 98 if skip_existing_db and dbmanage.db_exists():
101 99 return
102 100
103 101 dbmanage.create_tables(override=True)
104 102 dbmanage.set_db_version()
105 103 opts = dbmanage.config_prompt(None)
106 104 dbmanage.create_settings(opts)
107 105 dbmanage.create_default_user()
108 106 dbmanage.create_admin_and_prompt()
109 107 dbmanage.create_permissions()
110 108 dbmanage.populate_default_permissions()
111 109 if apply_license_key:
112 110 try:
113 111 from rc_license.models import apply_trial_license_if_missing
114 112 apply_trial_license_if_missing(force=True)
115 113 except ImportError:
116 114 pass
117 115
118 116 Session().commit()
119 117
120 118 with bootstrap(ini_path, env={'RC_CMD_SETUP_RC': '1'}) as env:
121 119 msg = 'Successfully initialized database, schema and default data.'
122 120 print()
123 121 print('*' * len(msg))
124 122 print(msg.upper())
125 123 print('*' * len(msg))
@@ -1,56 +1,54 b''
1
2
3 1 # Copyright (C) 2016-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import logging
22 20
23 21 import click
24 22 import pyramid.paster
25 23
26 24 from rhodecode.lib.pyramid_utils import bootstrap
27 25 from rhodecode.lib.db_manage import DbManage
28 26 from rhodecode.lib.utils2 import safe_int, get_encryption_key
29 27
30 28 log = logging.getLogger(__name__)
31 29
32 30
33 31 @click.command()
34 32 @click.argument('ini_path', type=click.Path(exists=True))
35 33 @click.option('--force-yes/--force-no', default=None,
36 34 help="Force yes/no to every question")
37 35 @click.option('--force-version', default=None,
38 36 help="Force upgrade from version")
39 37 def main(ini_path, force_yes, force_version):
40 38 return command(ini_path, force_yes, force_version)
41 39
42 40
43 41 def command(ini_path, force_yes, force_version):
44 42 pyramid.paster.setup_logging(ini_path)
45 43
46 44 with bootstrap(ini_path, env={'RC_CMD_UPGRADE_DB': '1'}) as env:
47 45 config = env['registry'].settings
48 46 db_uri = config['sqlalchemy.db1.url']
49 47 enc_key = get_encryption_key(config)
50 48 options = {}
51 49 if force_yes is not None:
52 50 options['force_ask'] = force_yes
53 51 dbmanage = DbManage(
54 52 log_sql=True, dbconf=db_uri, root='.', tests=False,
55 53 cli_args=options, enc_key=enc_key)
56 54 dbmanage.upgrade(version=safe_int(force_version))
@@ -1,193 +1,191 b''
1
2
3 1 # Copyright (C) 2017-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18 import logging
21 19
22 20 log = logging.getLogger(__name__)
23 21
24 22
25 23 class MaintenanceTask(object):
26 24 human_name = 'undefined'
27 25
28 26 def __init__(self, db_repo):
29 27 self.db_repo = db_repo
30 28
31 29 def run(self):
32 30 """Execute task and return task human value"""
33 31 raise NotImplementedError()
34 32
35 33
36 34 class GitGC(MaintenanceTask):
37 35 human_name = 'GIT Garbage collect'
38 36
39 37 def _count_objects(self, repo):
40 38 stdout, stderr = repo.run_git_command(
41 39 ['count-objects', '-v'], fail_on_stderr=False)
42 40
43 41 errors = ' '
44 42 objects = ' '.join(stdout.splitlines())
45 43
46 44 if stderr:
47 45 errors = '\nSTD ERR:' + '\n'.join(stderr.splitlines())
48 46 return objects + errors
49 47
50 48 def run(self):
51 49 output = []
52 50 instance = self.db_repo.scm_instance()
53 51
54 52 objects_before = self._count_objects(instance)
55 53
56 54 log.debug('GIT objects:%s', objects_before)
57 55 cmd = ['gc', '--aggressive']
58 56 stdout, stderr = instance.run_git_command(cmd, fail_on_stderr=False)
59 57
60 58 out = 'executed {}'.format(' '.join(cmd))
61 59 output.append(out)
62 60
63 61 out = ''
64 62 if stderr:
65 63 out += ''.join(stderr.splitlines())
66 64
67 65 if stdout:
68 66 out += ''.join(stdout.splitlines())
69 67
70 68 if out:
71 69 output.append(out)
72 70
73 71 objects_after = self._count_objects(instance)
74 72 log.debug('GIT objects:%s', objects_after)
75 73 output.append('objects before :' + objects_before)
76 74 output.append('objects after :' + objects_after)
77 75
78 76 return '\n'.join(output)
79 77
80 78
81 79 class GitFSCK(MaintenanceTask):
82 80 human_name = 'GIT FSCK'
83 81
84 82 def run(self):
85 83 output = []
86 84 instance = self.db_repo.scm_instance()
87 85
88 86 cmd = ['fsck', '--full']
89 87 stdout, stderr = instance.run_git_command(cmd, fail_on_stderr=False)
90 88
91 89 out = 'executed {}'.format(' '.join(cmd))
92 90 output.append(out)
93 91
94 92 out = ''
95 93 if stderr:
96 94 out += ''.join(stderr.splitlines())
97 95
98 96 if stdout:
99 97 out += ''.join(stdout.splitlines())
100 98
101 99 if out:
102 100 output.append(out)
103 101
104 102 return '\n'.join(output)
105 103
106 104
107 105 class GitRepack(MaintenanceTask):
108 106 human_name = 'GIT Repack'
109 107
110 108 def run(self):
111 109 output = []
112 110 instance = self.db_repo.scm_instance()
113 111 cmd = ['repack', '-a', '-d',
114 112 '--window-memory', '10m', '--max-pack-size', '100m']
115 113 stdout, stderr = instance.run_git_command(cmd, fail_on_stderr=False)
116 114
117 115 out = 'executed {}'.format(' '.join(cmd))
118 116 output.append(out)
119 117 out = ''
120 118
121 119 if stderr:
122 120 out += ''.join(stderr.splitlines())
123 121
124 122 if stdout:
125 123 out += ''.join(stdout.splitlines())
126 124
127 125 if out:
128 126 output.append(out)
129 127
130 128 return '\n'.join(output)
131 129
132 130
133 131 class HGVerify(MaintenanceTask):
134 132 human_name = 'HG Verify repo'
135 133
136 134 def run(self):
137 135 instance = self.db_repo.scm_instance()
138 136 res = instance.verify()
139 137 return res
140 138
141 139
142 140 class HGUpdateCaches(MaintenanceTask):
143 141 human_name = 'HG update caches'
144 142
145 143 def run(self):
146 144 instance = self.db_repo.scm_instance()
147 145 res = instance.hg_update_cache()
148 146 return res
149 147
150 148
151 149 class HGRebuildFnCaches(MaintenanceTask):
152 150 human_name = 'HG rebuild fn caches'
153 151
154 152 def run(self):
155 153 instance = self.db_repo.scm_instance()
156 154 res = instance.hg_rebuild_fn_cache()
157 155 return res
158 156
159 157
160 158 class SVNVerify(MaintenanceTask):
161 159 human_name = 'SVN Verify repo'
162 160
163 161 def run(self):
164 162 instance = self.db_repo.scm_instance()
165 163 res = instance.verify()
166 164 return res
167 165
168 166
169 167 class RepoMaintenance(object):
170 168 """
171 169 Performs maintenance of repository based on it's type
172 170 """
173 171 tasks = {
174 172 'hg': [HGVerify, HGUpdateCaches, HGRebuildFnCaches],
175 173 'git': [GitFSCK, GitGC, GitRepack],
176 174 'svn': [SVNVerify],
177 175 }
178 176
179 177 def get_tasks_for_repo(self, db_repo):
180 178 """
181 179 fetches human names of tasks pending for execution for given type of repo
182 180 """
183 181 tasks = []
184 182 for task in self.tasks[db_repo.repo_type]:
185 183 tasks.append(task.human_name)
186 184 return tasks
187 185
188 186 def execute(self, db_repo):
189 187 executed_tasks = []
190 188 for task in self.tasks[db_repo.repo_type]:
191 189 output = task.human_name + ':\n' + task(db_repo).run() + '\n--\n'
192 190 executed_tasks.append(output)
193 191 return executed_tasks
@@ -1,109 +1,107 b''
1
2
3 1 # Copyright (C) 2017-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 from uuid import uuid4
22 20 import pyramid.testing
23 21 from pyramid.decorator import reify
24 22 from pyramid.request import Request as _Request
25 23 from rhodecode.lib.type_utils import StrictAttributeDict
26 24
27 25
28 26 class TemplateArgs(StrictAttributeDict):
29 27 pass
30 28
31 29
32 30 # Base Class with DummyMethods, testing / CLI scripts
33 31 class RequestBase(object):
34 32 _req_id_bucket = list()
35 33 _call_context = TemplateArgs()
36 34 _call_context.visual = TemplateArgs()
37 35 _call_context.visual.show_sha_length = 12
38 36 _call_context.visual.show_revision_number = True
39 37
40 38 @reify
41 39 def req_id(self):
42 40 return str(uuid4())
43 41
44 42 @property
45 43 def req_id_bucket(self):
46 44 return self._req_id_bucket
47 45
48 46 def req_id_records_init(self):
49 47 self._req_id_bucket = list()
50 48
51 49 def translate(self, *args, **kwargs):
52 50 raise NotImplementedError()
53 51
54 52 def plularize(self, *args, **kwargs):
55 53 raise NotImplementedError()
56 54
57 55 def get_partial_renderer(self, tmpl_name):
58 56 raise NotImplementedError()
59 57
60 58 @property
61 59 def call_context(self):
62 60 return self._call_context
63 61
64 62 def set_call_context(self, new_context):
65 63 self._call_context = new_context
66 64
67 65
68 66 # for thin non-web/cli etc
69 67 class ThinRequest(RequestBase, pyramid.testing.DummyRequest):
70 68
71 69 def translate(self, msg):
72 70 return msg
73 71
74 72 def plularize(self, singular, plural, n):
75 73 return singular
76 74
77 75 def get_partial_renderer(self, tmpl_name):
78 76 from rhodecode.lib.partial_renderer import get_partial_renderer
79 77 return get_partial_renderer(request=self, tmpl_name=tmpl_name)
80 78
81 79
82 80 # for real-web-based
83 81 class RealRequest(RequestBase, _Request):
84 82 def get_partial_renderer(self, tmpl_name):
85 83 from rhodecode.lib.partial_renderer import get_partial_renderer
86 84 return get_partial_renderer(request=self, tmpl_name=tmpl_name)
87 85
88 86 def request_count(self):
89 87 from rhodecode.lib.request_counter import get_request_counter
90 88 return get_request_counter()
91 89
92 90 def plularize(self, *args, **kwargs):
93 91 return self.localizer.pluralize(*args, **kwargs)
94 92
95 93 def translate(self, *args, **kwargs):
96 94 localizer = self.localizer
97 95 from rhodecode.translation import _ as tsf
98 96
99 97 def auto_translate(*_args, **_kwargs):
100 98 return localizer.translate(tsf(*_args, **_kwargs))
101 99
102 100 return auto_translate(*args, **kwargs)
103 101
104 102
105 103 class Request(RealRequest):
106 104 """
107 105 This is the main request object used in web-context
108 106 """
109 107 pass
@@ -1,27 +1,25 b''
1
2
3 1 # Copyright (C) 2017-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 counter = 0
22 20
23 21
24 22 def get_request_counter():
25 23 global counter
26 24 counter += 1
27 25 return counter
@@ -1,49 +1,49 b''
1 1 from rhodecode.lib._vendor.statsd import client_from_config
2 2
3 3
4 4 class StatsdClientNotInitialised(Exception):
5 5 pass
6 6
7 7
8 8 class _Singleton(type):
9 9 """A metaclass that creates a Singleton base class when called."""
10 10
11 11 _instances = {}
12 12
13 13 def __call__(cls, *args, **kwargs):
14 14 if cls not in cls._instances:
15 cls._instances[cls] = super(_Singleton, cls).__call__(*args, **kwargs)
15 cls._instances[cls] = super().__call__(*args, **kwargs)
16 16 return cls._instances[cls]
17 17
18 18
19 19 class Singleton(_Singleton("SingletonMeta", (object,), {})):
20 20 pass
21 21
22 22
23 23 class StatsdClientClass(Singleton):
24 24 setup_run = False
25 25 statsd_client = None
26 26 statsd = None
27 27
28 28 def __getattribute__(self, name):
29 29
30 30 if name.startswith("statsd"):
31 31 if self.setup_run:
32 return super(StatsdClientClass, self).__getattribute__(name)
32 return super().__getattribute__(name)
33 33 else:
34 34 return None
35 35 #raise StatsdClientNotInitialised("requested key was %s" % name)
36 36
37 return super(StatsdClientClass, self).__getattribute__(name)
37 return super().__getattribute__(name)
38 38
39 39 def setup(self, settings):
40 40 """
41 41 Initialize the client
42 42 """
43 43 statsd = client_from_config(settings)
44 44 self.statsd = statsd
45 45 self.statsd_client = statsd
46 46 self.setup_run = True
47 47
48 48
49 49 StatsdClient = StatsdClientClass()
@@ -1,170 +1,169 b''
1
2 1 # Copyright (C) 2011-2023 RhodeCode GmbH
3 2 #
4 3 # This program is free software: you can redistribute it and/or modify
5 4 # it under the terms of the GNU Affero General Public License, version 3
6 5 # (only), as published by the Free Software Foundation.
7 6 #
8 7 # This program is distributed in the hope that it will be useful,
9 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 10 # GNU General Public License for more details.
12 11 #
13 12 # You should have received a copy of the GNU Affero General Public License
14 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 14 #
16 15 # This program is dual-licensed. If you wish to learn more about the
17 16 # RhodeCode Enterprise Edition, including its added features, Support services,
18 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 18
20 19 import typing
21 20 import base64
22 21 import logging
23 22 from unidecode import unidecode
24 23
25 24 import rhodecode
26 25 from rhodecode.lib.type_utils import aslist
27 26
28 27
29 28 log = logging.getLogger(__name__)
30 29
31 30
32 31 def safe_int(val, default=None) -> int:
33 32 """
34 33 Returns int() of val if val is not convertable to int use default
35 34 instead
36 35
37 36 :param val:
38 37 :param default:
39 38 """
40 39
41 40 try:
42 41 val = int(val)
43 42 except (ValueError, TypeError):
44 43 val = default
45 44
46 45 return val
47 46
48 47
49 48 def safe_float(val, default=None) -> float:
50 49 """
51 50 Returns float() of val if val is not convertable to float use default
52 51 instead
53 52
54 53 :param val:
55 54 :param default:
56 55 """
57 56
58 57 try:
59 58 val = float(val)
60 59 except (ValueError, TypeError):
61 60 val = default
62 61
63 62 return val
64 63
65 64
66 65 def base64_to_str(text) -> str:
67 66 return safe_str(base64.encodebytes(safe_bytes(text))).strip()
68 67
69 68
70 def get_default_encodings() -> typing.List[str]:
69 def get_default_encodings() -> list[str]:
71 70 return aslist(rhodecode.CONFIG.get('default_encoding', 'utf8'), sep=',')
72 71
73 72
74 73 DEFAULT_ENCODINGS = get_default_encodings()
75 74
76 75
77 76 def safe_str(str_, to_encoding=None) -> str:
78 77 """
79 78 safe str function. Does few trick to turn unicode_ into string
80 79
81 80 :param str_: str to encode
82 81 :param to_encoding: encode to this type UTF8 default
83 82 """
84 83 if isinstance(str_, str):
85 84 return str_
86 85
87 86 # if it's bytes cast to str
88 87 if not isinstance(str_, bytes):
89 88 return str(str_)
90 89
91 90 to_encoding = to_encoding or DEFAULT_ENCODINGS
92 91 if not isinstance(to_encoding, (list, tuple)):
93 92 to_encoding = [to_encoding]
94 93
95 94 for enc in to_encoding:
96 95 try:
97 96 return str(str_, enc)
98 97 except UnicodeDecodeError:
99 98 pass
100 99
101 100 return str(str_, to_encoding[0], 'replace')
102 101
103 102
104 103 def safe_bytes(str_, from_encoding=None) -> bytes:
105 104 """
106 105 safe bytes function. Does few trick to turn str_ into bytes string:
107 106
108 107 :param str_: string to decode
109 108 :param from_encoding: encode from this type UTF8 default
110 109 """
111 110 if isinstance(str_, bytes):
112 111 return str_
113 112
114 113 if not isinstance(str_, str):
115 114 raise ValueError(f'safe_bytes cannot convert other types than str: got: {type(str_)}')
116 115
117 116 from_encoding = from_encoding or get_default_encodings()
118 117 if not isinstance(from_encoding, (list, tuple)):
119 118 from_encoding = [from_encoding]
120 119
121 120 for enc in from_encoding:
122 121 try:
123 122 return str_.encode(enc)
124 123 except UnicodeDecodeError:
125 124 pass
126 125
127 126 return str_.encode(from_encoding[0], 'replace')
128 127
129 128
130 129 def ascii_bytes(str_, allow_bytes=False) -> bytes:
131 130 """
132 131 Simple conversion from str to bytes, with assumption that str_ is pure ASCII.
133 132 Fails with UnicodeError on invalid input.
134 133 This should be used where encoding and "safe" ambiguity should be avoided.
135 134 Where strings already have been encoded in other ways but still are unicode
136 135 string - for example to hex, base64, json, urlencoding, or are known to be
137 136 identifiers.
138 137 """
139 138 if allow_bytes and isinstance(str_, bytes):
140 139 return str_
141 140
142 141 if not isinstance(str_, str):
143 142 raise ValueError(f'ascii_bytes cannot convert other types than str: got: {type(str_)}')
144 143 return str_.encode('ascii')
145 144
146 145
147 146 def ascii_str(str_) -> str:
148 147 """
149 148 Simple conversion from bytes to str, with assumption that str_ is pure ASCII.
150 149 Fails with UnicodeError on invalid input.
151 150 This should be used where encoding and "safe" ambiguity should be avoided.
152 151 Where strings are encoded but also in other ways are known to be ASCII, and
153 152 where a unicode string is wanted without caring about encoding. For example
154 153 to hex, base64, urlencoding, or are known to be identifiers.
155 154 """
156 155
157 156 if not isinstance(str_, bytes):
158 157 raise ValueError(f'ascii_str cannot convert other types than bytes: got: {type(str_)}')
159 158 return str_.decode('ascii')
160 159
161 160
162 161 def convert_special_chars(str_) -> str:
163 162 """
164 163 trie to replace non-ascii letters to their ascii representation eg::
165 164
166 165 `żołw` converts into `zolw`
167 166 """
168 167 value = safe_str(str_)
169 168 converted_value = unidecode(value)
170 169 return converted_value
@@ -1,34 +1,33 b''
1
2 1 # Copyright (C) 2010-2023 RhodeCode GmbH
3 2 #
4 3 # This program is free software: you can redistribute it and/or modify
5 4 # it under the terms of the GNU Affero General Public License, version 3
6 5 # (only), as published by the Free Software Foundation.
7 6 #
8 7 # This program is distributed in the hope that it will be useful,
9 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 10 # GNU General Public License for more details.
12 11 #
13 12 # You should have received a copy of the GNU Affero General Public License
14 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 14 #
16 15 # This program is dual-licensed. If you wish to learn more about the
17 16 # RhodeCode Enterprise Edition, including its added features, Support services,
18 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 18
20 19
21 20 def html(info):
22 21 """
23 22 Custom string as html content_type renderer for pyramid
24 23 """
25 24 def _render(value, system):
26 25 request = system.get('request')
27 26 if request is not None:
28 27 response = request.response
29 28 ct = response.content_type
30 29 if ct == response.default_content_type:
31 30 response.content_type = 'text/html'
32 31 return value
33 32
34 33 return _render
@@ -1,848 +1,846 b''
1
2
3 1 # Copyright (C) 2017-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19
22 20 import os
23 21 import sys
24 22 import time
25 23 import platform
26 24 import collections
27 25 import psutil
28 26 from functools import wraps
29 27
30 28 import pkg_resources
31 29 import logging
32 30 import resource
33 31
34 32 import configparser
35 33
36 34 from rhodecode.lib.str_utils import safe_str
37 35
38 36 log = logging.getLogger(__name__)
39 37
40 38
41 39 _NA = 'NOT AVAILABLE'
42 40 _NA_FLOAT = 0.0
43 41
44 42 STATE_OK = 'ok'
45 43 STATE_ERR = 'error'
46 44 STATE_WARN = 'warning'
47 45
48 46 STATE_OK_DEFAULT = {'message': '', 'type': STATE_OK}
49 47
50 48
51 49 registered_helpers = {}
52 50
53 51
54 52 def register_sysinfo(func):
55 53 """
56 54 @register_helper
57 55 def db_check():
58 56 pass
59 57
60 58 db_check == registered_helpers['db_check']
61 59 """
62 60 global registered_helpers
63 61 registered_helpers[func.__name__] = func
64 62
65 63 @wraps(func)
66 64 def _wrapper(*args, **kwargs):
67 65 return func(*args, **kwargs)
68 66 return _wrapper
69 67
70 68
71 69 # HELPERS
72 70 def percentage(part: (int, float), whole: (int, float)):
73 71 whole = float(whole)
74 72 if whole > 0:
75 73 return round(100 * float(part) / whole, 1)
76 74 return 0.0
77 75
78 76
79 77 def get_storage_size(storage_path):
80 78 sizes = []
81 79 for file_ in os.listdir(storage_path):
82 80 storage_file = os.path.join(storage_path, file_)
83 81 if os.path.isfile(storage_file):
84 82 try:
85 83 sizes.append(os.path.getsize(storage_file))
86 84 except OSError:
87 85 log.exception('Failed to get size of storage file %s', storage_file)
88 86 pass
89 87
90 88 return sum(sizes)
91 89
92 90
93 91 def get_resource(resource_type):
94 92 try:
95 93 return resource.getrlimit(resource_type)
96 94 except Exception:
97 95 return 'NOT_SUPPORTED'
98 96
99 97
100 98 def get_cert_path(ini_path):
101 99 default = '/etc/ssl/certs/ca-certificates.crt'
102 100 control_ca_bundle = os.path.join(
103 101 os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(ini_path)))),
104 102 '.rccontrol-profile/etc/ca-bundle.crt')
105 103 if os.path.isfile(control_ca_bundle):
106 104 default = control_ca_bundle
107 105
108 106 return default
109 107
110 108
111 109 class SysInfoRes(object):
112 110 def __init__(self, value, state=None, human_value=None):
113 111 self.value = value
114 112 self.state = state or STATE_OK_DEFAULT
115 113 self.human_value = human_value or value
116 114
117 115 def __json__(self):
118 116 return {
119 117 'value': self.value,
120 118 'state': self.state,
121 119 'human_value': self.human_value,
122 120 }
123 121
124 122 def get_value(self):
125 123 return self.__json__()
126 124
127 125 def __str__(self):
128 return '<SysInfoRes({})>'.format(self.__json__())
126 return f'<SysInfoRes({self.__json__()})>'
129 127
130 128
131 129 class SysInfo(object):
132 130
133 131 def __init__(self, func_name, **kwargs):
134 132 self.function_name = func_name
135 133 self.value = _NA
136 134 self.state = None
137 135 self.kwargs = kwargs or {}
138 136
139 137 def __call__(self):
140 138 computed = self.compute(**self.kwargs)
141 139 if not isinstance(computed, SysInfoRes):
142 140 raise ValueError(
143 141 'computed value for {} is not instance of '
144 142 '{}, got {} instead'.format(
145 143 self.function_name, SysInfoRes, type(computed)))
146 144 return computed.__json__()
147 145
148 146 def __str__(self):
149 return '<SysInfo({})>'.format(self.function_name)
147 return f'<SysInfo({self.function_name})>'
150 148
151 149 def compute(self, **kwargs):
152 150 return self.function_name(**kwargs)
153 151
154 152
155 153 # SysInfo functions
156 154 @register_sysinfo
157 155 def python_info():
158 156 value = dict(version=f'{platform.python_version()}:{platform.python_implementation()}',
159 157 executable=sys.executable)
160 158 return SysInfoRes(value=value)
161 159
162 160
163 161 @register_sysinfo
164 162 def py_modules():
165 163 mods = dict([(p.project_name, {'version': p.version, 'location': p.location})
166 164 for p in pkg_resources.working_set])
167 165
168 166 value = sorted(mods.items(), key=lambda k: k[0].lower())
169 167 return SysInfoRes(value=value)
170 168
171 169
172 170 @register_sysinfo
173 171 def platform_type():
174 172 from rhodecode.lib.utils import generate_platform_uuid
175 173
176 174 value = dict(
177 175 name=safe_str(platform.platform()),
178 176 uuid=generate_platform_uuid()
179 177 )
180 178 return SysInfoRes(value=value)
181 179
182 180
183 181 @register_sysinfo
184 182 def locale_info():
185 183 import locale
186 184
187 185 def safe_get_locale(locale_name):
188 186 try:
189 187 locale.getlocale(locale_name)
190 188 except TypeError:
191 189 return f'FAILED_LOCALE_GET:{locale_name}'
192 190
193 191 value = dict(
194 192 locale_default=locale.getlocale(),
195 193 locale_lc_all=safe_get_locale(locale.LC_ALL),
196 194 locale_lc_ctype=safe_get_locale(locale.LC_CTYPE),
197 195 lang_env=os.environ.get('LANG'),
198 196 lc_all_env=os.environ.get('LC_ALL'),
199 197 local_archive_env=os.environ.get('LOCALE_ARCHIVE'),
200 198 )
201 199 human_value = \
202 200 f"LANG: {value['lang_env']}, \
203 201 locale LC_ALL: {value['locale_lc_all']}, \
204 202 locale LC_CTYPE: {value['locale_lc_ctype']}, \
205 203 Default locales: {value['locale_default']}"
206 204
207 205 return SysInfoRes(value=value, human_value=human_value)
208 206
209 207
210 208 @register_sysinfo
211 209 def ulimit_info():
212 210 data = collections.OrderedDict([
213 211 ('cpu time (seconds)', get_resource(resource.RLIMIT_CPU)),
214 212 ('file size', get_resource(resource.RLIMIT_FSIZE)),
215 213 ('stack size', get_resource(resource.RLIMIT_STACK)),
216 214 ('core file size', get_resource(resource.RLIMIT_CORE)),
217 215 ('address space size', get_resource(resource.RLIMIT_AS)),
218 216 ('locked in mem size', get_resource(resource.RLIMIT_MEMLOCK)),
219 217 ('heap size', get_resource(resource.RLIMIT_DATA)),
220 218 ('rss size', get_resource(resource.RLIMIT_RSS)),
221 219 ('number of processes', get_resource(resource.RLIMIT_NPROC)),
222 220 ('open files', get_resource(resource.RLIMIT_NOFILE)),
223 221 ])
224 222
225 223 text = ', '.join(f'{k}:{v}' for k, v in data.items())
226 224
227 225 value = {
228 226 'limits': data,
229 227 'text': text,
230 228 }
231 229 return SysInfoRes(value=value)
232 230
233 231
234 232 @register_sysinfo
235 233 def uptime():
236 234 from rhodecode.lib.helpers import age, time_to_datetime
237 235 from rhodecode.translation import TranslationString
238 236
239 237 value = dict(boot_time=0, uptime=0, text='')
240 238 state = STATE_OK_DEFAULT
241 239
242 240 boot_time = psutil.boot_time()
243 241 value['boot_time'] = boot_time
244 242 value['uptime'] = time.time() - boot_time
245 243
246 244 date_or_age = age(time_to_datetime(boot_time))
247 245 if isinstance(date_or_age, TranslationString):
248 246 date_or_age = date_or_age.interpolate()
249 247
250 248 human_value = value.copy()
251 249 human_value['boot_time'] = time_to_datetime(boot_time)
252 250 human_value['uptime'] = age(time_to_datetime(boot_time), show_suffix=False)
253 251
254 human_value['text'] = 'Server started {}'.format(date_or_age)
252 human_value['text'] = f'Server started {date_or_age}'
255 253 return SysInfoRes(value=value, human_value=human_value)
256 254
257 255
258 256 @register_sysinfo
259 257 def memory():
260 258 from rhodecode.lib.helpers import format_byte_size_binary
261 259 value = dict(available=0, used=0, used_real=0, cached=0, percent=0,
262 260 percent_used=0, free=0, inactive=0, active=0, shared=0,
263 261 total=0, buffers=0, text='')
264 262
265 263 state = STATE_OK_DEFAULT
266 264
267 265 value.update(dict(psutil.virtual_memory()._asdict()))
268 266 value['used_real'] = value['total'] - value['available']
269 267 value['percent_used'] = psutil._common.usage_percent(
270 268 value['used_real'], value['total'], 1)
271 269
272 270 human_value = value.copy()
273 human_value['text'] = '%s/%s, %s%% used' % (
271 human_value['text'] = '{}/{}, {}% used'.format(
274 272 format_byte_size_binary(value['used_real']),
275 273 format_byte_size_binary(value['total']),
276 value['percent_used'],)
274 value['percent_used'])
277 275
278 276 keys = list(value.keys())[::]
279 277 keys.pop(keys.index('percent'))
280 278 keys.pop(keys.index('percent_used'))
281 279 keys.pop(keys.index('text'))
282 280 for k in keys:
283 281 human_value[k] = format_byte_size_binary(value[k])
284 282
285 283 if state['type'] == STATE_OK and value['percent_used'] > 90:
286 284 msg = 'Critical: your available RAM memory is very low.'
287 285 state = {'message': msg, 'type': STATE_ERR}
288 286
289 287 elif state['type'] == STATE_OK and value['percent_used'] > 70:
290 288 msg = 'Warning: your available RAM memory is running low.'
291 289 state = {'message': msg, 'type': STATE_WARN}
292 290
293 291 return SysInfoRes(value=value, state=state, human_value=human_value)
294 292
295 293
296 294 @register_sysinfo
297 295 def machine_load():
298 296 value = {'1_min': _NA_FLOAT, '5_min': _NA_FLOAT, '15_min': _NA_FLOAT, 'text': ''}
299 297 state = STATE_OK_DEFAULT
300 298
301 299 # load averages
302 300 if hasattr(psutil.os, 'getloadavg'):
303 301 value.update(dict(
304 302 list(zip(['1_min', '5_min', '15_min'], psutil.os.getloadavg()))
305 303 ))
306 304
307 305 human_value = value.copy()
308 306 human_value['text'] = '1min: {}, 5min: {}, 15min: {}'.format(
309 307 value['1_min'], value['5_min'], value['15_min'])
310 308
311 309 if state['type'] == STATE_OK and value['15_min'] > 5.0:
312 310 msg = 'Warning: your machine load is very high.'
313 311 state = {'message': msg, 'type': STATE_WARN}
314 312
315 313 return SysInfoRes(value=value, state=state, human_value=human_value)
316 314
317 315
318 316 @register_sysinfo
319 317 def cpu():
320 318 value = {'cpu': 0, 'cpu_count': 0, 'cpu_usage': []}
321 319 state = STATE_OK_DEFAULT
322 320
323 321 value['cpu'] = psutil.cpu_percent(0.5)
324 322 value['cpu_usage'] = psutil.cpu_percent(0.5, percpu=True)
325 323 value['cpu_count'] = psutil.cpu_count()
326 324
327 325 human_value = value.copy()
328 326 human_value['text'] = '{} cores at {} %'.format(
329 327 value['cpu_count'], value['cpu'])
330 328
331 329 return SysInfoRes(value=value, state=state, human_value=human_value)
332 330
333 331
334 332 @register_sysinfo
335 333 def storage():
336 334 from rhodecode.lib.helpers import format_byte_size_binary
337 335 from rhodecode.model.settings import VcsSettingsModel
338 336 path = VcsSettingsModel().get_repos_location()
339 337
340 338 value = dict(percent=0, used=0, total=0, path=path, text='')
341 339 state = STATE_OK_DEFAULT
342 340
343 341 try:
344 342 value.update(dict(psutil.disk_usage(path)._asdict()))
345 343 except Exception as e:
346 344 log.exception('Failed to fetch disk info')
347 345 state = {'message': str(e), 'type': STATE_ERR}
348 346
349 347 human_value = value.copy()
350 348 human_value['used'] = format_byte_size_binary(value['used'])
351 349 human_value['total'] = format_byte_size_binary(value['total'])
352 350 human_value['text'] = "{}/{}, {}% used".format(
353 351 format_byte_size_binary(value['used']),
354 352 format_byte_size_binary(value['total']),
355 353 value['percent'])
356 354
357 355 if state['type'] == STATE_OK and value['percent'] > 90:
358 356 msg = 'Critical: your disk space is very low.'
359 357 state = {'message': msg, 'type': STATE_ERR}
360 358
361 359 elif state['type'] == STATE_OK and value['percent'] > 70:
362 360 msg = 'Warning: your disk space is running low.'
363 361 state = {'message': msg, 'type': STATE_WARN}
364 362
365 363 return SysInfoRes(value=value, state=state, human_value=human_value)
366 364
367 365
368 366 @register_sysinfo
369 367 def storage_inodes():
370 368 from rhodecode.model.settings import VcsSettingsModel
371 369 path = VcsSettingsModel().get_repos_location()
372 370
373 371 value = dict(percent=0.0, free=0, used=0, total=0, path=path, text='')
374 372 state = STATE_OK_DEFAULT
375 373
376 374 try:
377 375 i_stat = os.statvfs(path)
378 376 value['free'] = i_stat.f_ffree
379 377 value['used'] = i_stat.f_files-i_stat.f_favail
380 378 value['total'] = i_stat.f_files
381 379 value['percent'] = percentage(value['used'], value['total'])
382 380 except Exception as e:
383 381 log.exception('Failed to fetch disk inodes info')
384 382 state = {'message': str(e), 'type': STATE_ERR}
385 383
386 384 human_value = value.copy()
387 385 human_value['text'] = "{}/{}, {}% used".format(
388 386 value['used'], value['total'], value['percent'])
389 387
390 388 if state['type'] == STATE_OK and value['percent'] > 90:
391 389 msg = 'Critical: your disk free inodes are very low.'
392 390 state = {'message': msg, 'type': STATE_ERR}
393 391
394 392 elif state['type'] == STATE_OK and value['percent'] > 70:
395 393 msg = 'Warning: your disk free inodes are running low.'
396 394 state = {'message': msg, 'type': STATE_WARN}
397 395
398 396 return SysInfoRes(value=value, state=state, human_value=human_value)
399 397
400 398
401 399 @register_sysinfo
402 400 def storage_archives():
403 401 import rhodecode
404 402 from rhodecode.lib.utils import safe_str
405 403 from rhodecode.lib.helpers import format_byte_size_binary
406 404
407 405 msg = 'Archive cache storage is controlled by ' \
408 406 'archive_cache.store_dir=/path/to/cache option in the .ini file'
409 407 path = safe_str(rhodecode.CONFIG.get('archive_cache.store_dir', msg))
410 408
411 409 value = dict(percent=0, used=0, total=0, items=0, path=path, text='')
412 410 state = STATE_OK_DEFAULT
413 411 try:
414 412 items_count = 0
415 413 used = 0
416 414 for root, dirs, files in os.walk(path):
417 415 if root == path:
418 416 items_count = len(dirs)
419 417
420 418 for f in files:
421 419 try:
422 420 used += os.path.getsize(os.path.join(root, f))
423 421 except OSError:
424 422 pass
425 423 value.update({
426 424 'percent': 100,
427 425 'used': used,
428 426 'total': used,
429 427 'items': items_count
430 428 })
431 429
432 430 except Exception as e:
433 431 log.exception('failed to fetch archive cache storage')
434 432 state = {'message': str(e), 'type': STATE_ERR}
435 433
436 434 human_value = value.copy()
437 435 human_value['used'] = format_byte_size_binary(value['used'])
438 436 human_value['total'] = format_byte_size_binary(value['total'])
439 437 human_value['text'] = "{} ({} items)".format(
440 438 human_value['used'], value['items'])
441 439
442 440 return SysInfoRes(value=value, state=state, human_value=human_value)
443 441
444 442
445 443 @register_sysinfo
446 444 def storage_gist():
447 445 from rhodecode.model.gist import GIST_STORE_LOC
448 446 from rhodecode.model.settings import VcsSettingsModel
449 447 from rhodecode.lib.utils import safe_str
450 448 from rhodecode.lib.helpers import format_byte_size_binary
451 449 path = safe_str(os.path.join(
452 450 VcsSettingsModel().get_repos_location(), GIST_STORE_LOC))
453 451
454 452 # gist storage
455 453 value = dict(percent=0, used=0, total=0, items=0, path=path, text='')
456 454 state = STATE_OK_DEFAULT
457 455
458 456 try:
459 457 items_count = 0
460 458 used = 0
461 459 for root, dirs, files in os.walk(path):
462 460 if root == path:
463 461 items_count = len(dirs)
464 462
465 463 for f in files:
466 464 try:
467 465 used += os.path.getsize(os.path.join(root, f))
468 466 except OSError:
469 467 pass
470 468 value.update({
471 469 'percent': 100,
472 470 'used': used,
473 471 'total': used,
474 472 'items': items_count
475 473 })
476 474 except Exception as e:
477 475 log.exception('failed to fetch gist storage items')
478 476 state = {'message': str(e), 'type': STATE_ERR}
479 477
480 478 human_value = value.copy()
481 479 human_value['used'] = format_byte_size_binary(value['used'])
482 480 human_value['total'] = format_byte_size_binary(value['total'])
483 481 human_value['text'] = "{} ({} items)".format(
484 482 human_value['used'], value['items'])
485 483
486 484 return SysInfoRes(value=value, state=state, human_value=human_value)
487 485
488 486
489 487 @register_sysinfo
490 488 def storage_temp():
491 489 import tempfile
492 490 from rhodecode.lib.helpers import format_byte_size_binary
493 491
494 492 path = tempfile.gettempdir()
495 493 value = dict(percent=0, used=0, total=0, items=0, path=path, text='')
496 494 state = STATE_OK_DEFAULT
497 495
498 496 if not psutil:
499 497 return SysInfoRes(value=value, state=state)
500 498
501 499 try:
502 500 value.update(dict(psutil.disk_usage(path)._asdict()))
503 501 except Exception as e:
504 502 log.exception('Failed to fetch temp dir info')
505 503 state = {'message': str(e), 'type': STATE_ERR}
506 504
507 505 human_value = value.copy()
508 506 human_value['used'] = format_byte_size_binary(value['used'])
509 507 human_value['total'] = format_byte_size_binary(value['total'])
510 508 human_value['text'] = "{}/{}, {}% used".format(
511 509 format_byte_size_binary(value['used']),
512 510 format_byte_size_binary(value['total']),
513 511 value['percent'])
514 512
515 513 return SysInfoRes(value=value, state=state, human_value=human_value)
516 514
517 515
518 516 @register_sysinfo
519 517 def search_info():
520 518 import rhodecode
521 519 from rhodecode.lib.index import searcher_from_config
522 520
523 521 backend = rhodecode.CONFIG.get('search.module', '')
524 522 location = rhodecode.CONFIG.get('search.location', '')
525 523
526 524 try:
527 525 searcher = searcher_from_config(rhodecode.CONFIG)
528 526 searcher = searcher.__class__.__name__
529 527 except Exception:
530 528 searcher = None
531 529
532 530 value = dict(
533 531 backend=backend, searcher=searcher, location=location, text='')
534 532 state = STATE_OK_DEFAULT
535 533
536 534 human_value = value.copy()
537 535 human_value['text'] = "backend:`{}`".format(human_value['backend'])
538 536
539 537 return SysInfoRes(value=value, state=state, human_value=human_value)
540 538
541 539
542 540 @register_sysinfo
543 541 def git_info():
544 542 from rhodecode.lib.vcs.backends import git
545 543 state = STATE_OK_DEFAULT
546 544 value = human_value = ''
547 545 try:
548 546 value = git.discover_git_version(raise_on_exc=True)
549 human_value = 'version reported from VCSServer: {}'.format(value)
547 human_value = f'version reported from VCSServer: {value}'
550 548 except Exception as e:
551 549 state = {'message': str(e), 'type': STATE_ERR}
552 550
553 551 return SysInfoRes(value=value, state=state, human_value=human_value)
554 552
555 553
556 554 @register_sysinfo
557 555 def hg_info():
558 556 from rhodecode.lib.vcs.backends import hg
559 557 state = STATE_OK_DEFAULT
560 558 value = human_value = ''
561 559 try:
562 560 value = hg.discover_hg_version(raise_on_exc=True)
563 human_value = 'version reported from VCSServer: {}'.format(value)
561 human_value = f'version reported from VCSServer: {value}'
564 562 except Exception as e:
565 563 state = {'message': str(e), 'type': STATE_ERR}
566 564 return SysInfoRes(value=value, state=state, human_value=human_value)
567 565
568 566
569 567 @register_sysinfo
570 568 def svn_info():
571 569 from rhodecode.lib.vcs.backends import svn
572 570 state = STATE_OK_DEFAULT
573 571 value = human_value = ''
574 572 try:
575 573 value = svn.discover_svn_version(raise_on_exc=True)
576 human_value = 'version reported from VCSServer: {}'.format(value)
574 human_value = f'version reported from VCSServer: {value}'
577 575 except Exception as e:
578 576 state = {'message': str(e), 'type': STATE_ERR}
579 577 return SysInfoRes(value=value, state=state, human_value=human_value)
580 578
581 579
582 580 @register_sysinfo
583 581 def vcs_backends():
584 582 import rhodecode
585 583 value = rhodecode.CONFIG.get('vcs.backends')
586 584 human_value = 'Enabled backends in order: {}'.format(','.join(value))
587 585 return SysInfoRes(value=value, human_value=human_value)
588 586
589 587
590 588 @register_sysinfo
591 589 def vcs_server():
592 590 import rhodecode
593 591 from rhodecode.lib.vcs.backends import get_vcsserver_service_data
594 592
595 593 server_url = rhodecode.CONFIG.get('vcs.server')
596 594 enabled = rhodecode.CONFIG.get('vcs.server.enable')
597 595 protocol = rhodecode.CONFIG.get('vcs.server.protocol') or 'http'
598 596 state = STATE_OK_DEFAULT
599 597 version = None
600 598 workers = 0
601 599
602 600 try:
603 601 data = get_vcsserver_service_data()
604 602 if data and 'version' in data:
605 603 version = data['version']
606 604
607 605 if data and 'config' in data:
608 606 conf = data['config']
609 607 workers = conf.get('workers', 'NOT AVAILABLE')
610 608
611 609 connection = 'connected'
612 610 except Exception as e:
613 611 connection = 'failed'
614 612 state = {'message': str(e), 'type': STATE_ERR}
615 613
616 614 value = dict(
617 615 url=server_url,
618 616 enabled=enabled,
619 617 protocol=protocol,
620 618 connection=connection,
621 619 version=version,
622 620 text='',
623 621 )
624 622
625 623 human_value = value.copy()
626 624 human_value['text'] = \
627 625 '{url}@ver:{ver} via {mode} mode[workers:{workers}], connection:{conn}'.format(
628 626 url=server_url, ver=version, workers=workers, mode=protocol,
629 627 conn=connection)
630 628
631 629 return SysInfoRes(value=value, state=state, human_value=human_value)
632 630
633 631
634 632 @register_sysinfo
635 633 def vcs_server_config():
636 634 from rhodecode.lib.vcs.backends import get_vcsserver_service_data
637 635 state = STATE_OK_DEFAULT
638 636
639 637 value = {}
640 638 try:
641 639 data = get_vcsserver_service_data()
642 640 value = data['app_config']
643 641 except Exception as e:
644 642 state = {'message': str(e), 'type': STATE_ERR}
645 643
646 644 human_value = value.copy()
647 645 human_value['text'] = 'VCS Server config'
648 646
649 647 return SysInfoRes(value=value, state=state, human_value=human_value)
650 648
651 649
652 650 @register_sysinfo
653 651 def rhodecode_app_info():
654 652 import rhodecode
655 653 edition = rhodecode.CONFIG.get('rhodecode.edition')
656 654
657 655 value = dict(
658 656 rhodecode_version=rhodecode.__version__,
659 657 rhodecode_lib_path=os.path.abspath(rhodecode.__file__),
660 658 text=''
661 659 )
662 660 human_value = value.copy()
663 661 human_value['text'] = 'RhodeCode {edition}, version {ver}'.format(
664 662 edition=edition, ver=value['rhodecode_version']
665 663 )
666 664 return SysInfoRes(value=value, human_value=human_value)
667 665
668 666
669 667 @register_sysinfo
670 668 def rhodecode_config():
671 669 import rhodecode
672 670 path = rhodecode.CONFIG.get('__file__')
673 671 rhodecode_ini_safe = rhodecode.CONFIG.copy()
674 672 cert_path = get_cert_path(path)
675 673
676 674 try:
677 675 config = configparser.ConfigParser()
678 676 config.read(path)
679 677 parsed_ini = config
680 678 if parsed_ini.has_section('server:main'):
681 679 parsed_ini = dict(parsed_ini.items('server:main'))
682 680 except Exception:
683 681 log.exception('Failed to read .ini file for display')
684 682 parsed_ini = {}
685 683
686 684 rhodecode_ini_safe['server:main'] = parsed_ini
687 685
688 686 blacklist = [
689 687 'rhodecode_license_key',
690 688 'routes.map',
691 689 'sqlalchemy.db1.url',
692 690 'channelstream.secret',
693 691 'beaker.session.secret',
694 692 'rhodecode.encrypted_values.secret',
695 693 'rhodecode_auth_github_consumer_key',
696 694 'rhodecode_auth_github_consumer_secret',
697 695 'rhodecode_auth_google_consumer_key',
698 696 'rhodecode_auth_google_consumer_secret',
699 697 'rhodecode_auth_bitbucket_consumer_secret',
700 698 'rhodecode_auth_bitbucket_consumer_key',
701 699 'rhodecode_auth_twitter_consumer_secret',
702 700 'rhodecode_auth_twitter_consumer_key',
703 701
704 702 'rhodecode_auth_twitter_secret',
705 703 'rhodecode_auth_github_secret',
706 704 'rhodecode_auth_google_secret',
707 705 'rhodecode_auth_bitbucket_secret',
708 706
709 707 'appenlight.api_key',
710 708 ('app_conf', 'sqlalchemy.db1.url')
711 709 ]
712 710 for k in blacklist:
713 711 if isinstance(k, tuple):
714 712 section, key = k
715 713 if section in rhodecode_ini_safe:
716 714 rhodecode_ini_safe[section] = '**OBFUSCATED**'
717 715 else:
718 716 rhodecode_ini_safe.pop(k, None)
719 717
720 718 # TODO: maybe put some CONFIG checks here ?
721 719 return SysInfoRes(value={'config': rhodecode_ini_safe,
722 720 'path': path, 'cert_path': cert_path})
723 721
724 722
725 723 @register_sysinfo
726 724 def database_info():
727 725 import rhodecode
728 726 from sqlalchemy.engine import url as engine_url
729 727 from rhodecode.model import meta
730 728 from rhodecode.model.meta import Session
731 729 from rhodecode.model.db import DbMigrateVersion
732 730
733 731 state = STATE_OK_DEFAULT
734 732
735 733 db_migrate = DbMigrateVersion.query().filter(
736 734 DbMigrateVersion.repository_id == 'rhodecode_db_migrations').one()
737 735
738 736 db_url_obj = engine_url.make_url(rhodecode.CONFIG['sqlalchemy.db1.url'])
739 737
740 738 try:
741 739 engine = meta.get_engine()
742 740 db_server_info = engine.dialect._get_server_version_info(
743 741 Session.connection(bind=engine))
744 742 db_version = '.'.join(map(str, db_server_info))
745 743 except Exception:
746 744 log.exception('failed to fetch db version')
747 745 db_version = 'UNKNOWN'
748 746
749 747 db_info = dict(
750 748 migrate_version=db_migrate.version,
751 749 type=db_url_obj.get_backend_name(),
752 750 version=db_version,
753 751 url=repr(db_url_obj)
754 752 )
755 753 current_version = db_migrate.version
756 754 expected_version = rhodecode.__dbversion__
757 755 if state['type'] == STATE_OK and current_version != expected_version:
758 756 msg = 'Critical: database schema mismatch, ' \
759 757 'expected version {}, got {}. ' \
760 758 'Please run migrations on your database.'.format(
761 759 expected_version, current_version)
762 760 state = {'message': msg, 'type': STATE_ERR}
763 761
764 762 human_value = db_info.copy()
765 763 human_value['url'] = "{} @ migration version: {}".format(
766 764 db_info['url'], db_info['migrate_version'])
767 765 human_value['version'] = "{} {}".format(db_info['type'], db_info['version'])
768 766 return SysInfoRes(value=db_info, state=state, human_value=human_value)
769 767
770 768
771 769 @register_sysinfo
772 770 def server_info(environ):
773 771 import rhodecode
774 772 from rhodecode.lib.base import get_server_ip_addr, get_server_port
775 773
776 774 value = {
777 'server_ip': '%s:%s' % (
775 'server_ip': '{}:{}'.format(
778 776 get_server_ip_addr(environ, log_errors=False),
779 777 get_server_port(environ)
780 778 ),
781 779 'server_id': rhodecode.CONFIG.get('instance_id'),
782 780 }
783 781 return SysInfoRes(value=value)
784 782
785 783
786 784 @register_sysinfo
787 785 def usage_info():
788 786 from rhodecode.model.db import User, Repository
789 787 value = {
790 788 'users': User.query().count(),
791 789 'users_active': User.query().filter(User.active == True).count(),
792 790 'repositories': Repository.query().count(),
793 791 'repository_types': {
794 792 'hg': Repository.query().filter(
795 793 Repository.repo_type == 'hg').count(),
796 794 'git': Repository.query().filter(
797 795 Repository.repo_type == 'git').count(),
798 796 'svn': Repository.query().filter(
799 797 Repository.repo_type == 'svn').count(),
800 798 },
801 799 }
802 800 return SysInfoRes(value=value)
803 801
804 802
805 803 def get_system_info(environ):
806 804 environ = environ or {}
807 805 return {
808 806 'rhodecode_app': SysInfo(rhodecode_app_info)(),
809 807 'rhodecode_config': SysInfo(rhodecode_config)(),
810 808 'rhodecode_usage': SysInfo(usage_info)(),
811 809 'python': SysInfo(python_info)(),
812 810 'py_modules': SysInfo(py_modules)(),
813 811
814 812 'platform': SysInfo(platform_type)(),
815 813 'locale': SysInfo(locale_info)(),
816 814 'server': SysInfo(server_info, environ=environ)(),
817 815 'database': SysInfo(database_info)(),
818 816 'ulimit': SysInfo(ulimit_info)(),
819 817 'storage': SysInfo(storage)(),
820 818 'storage_inodes': SysInfo(storage_inodes)(),
821 819 'storage_archive': SysInfo(storage_archives)(),
822 820 'storage_gist': SysInfo(storage_gist)(),
823 821 'storage_temp': SysInfo(storage_temp)(),
824 822
825 823 'search': SysInfo(search_info)(),
826 824
827 825 'uptime': SysInfo(uptime)(),
828 826 'load': SysInfo(machine_load)(),
829 827 'cpu': SysInfo(cpu)(),
830 828 'memory': SysInfo(memory)(),
831 829
832 830 'vcs_backends': SysInfo(vcs_backends)(),
833 831 'vcs_server': SysInfo(vcs_server)(),
834 832
835 833 'vcs_server_config': SysInfo(vcs_server_config)(),
836 834
837 835 'git': SysInfo(git_info)(),
838 836 'hg': SysInfo(hg_info)(),
839 837 'svn': SysInfo(svn_info)(),
840 838 }
841 839
842 840
843 841 def load_system_info(key):
844 842 """
845 843 get_sys_info('vcs_server')
846 844 get_sys_info('database')
847 845 """
848 846 return SysInfo(registered_helpers[key])()
@@ -1,93 +1,91 b''
1
2
3 1 # Copyright (C) 2011-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import logging
22 20
23 21 log = logging.getLogger(__name__)
24 22
25 23
26 24 def str2bool(str_):
27 25 """
28 26 returns True/False value from given string, it tries to translate the
29 27 string into boolean
30 28
31 29 :param str_: string value to translate into boolean
32 30 :rtype: boolean
33 31 :returns: boolean from given string
34 32 """
35 33 if str_ is None:
36 34 return False
37 35 if str_ in (True, False):
38 36 return str_
39 37 str_ = str(str_).strip().lower()
40 38 return str_ in ('t', 'true', 'y', 'yes', 'on', '1')
41 39
42 40
43 41 def aslist(obj, sep=None, strip=True) -> list:
44 42 """
45 43 Returns given string separated by sep as list
46 44
47 45 :param obj:
48 46 :param sep:
49 47 :param strip:
50 48 """
51 49 if isinstance(obj, str):
52 50 if obj in ['', ""]:
53 51 return []
54 52
55 53 lst = obj.split(sep)
56 54 if strip:
57 55 lst = [v.strip() for v in lst]
58 56 return lst
59 57 elif isinstance(obj, (list, tuple)):
60 58 return obj
61 59 elif obj is None:
62 60 return []
63 61 else:
64 62 return [obj]
65 63
66 64
67 65 class AttributeDictBase(dict):
68 66 def __getstate__(self):
69 67 odict = self.__dict__ # get attribute dictionary
70 68 return odict
71 69
72 70 def __setstate__(self, dict):
73 71 self.__dict__ = dict
74 72
75 73 __setattr__ = dict.__setitem__
76 74 __delattr__ = dict.__delitem__
77 75
78 76
79 77 class StrictAttributeDict(AttributeDictBase):
80 78 """
81 79 Strict Version of Attribute dict which raises an Attribute error when
82 80 requested attribute is not set
83 81 """
84 82 def __getattr__(self, attr):
85 83 try:
86 84 return self[attr]
87 85 except KeyError:
88 86 raise AttributeError(f'{self.__class__} object has no attribute {attr}')
89 87
90 88
91 89 class AttributeDict(AttributeDictBase):
92 90 def __getattr__(self, attr):
93 91 return self.get(attr, None)
@@ -1,128 +1,127 b''
1
2 1 # Copyright (C) 2010-2023 RhodeCode GmbH
3 2 #
4 3 # This program is free software: you can redistribute it and/or modify
5 4 # it under the terms of the GNU Affero General Public License, version 3
6 5 # (only), as published by the Free Software Foundation.
7 6 #
8 7 # This program is distributed in the hope that it will be useful,
9 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 10 # GNU General Public License for more details.
12 11 #
13 12 # You should have received a copy of the GNU Affero General Public License
14 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 14 #
16 15 # This program is dual-licensed. If you wish to learn more about the
17 16 # RhodeCode Enterprise Edition, including its added features, Support services,
18 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 18
20 19 import logging
21 20
22 21 from whoosh.qparser.default import QueryParser, query
23 22 from whoosh.qparser.dateparse import DateParserPlugin
24 23 from whoosh.fields import (TEXT, Schema, DATETIME, KEYWORD)
25 24 from sqlalchemy.sql.expression import or_, and_, not_, func
26 25
27 26 from rhodecode.model.db import UserLog
28 27 from rhodecode.lib.utils2 import remove_prefix, remove_suffix
29 28 from rhodecode.lib.str_utils import safe_str
30 29
31 30
32 31 # JOURNAL SCHEMA used only to generate queries in journal. We use whoosh
33 32 # querylang to build sql queries and filter journals
34 33 AUDIT_LOG_SCHEMA = Schema(
35 34 username=KEYWORD(),
36 35 repository=KEYWORD(),
37 36
38 37 date=DATETIME(),
39 38 action=TEXT(),
40 39 ip=TEXT(),
41 40 )
42 41
43 42 log = logging.getLogger(__name__)
44 43
45 44
46 45 def user_log_filter(user_log, search_term):
47 46 """
48 47 Filters sqlalchemy user_log based on search_term with whoosh Query language
49 48 http://packages.python.org/Whoosh/querylang.html
50 49
51 50 :param user_log:
52 51 :param search_term:
53 52 """
54 53 log.debug('Initial search term: %r', search_term)
55 54 qry = None
56 55 if search_term:
57 56 qp = QueryParser('repository', schema=AUDIT_LOG_SCHEMA)
58 57 qp.add_plugin(DateParserPlugin())
59 58 qry = qp.parse(safe_str(search_term))
60 59 log.debug('Filtering using parsed query %r', qry)
61 60
62 61 def wildcard_handler(col, wc_term):
63 62 if wc_term.startswith('*') and not wc_term.endswith('*'):
64 63 # postfix == endswith
65 64 wc_term = remove_prefix(wc_term, prefix='*')
66 65 return func.lower(col).endswith(wc_term)
67 66 elif wc_term.startswith('*') and wc_term.endswith('*'):
68 67 # wildcard == ilike
69 68 wc_term = remove_prefix(wc_term, prefix='*')
70 69 wc_term = remove_suffix(wc_term, suffix='*')
71 70 return func.lower(col).contains(wc_term)
72 71
73 72 def get_filterion(field, val, term):
74 73
75 74 if field == 'repository':
76 75 field = getattr(UserLog, 'repository_name')
77 76 elif field == 'ip':
78 77 field = getattr(UserLog, 'user_ip')
79 78 elif field == 'date':
80 79 field = getattr(UserLog, 'action_date')
81 80 elif field == 'username':
82 81 field = getattr(UserLog, 'username')
83 82 else:
84 83 field = getattr(UserLog, field)
85 84 log.debug('filter field: %s val=>%s', field, val)
86 85
87 86 # sql filtering
88 87 if isinstance(term, query.Wildcard):
89 88 return wildcard_handler(field, val)
90 89 elif isinstance(term, query.Prefix):
91 90 return func.lower(field).startswith(func.lower(val))
92 91 elif isinstance(term, query.DateRange):
93 92 return and_(field >= val[0], field <= val[1])
94 93 elif isinstance(term, query.Not):
95 94 return not_(field == val)
96 95 return func.lower(field) == func.lower(val)
97 96
98 97 if isinstance(qry, (query.And, query.Not, query.Term, query.Prefix,
99 98 query.Wildcard, query.DateRange)):
100 99 if not isinstance(qry, query.And):
101 100 qry = [qry]
102 101
103 102 for term in qry:
104 103 if isinstance(term, query.Not):
105 104 not_term = [z for z in term.leaves()][0]
106 105 field = not_term.fieldname
107 106 val = not_term.text
108 107 elif isinstance(term, query.DateRange):
109 108 field = term.fieldname
110 109 val = [term.startdate, term.enddate]
111 110 elif isinstance(term, query.NullQuery.__class__):
112 111 field = ''
113 112 val = ''
114 113 else:
115 114 field = term.fieldname
116 115 val = term.text
117 116 if field:
118 117 user_log = user_log.filter(get_filterion(field, val, term))
119 118 elif isinstance(qry, query.Or):
120 119 filters = []
121 120 for term in qry:
122 121 field = term.fieldname
123 122 val = (term.text if not isinstance(term, query.DateRange)
124 123 else [term.startdate, term.enddate])
125 124 filters.append(get_filterion(field, val, term))
126 125 user_log = user_log.filter(or_(*filters))
127 126
128 127 return user_log
@@ -1,264 +1,261 b''
1
2
3 1 # Copyright (C) 2017-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import os
22 20 import re
23 21 import time
24 22 import datetime
25 23 import dateutil
26 24 import pickle
27 25
28 26 from rhodecode.model.db import DbSession, Session
29 27
30 28
31 29 class CleanupCommand(Exception):
32 30 pass
33 31
34 32
35 33 class BaseAuthSessions(object):
36 34 SESSION_TYPE = None
37 35 NOT_AVAILABLE = 'NOT AVAILABLE'
38 36
39 37 def __init__(self, config):
40 38 session_conf = {}
41 39 for k, v in list(config.items()):
42 40 if k.startswith('beaker.session'):
43 41 session_conf[k] = v
44 42 self.config = session_conf
45 43
46 44 def get_count(self):
47 45 raise NotImplementedError
48 46
49 47 def get_expired_count(self, older_than_seconds=None):
50 48 raise NotImplementedError
51 49
52 50 def clean_sessions(self, older_than_seconds=None):
53 51 raise NotImplementedError
54 52
55 53 def _seconds_to_date(self, seconds):
56 54 return datetime.datetime.utcnow() - dateutil.relativedelta.relativedelta(
57 55 seconds=seconds)
58 56
59 57
60 58 class DbAuthSessions(BaseAuthSessions):
61 59 SESSION_TYPE = 'ext:database'
62 60
63 61 def get_count(self):
64 62 return DbSession.query().count()
65 63
66 64 def get_expired_count(self, older_than_seconds=None):
67 65 expiry_date = self._seconds_to_date(older_than_seconds)
68 66 return DbSession.query().filter(DbSession.accessed < expiry_date).count()
69 67
70 68 def clean_sessions(self, older_than_seconds=None):
71 69 expiry_date = self._seconds_to_date(older_than_seconds)
72 70 to_remove = DbSession.query().filter(DbSession.accessed < expiry_date).count()
73 71 DbSession.query().filter(DbSession.accessed < expiry_date).delete()
74 72 Session().commit()
75 73 return to_remove
76 74
77 75
78 76 class FileAuthSessions(BaseAuthSessions):
79 77 SESSION_TYPE = 'file sessions'
80 78
81 79 def _get_sessions_dir(self):
82 80 data_dir = self.config.get('beaker.session.data_dir')
83 81 return data_dir
84 82
85 83 def _count_on_filesystem(self, path, older_than=0, callback=None):
86 84 value = dict(percent=0, used=0, total=0, items=0, callbacks=0,
87 85 path=path, text='')
88 86 items_count = 0
89 87 used = 0
90 88 callbacks = 0
91 89 cur_time = time.time()
92 90 for root, dirs, files in os.walk(path):
93 91 for f in files:
94 92 final_path = os.path.join(root, f)
95 93 try:
96 94 mtime = os.stat(final_path).st_mtime
97 95 if (cur_time - mtime) > older_than:
98 96 items_count += 1
99 97 if callback:
100 98 callback_res = callback(final_path)
101 99 callbacks += 1
102 100 else:
103 101 used += os.path.getsize(final_path)
104 102 except OSError:
105 103 pass
106 104 value.update({
107 105 'percent': 100,
108 106 'used': used,
109 107 'total': used,
110 108 'items': items_count,
111 109 'callbacks': callbacks
112 110 })
113 111 return value
114 112
115 113 def get_count(self):
116 114 try:
117 115 sessions_dir = self._get_sessions_dir()
118 116 items_count = self._count_on_filesystem(sessions_dir)['items']
119 117 except Exception:
120 118 items_count = self.NOT_AVAILABLE
121 119 return items_count
122 120
123 121 def get_expired_count(self, older_than_seconds=0):
124 122 try:
125 123 sessions_dir = self._get_sessions_dir()
126 124 items_count = self._count_on_filesystem(
127 125 sessions_dir, older_than=older_than_seconds)['items']
128 126 except Exception:
129 127 items_count = self.NOT_AVAILABLE
130 128 return items_count
131 129
132 130 def clean_sessions(self, older_than_seconds=0):
133 131 # find . -mtime +60 -exec rm {} \;
134 132
135 133 sessions_dir = self._get_sessions_dir()
136 134
137 135 def remove_item(path):
138 136 os.remove(path)
139 137
140 138 stats = self._count_on_filesystem(
141 139 sessions_dir, older_than=older_than_seconds,
142 140 callback=remove_item)
143 141 return stats['callbacks']
144 142
145 143
146 144 class MemcachedAuthSessions(BaseAuthSessions):
147 145 SESSION_TYPE = 'ext:memcached'
148 146 _key_regex = re.compile(r'ITEM (.*_session) \[(.*); (.*)\]')
149 147
150 148 def _get_client(self):
151 149 import memcache
152 150 client = memcache.Client([self.config.get('beaker.session.url')])
153 151 return client
154 152
155 153 def _get_telnet_client(self, host, port):
156 154 import telnetlib
157 155 client = telnetlib.Telnet(host, port, None)
158 156 return client
159 157
160 158 def _run_telnet_cmd(self, client, cmd):
161 159 client.write("%s\n" % cmd)
162 160 return client.read_until('END')
163 161
164 162 def key_details(self, client, slab_ids, limit=100):
165 163 """ Return a list of tuples containing keys and details """
166 164 cmd = 'stats cachedump %s %s'
167 165 for slab_id in slab_ids:
168 for key in self._key_regex.finditer(
169 self._run_telnet_cmd(client, cmd % (slab_id, limit))):
170 yield key
166 yield from self._key_regex.finditer(
167 self._run_telnet_cmd(client, cmd % (slab_id, limit)))
171 168
172 169 def get_count(self):
173 170 client = self._get_client()
174 171 count = self.NOT_AVAILABLE
175 172 try:
176 173 slabs = []
177 174 for server, slabs_data in client.get_slabs():
178 175 slabs.extend(list(slabs_data.keys()))
179 176
180 177 host, port = client.servers[0].address
181 178 telnet_client = self._get_telnet_client(host, port)
182 179 keys = self.key_details(telnet_client, slabs)
183 180 count = 0
184 181 for _k in keys:
185 182 count += 1
186 183 except Exception:
187 184 return count
188 185
189 186 return count
190 187
191 188 def get_expired_count(self, older_than_seconds=None):
192 189 return self.NOT_AVAILABLE
193 190
194 191 def clean_sessions(self, older_than_seconds=None):
195 192 raise CleanupCommand('Cleanup for this session type not yet available')
196 193
197 194
198 195 class RedisAuthSessions(BaseAuthSessions):
199 196 SESSION_TYPE = 'ext:redis'
200 197
201 198 def _get_client(self):
202 199 import redis
203 200 args = {
204 201 'socket_timeout': 60,
205 202 'url': self.config.get('beaker.session.url')
206 203 }
207 204
208 205 client = redis.StrictRedis.from_url(**args)
209 206 return client
210 207
211 208 def get_count(self):
212 209 client = self._get_client()
213 210 return len(client.keys('beaker_cache:*'))
214 211
215 212 def get_expired_count(self, older_than_seconds=None):
216 213 expiry_date = self._seconds_to_date(older_than_seconds)
217 214 return self.NOT_AVAILABLE
218 215
219 216 def clean_sessions(self, older_than_seconds=None):
220 217 client = self._get_client()
221 218 expiry_time = time.time() - older_than_seconds
222 219 deleted_keys = 0
223 220 for key in client.keys('beaker_cache:*'):
224 221 data = client.get(key)
225 222 if data:
226 223 json_data = pickle.loads(data)
227 224 try:
228 225 accessed_time = json_data['_accessed_time']
229 226 except KeyError:
230 227 accessed_time = 0
231 228 if accessed_time < expiry_time:
232 229 client.delete(key)
233 230 deleted_keys += 1
234 231
235 232 return deleted_keys
236 233
237 234
238 235 class MemoryAuthSessions(BaseAuthSessions):
239 236 SESSION_TYPE = 'memory'
240 237
241 238 def get_count(self):
242 239 return self.NOT_AVAILABLE
243 240
244 241 def get_expired_count(self, older_than_seconds=None):
245 242 return self.NOT_AVAILABLE
246 243
247 244 def clean_sessions(self, older_than_seconds=None):
248 245 raise CleanupCommand('Cleanup for this session type not yet available')
249 246
250 247
251 248 def get_session_handler(session_type):
252 249 types = {
253 250 'file': FileAuthSessions,
254 251 'ext:memcached': MemcachedAuthSessions,
255 252 'ext:redis': RedisAuthSessions,
256 253 'ext:database': DbAuthSessions,
257 254 'memory': MemoryAuthSessions
258 255 }
259 256
260 257 try:
261 258 return types[session_type]
262 259 except KeyError:
263 260 raise ValueError(
264 'This type {} is not supported'.format(session_type))
261 f'This type {session_type} is not supported')
@@ -1,809 +1,807 b''
1
2 1 # Copyright (C) 2010-2023 RhodeCode GmbH
3 2 #
4 3 # This program is free software: you can redistribute it and/or modify
5 4 # it under the terms of the GNU Affero General Public License, version 3
6 5 # (only), as published by the Free Software Foundation.
7 6 #
8 7 # This program is distributed in the hope that it will be useful,
9 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 10 # GNU General Public License for more details.
12 11 #
13 12 # You should have received a copy of the GNU Affero General Public License
14 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 14 #
16 15 # This program is dual-licensed. If you wish to learn more about the
17 16 # RhodeCode Enterprise Edition, including its added features, Support services,
18 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 18
20 19 """
21 20 Utilities library for RhodeCode
22 21 """
23 22
24 23 import datetime
25 24 import decorator
26 25 import logging
27 26 import os
28 27 import re
29 28 import sys
30 29 import shutil
31 30 import socket
32 31 import tempfile
33 32 import traceback
34 33 import tarfile
35 34 import warnings
36 35 from os.path import join as jn
37 36
38 37 import paste
39 38 import pkg_resources
40 39 from webhelpers2.text import collapse, strip_tags, convert_accented_entities, convert_misc_entities
41 40
42 41 from mako import exceptions
43 42
44 43 from rhodecode.lib.hash_utils import sha256_safe, md5, sha1
45 44 from rhodecode.lib.str_utils import safe_bytes, safe_str
46 45 from rhodecode.lib.vcs.backends.base import Config
47 46 from rhodecode.lib.vcs.exceptions import VCSError
48 47 from rhodecode.lib.vcs.utils.helpers import get_scm, get_scm_backend
49 48 from rhodecode.lib.ext_json import sjson as json
50 49 from rhodecode.model import meta
51 50 from rhodecode.model.db import (
52 51 Repository, User, RhodeCodeUi, UserLog, RepoGroup, UserGroup)
53 52 from rhodecode.model.meta import Session
54 53
55 54
56 55 log = logging.getLogger(__name__)
57 56
58 57 REMOVED_REPO_PAT = re.compile(r'rm__\d{8}_\d{6}_\d{6}__.*')
59 58
60 59 # String which contains characters that are not allowed in slug names for
61 60 # repositories or repository groups. It is properly escaped to use it in
62 61 # regular expressions.
63 62 SLUG_BAD_CHARS = re.escape(r'`?=[]\;\'"<>,/~!@#$%^&*()+{}|:')
64 63
65 64 # Regex that matches forbidden characters in repo/group slugs.
66 65 SLUG_BAD_CHAR_RE = re.compile(r'[{}\x00-\x08\x0b-\x0c\x0e-\x1f]'.format(SLUG_BAD_CHARS))
67 66
68 67 # Regex that matches allowed characters in repo/group slugs.
69 68 SLUG_GOOD_CHAR_RE = re.compile(r'[^{}]'.format(SLUG_BAD_CHARS))
70 69
71 70 # Regex that matches whole repo/group slugs.
72 71 SLUG_RE = re.compile(r'[^{}]+'.format(SLUG_BAD_CHARS))
73 72
74 73 _license_cache = None
75 74
76 75
77 76 def repo_name_slug(value):
78 77 """
79 78 Return slug of name of repository
80 79 This function is called on each creation/modification
81 80 of repository to prevent bad names in repo
82 81 """
83 82
84 83 replacement_char = '-'
85 84
86 85 slug = strip_tags(value)
87 86 slug = convert_accented_entities(slug)
88 87 slug = convert_misc_entities(slug)
89 88
90 89 slug = SLUG_BAD_CHAR_RE.sub('', slug)
91 90 slug = re.sub(r'[\s]+', '-', slug)
92 91 slug = collapse(slug, replacement_char)
93 92
94 93 return slug
95 94
96 95
97 96 #==============================================================================
98 97 # PERM DECORATOR HELPERS FOR EXTRACTING NAMES FOR PERM CHECKS
99 98 #==============================================================================
100 99 def get_repo_slug(request):
101 100 _repo = ''
102 101
103 102 if hasattr(request, 'db_repo_name'):
104 103 # if our requests has set db reference use it for name, this
105 104 # translates the example.com/_<id> into proper repo names
106 105 _repo = request.db_repo_name
107 106 elif getattr(request, 'matchdict', None):
108 107 # pyramid
109 108 _repo = request.matchdict.get('repo_name')
110 109
111 110 if _repo:
112 111 _repo = _repo.rstrip('/')
113 112 return _repo
114 113
115 114
116 115 def get_repo_group_slug(request):
117 116 _group = ''
118 117 if hasattr(request, 'db_repo_group'):
119 118 # if our requests has set db reference use it for name, this
120 119 # translates the example.com/_<id> into proper repo group names
121 120 _group = request.db_repo_group.group_name
122 121 elif getattr(request, 'matchdict', None):
123 122 # pyramid
124 123 _group = request.matchdict.get('repo_group_name')
125 124
126 125 if _group:
127 126 _group = _group.rstrip('/')
128 127 return _group
129 128
130 129
131 130 def get_user_group_slug(request):
132 131 _user_group = ''
133 132
134 133 if hasattr(request, 'db_user_group'):
135 134 _user_group = request.db_user_group.users_group_name
136 135 elif getattr(request, 'matchdict', None):
137 136 # pyramid
138 137 _user_group = request.matchdict.get('user_group_id')
139 138 _user_group_name = request.matchdict.get('user_group_name')
140 139 try:
141 140 if _user_group:
142 141 _user_group = UserGroup.get(_user_group)
143 142 elif _user_group_name:
144 143 _user_group = UserGroup.get_by_group_name(_user_group_name)
145 144
146 145 if _user_group:
147 146 _user_group = _user_group.users_group_name
148 147 except Exception:
149 148 log.exception('Failed to get user group by id and name')
150 149 # catch all failures here
151 150 return None
152 151
153 152 return _user_group
154 153
155 154
156 155 def get_filesystem_repos(path, recursive=False, skip_removed_repos=True):
157 156 """
158 157 Scans given path for repos and return (name,(type,path)) tuple
159 158
160 159 :param path: path to scan for repositories
161 160 :param recursive: recursive search and return names with subdirs in front
162 161 """
163 162
164 163 # remove ending slash for better results
165 164 path = path.rstrip(os.sep)
166 165 log.debug('now scanning in %s location recursive:%s...', path, recursive)
167 166
168 167 def _get_repos(p):
169 168 dirpaths = get_dirpaths(p)
170 169 if not _is_dir_writable(p):
171 170 log.warning('repo path without write access: %s', p)
172 171
173 172 for dirpath in dirpaths:
174 173 if os.path.isfile(os.path.join(p, dirpath)):
175 174 continue
176 175 cur_path = os.path.join(p, dirpath)
177 176
178 177 # skip removed repos
179 178 if skip_removed_repos and REMOVED_REPO_PAT.match(dirpath):
180 179 continue
181 180
182 181 #skip .<somethin> dirs
183 182 if dirpath.startswith('.'):
184 183 continue
185 184
186 185 try:
187 186 scm_info = get_scm(cur_path)
188 187 yield scm_info[1].split(path, 1)[-1].lstrip(os.sep), scm_info
189 188 except VCSError:
190 189 if not recursive:
191 190 continue
192 191 #check if this dir containts other repos for recursive scan
193 192 rec_path = os.path.join(p, dirpath)
194 193 if os.path.isdir(rec_path):
195 for inner_scm in _get_repos(rec_path):
196 yield inner_scm
194 yield from _get_repos(rec_path)
197 195
198 196 return _get_repos(path)
199 197
200 198
201 199 def get_dirpaths(p: str) -> list:
202 200 try:
203 201 # OS-independable way of checking if we have at least read-only
204 202 # access or not.
205 203 dirpaths = os.listdir(p)
206 204 except OSError:
207 205 log.warning('ignoring repo path without read access: %s', p)
208 206 return []
209 207
210 208 # os.listpath has a tweak: If a unicode is passed into it, then it tries to
211 209 # decode paths and suddenly returns unicode objects itself. The items it
212 210 # cannot decode are returned as strings and cause issues.
213 211 #
214 212 # Those paths are ignored here until a solid solution for path handling has
215 213 # been built.
216 214 expected_type = type(p)
217 215
218 216 def _has_correct_type(item):
219 217 if type(item) is not expected_type:
220 218 log.error(
221 219 "Ignoring path %s since it cannot be decoded into str.",
222 220 # Using "repr" to make sure that we see the byte value in case
223 221 # of support.
224 222 repr(item))
225 223 return False
226 224 return True
227 225
228 226 dirpaths = [item for item in dirpaths if _has_correct_type(item)]
229 227
230 228 return dirpaths
231 229
232 230
233 231 def _is_dir_writable(path):
234 232 """
235 233 Probe if `path` is writable.
236 234
237 235 Due to trouble on Cygwin / Windows, this is actually probing if it is
238 236 possible to create a file inside of `path`, stat does not produce reliable
239 237 results in this case.
240 238 """
241 239 try:
242 240 with tempfile.TemporaryFile(dir=path):
243 241 pass
244 242 except OSError:
245 243 return False
246 244 return True
247 245
248 246
249 247 def is_valid_repo(repo_name, base_path, expect_scm=None, explicit_scm=None, config=None):
250 248 """
251 249 Returns True if given path is a valid repository False otherwise.
252 250 If expect_scm param is given also, compare if given scm is the same
253 251 as expected from scm parameter. If explicit_scm is given don't try to
254 252 detect the scm, just use the given one to check if repo is valid
255 253
256 254 :param repo_name:
257 255 :param base_path:
258 256 :param expect_scm:
259 257 :param explicit_scm:
260 258 :param config:
261 259
262 260 :return True: if given path is a valid repository
263 261 """
264 262 full_path = os.path.join(safe_str(base_path), safe_str(repo_name))
265 263 log.debug('Checking if `%s` is a valid path for repository. '
266 264 'Explicit type: %s', repo_name, explicit_scm)
267 265
268 266 try:
269 267 if explicit_scm:
270 268 detected_scms = [get_scm_backend(explicit_scm)(
271 269 full_path, config=config).alias]
272 270 else:
273 271 detected_scms = get_scm(full_path)
274 272
275 273 if expect_scm:
276 274 return detected_scms[0] == expect_scm
277 275 log.debug('path: %s is an vcs object:%s', full_path, detected_scms)
278 276 return True
279 277 except VCSError:
280 278 log.debug('path: %s is not a valid repo !', full_path)
281 279 return False
282 280
283 281
284 282 def is_valid_repo_group(repo_group_name, base_path, skip_path_check=False):
285 283 """
286 284 Returns True if given path is a repository group, False otherwise
287 285
288 286 :param repo_name:
289 287 :param base_path:
290 288 """
291 289 full_path = os.path.join(safe_str(base_path), safe_str(repo_group_name))
292 290 log.debug('Checking if `%s` is a valid path for repository group',
293 291 repo_group_name)
294 292
295 293 # check if it's not a repo
296 294 if is_valid_repo(repo_group_name, base_path):
297 295 log.debug('Repo called %s exist, it is not a valid repo group', repo_group_name)
298 296 return False
299 297
300 298 try:
301 299 # we need to check bare git repos at higher level
302 300 # since we might match branches/hooks/info/objects or possible
303 301 # other things inside bare git repo
304 302 maybe_repo = os.path.dirname(full_path)
305 303 if maybe_repo == base_path:
306 304 # skip root level repo check, we know root location CANNOT BE a repo group
307 305 return False
308 306
309 307 scm_ = get_scm(maybe_repo)
310 308 log.debug('path: %s is a vcs object:%s, not valid repo group', full_path, scm_)
311 309 return False
312 310 except VCSError:
313 311 pass
314 312
315 313 # check if it's a valid path
316 314 if skip_path_check or os.path.isdir(full_path):
317 315 log.debug('path: %s is a valid repo group !', full_path)
318 316 return True
319 317
320 318 log.debug('path: %s is not a valid repo group !', full_path)
321 319 return False
322 320
323 321
324 322 def ask_ok(prompt, retries=4, complaint='[y]es or [n]o please!'):
325 323 while True:
326 324 ok = eval(input(prompt))
327 325 if ok.lower() in ('y', 'ye', 'yes'):
328 326 return True
329 327 if ok.lower() in ('n', 'no', 'nop', 'nope'):
330 328 return False
331 329 retries = retries - 1
332 330 if retries < 0:
333 raise IOError
331 raise OSError
334 332 print(complaint)
335 333
336 334 # propagated from mercurial documentation
337 335 ui_sections = [
338 336 'alias', 'auth',
339 337 'decode/encode', 'defaults',
340 338 'diff', 'email',
341 339 'extensions', 'format',
342 340 'merge-patterns', 'merge-tools',
343 341 'hooks', 'http_proxy',
344 342 'smtp', 'patch',
345 343 'paths', 'profiling',
346 344 'server', 'trusted',
347 345 'ui', 'web', ]
348 346
349 347
350 348 def config_data_from_db(clear_session=True, repo=None):
351 349 """
352 350 Read the configuration data from the database and return configuration
353 351 tuples.
354 352 """
355 353 from rhodecode.model.settings import VcsSettingsModel
356 354
357 355 config = []
358 356
359 357 sa = meta.Session()
360 358 settings_model = VcsSettingsModel(repo=repo, sa=sa)
361 359
362 360 ui_settings = settings_model.get_ui_settings()
363 361
364 362 ui_data = []
365 363 for setting in ui_settings:
366 364 if setting.active:
367 365 ui_data.append((setting.section, setting.key, setting.value))
368 366 config.append((
369 367 safe_str(setting.section), safe_str(setting.key),
370 368 safe_str(setting.value)))
371 369 if setting.key == 'push_ssl':
372 370 # force set push_ssl requirement to False, rhodecode
373 371 # handles that
374 372 config.append((
375 373 safe_str(setting.section), safe_str(setting.key), False))
376 374 log.debug(
377 375 'settings ui from db@repo[%s]: %s',
378 376 repo,
379 377 ','.join(['[{}] {}={}'.format(*s) for s in ui_data]))
380 378 if clear_session:
381 379 meta.Session.remove()
382 380
383 381 # TODO: mikhail: probably it makes no sense to re-read hooks information.
384 382 # It's already there and activated/deactivated
385 383 skip_entries = []
386 384 enabled_hook_classes = get_enabled_hook_classes(ui_settings)
387 385 if 'pull' not in enabled_hook_classes:
388 386 skip_entries.append(('hooks', RhodeCodeUi.HOOK_PRE_PULL))
389 387 if 'push' not in enabled_hook_classes:
390 388 skip_entries.append(('hooks', RhodeCodeUi.HOOK_PRE_PUSH))
391 389 skip_entries.append(('hooks', RhodeCodeUi.HOOK_PRETX_PUSH))
392 390 skip_entries.append(('hooks', RhodeCodeUi.HOOK_PUSH_KEY))
393 391
394 392 config = [entry for entry in config if entry[:2] not in skip_entries]
395 393
396 394 return config
397 395
398 396
399 397 def make_db_config(clear_session=True, repo=None):
400 398 """
401 399 Create a :class:`Config` instance based on the values in the database.
402 400 """
403 401 config = Config()
404 402 config_data = config_data_from_db(clear_session=clear_session, repo=repo)
405 403 for section, option, value in config_data:
406 404 config.set(section, option, value)
407 405 return config
408 406
409 407
410 408 def get_enabled_hook_classes(ui_settings):
411 409 """
412 410 Return the enabled hook classes.
413 411
414 412 :param ui_settings: List of ui_settings as returned
415 413 by :meth:`VcsSettingsModel.get_ui_settings`
416 414
417 415 :return: a list with the enabled hook classes. The order is not guaranteed.
418 416 :rtype: list
419 417 """
420 418 enabled_hooks = []
421 419 active_hook_keys = [
422 420 key for section, key, value, active in ui_settings
423 421 if section == 'hooks' and active]
424 422
425 423 hook_names = {
426 424 RhodeCodeUi.HOOK_PUSH: 'push',
427 425 RhodeCodeUi.HOOK_PULL: 'pull',
428 426 RhodeCodeUi.HOOK_REPO_SIZE: 'repo_size'
429 427 }
430 428
431 429 for key in active_hook_keys:
432 430 hook = hook_names.get(key)
433 431 if hook:
434 432 enabled_hooks.append(hook)
435 433
436 434 return enabled_hooks
437 435
438 436
439 437 def set_rhodecode_config(config):
440 438 """
441 439 Updates pyramid config with new settings from database
442 440
443 441 :param config:
444 442 """
445 443 from rhodecode.model.settings import SettingsModel
446 444 app_settings = SettingsModel().get_all_settings()
447 445
448 446 for k, v in list(app_settings.items()):
449 447 config[k] = v
450 448
451 449
452 450 def get_rhodecode_realm():
453 451 """
454 452 Return the rhodecode realm from database.
455 453 """
456 454 from rhodecode.model.settings import SettingsModel
457 455 realm = SettingsModel().get_setting_by_name('realm')
458 456 return safe_str(realm.app_settings_value)
459 457
460 458
461 459 def get_rhodecode_base_path():
462 460 """
463 461 Returns the base path. The base path is the filesystem path which points
464 462 to the repository store.
465 463 """
466 464
467 465 import rhodecode
468 466 return rhodecode.CONFIG['default_base_path']
469 467
470 468
471 469 def map_groups(path):
472 470 """
473 471 Given a full path to a repository, create all nested groups that this
474 472 repo is inside. This function creates parent-child relationships between
475 473 groups and creates default perms for all new groups.
476 474
477 475 :param paths: full path to repository
478 476 """
479 477 from rhodecode.model.repo_group import RepoGroupModel
480 478 sa = meta.Session()
481 479 groups = path.split(Repository.NAME_SEP)
482 480 parent = None
483 481 group = None
484 482
485 483 # last element is repo in nested groups structure
486 484 groups = groups[:-1]
487 485 rgm = RepoGroupModel(sa)
488 486 owner = User.get_first_super_admin()
489 487 for lvl, group_name in enumerate(groups):
490 488 group_name = '/'.join(groups[:lvl] + [group_name])
491 489 group = RepoGroup.get_by_group_name(group_name)
492 490 desc = '%s group' % group_name
493 491
494 492 # skip folders that are now removed repos
495 493 if REMOVED_REPO_PAT.match(group_name):
496 494 break
497 495
498 496 if group is None:
499 497 log.debug('creating group level: %s group_name: %s',
500 498 lvl, group_name)
501 499 group = RepoGroup(group_name, parent)
502 500 group.group_description = desc
503 501 group.user = owner
504 502 sa.add(group)
505 503 perm_obj = rgm._create_default_perms(group)
506 504 sa.add(perm_obj)
507 505 sa.flush()
508 506
509 507 parent = group
510 508 return group
511 509
512 510
513 511 def repo2db_mapper(initial_repo_list, remove_obsolete=False):
514 512 """
515 513 maps all repos given in initial_repo_list, non existing repositories
516 514 are created, if remove_obsolete is True it also checks for db entries
517 515 that are not in initial_repo_list and removes them.
518 516
519 517 :param initial_repo_list: list of repositories found by scanning methods
520 518 :param remove_obsolete: check for obsolete entries in database
521 519 """
522 520 from rhodecode.model.repo import RepoModel
523 521 from rhodecode.model.repo_group import RepoGroupModel
524 522 from rhodecode.model.settings import SettingsModel
525 523
526 524 sa = meta.Session()
527 525 repo_model = RepoModel()
528 526 user = User.get_first_super_admin()
529 527 added = []
530 528
531 529 # creation defaults
532 530 defs = SettingsModel().get_default_repo_settings(strip_prefix=True)
533 531 enable_statistics = defs.get('repo_enable_statistics')
534 532 enable_locking = defs.get('repo_enable_locking')
535 533 enable_downloads = defs.get('repo_enable_downloads')
536 534 private = defs.get('repo_private')
537 535
538 536 for name, repo in list(initial_repo_list.items()):
539 537 group = map_groups(name)
540 538 str_name = safe_str(name)
541 539 db_repo = repo_model.get_by_repo_name(str_name)
542 540 # found repo that is on filesystem not in RhodeCode database
543 541 if not db_repo:
544 542 log.info('repository %s not found, creating now', name)
545 543 added.append(name)
546 544 desc = (repo.description
547 545 if repo.description != 'unknown'
548 546 else '%s repository' % name)
549 547
550 548 db_repo = repo_model._create_repo(
551 549 repo_name=name,
552 550 repo_type=repo.alias,
553 551 description=desc,
554 552 repo_group=getattr(group, 'group_id', None),
555 553 owner=user,
556 554 enable_locking=enable_locking,
557 555 enable_downloads=enable_downloads,
558 556 enable_statistics=enable_statistics,
559 557 private=private,
560 558 state=Repository.STATE_CREATED
561 559 )
562 560 sa.commit()
563 561 # we added that repo just now, and make sure we updated server info
564 562 if db_repo.repo_type == 'git':
565 563 git_repo = db_repo.scm_instance()
566 564 # update repository server-info
567 565 log.debug('Running update server info')
568 566 git_repo._update_server_info()
569 567
570 568 db_repo.update_commit_cache()
571 569
572 570 config = db_repo._config
573 571 config.set('extensions', 'largefiles', '')
574 572 repo = db_repo.scm_instance(config=config)
575 573 repo.install_hooks()
576 574
577 575 removed = []
578 576 if remove_obsolete:
579 577 # remove from database those repositories that are not in the filesystem
580 578 for repo in sa.query(Repository).all():
581 579 if repo.repo_name not in list(initial_repo_list.keys()):
582 580 log.debug("Removing non-existing repository found in db `%s`",
583 581 repo.repo_name)
584 582 try:
585 583 RepoModel(sa).delete(repo, forks='detach', fs_remove=False)
586 584 sa.commit()
587 585 removed.append(repo.repo_name)
588 586 except Exception:
589 587 # don't hold further removals on error
590 588 log.error(traceback.format_exc())
591 589 sa.rollback()
592 590
593 591 def splitter(full_repo_name):
594 592 _parts = full_repo_name.rsplit(RepoGroup.url_sep(), 1)
595 593 gr_name = None
596 594 if len(_parts) == 2:
597 595 gr_name = _parts[0]
598 596 return gr_name
599 597
600 598 initial_repo_group_list = [splitter(x) for x in
601 599 list(initial_repo_list.keys()) if splitter(x)]
602 600
603 601 # remove from database those repository groups that are not in the
604 602 # filesystem due to parent child relationships we need to delete them
605 603 # in a specific order of most nested first
606 604 all_groups = [x.group_name for x in sa.query(RepoGroup).all()]
607 605 def nested_sort(gr):
608 606 return len(gr.split('/'))
609 607 for group_name in sorted(all_groups, key=nested_sort, reverse=True):
610 608 if group_name not in initial_repo_group_list:
611 609 repo_group = RepoGroup.get_by_group_name(group_name)
612 610 if (repo_group.children.all() or
613 611 not RepoGroupModel().check_exist_filesystem(
614 612 group_name=group_name, exc_on_failure=False)):
615 613 continue
616 614
617 615 log.info(
618 616 'Removing non-existing repository group found in db `%s`',
619 617 group_name)
620 618 try:
621 619 RepoGroupModel(sa).delete(group_name, fs_remove=False)
622 620 sa.commit()
623 621 removed.append(group_name)
624 622 except Exception:
625 623 # don't hold further removals on error
626 624 log.exception(
627 625 'Unable to remove repository group `%s`',
628 626 group_name)
629 627 sa.rollback()
630 628 raise
631 629
632 630 return added, removed
633 631
634 632
635 633 def load_rcextensions(root_path):
636 634 import rhodecode
637 635 from rhodecode.config import conf
638 636
639 637 path = os.path.join(root_path)
640 638 sys.path.append(path)
641 639
642 640 try:
643 641 rcextensions = __import__('rcextensions')
644 642 except ImportError:
645 643 if os.path.isdir(os.path.join(path, 'rcextensions')):
646 644 log.warning('Unable to load rcextensions from %s', path)
647 645 rcextensions = None
648 646
649 647 if rcextensions:
650 648 log.info('Loaded rcextensions from %s...', rcextensions)
651 649 rhodecode.EXTENSIONS = rcextensions
652 650
653 651 # Additional mappings that are not present in the pygments lexers
654 652 conf.LANGUAGES_EXTENSIONS_MAP.update(
655 653 getattr(rhodecode.EXTENSIONS, 'EXTRA_MAPPINGS', {}))
656 654
657 655
658 656 def get_custom_lexer(extension):
659 657 """
660 658 returns a custom lexer if it is defined in rcextensions module, or None
661 659 if there's no custom lexer defined
662 660 """
663 661 import rhodecode
664 662 from pygments import lexers
665 663
666 664 # custom override made by RhodeCode
667 665 if extension in ['mako']:
668 666 return lexers.get_lexer_by_name('html+mako')
669 667
670 668 # check if we didn't define this extension as other lexer
671 669 extensions = rhodecode.EXTENSIONS and getattr(rhodecode.EXTENSIONS, 'EXTRA_LEXERS', None)
672 670 if extensions and extension in rhodecode.EXTENSIONS.EXTRA_LEXERS:
673 671 _lexer_name = rhodecode.EXTENSIONS.EXTRA_LEXERS[extension]
674 672 return lexers.get_lexer_by_name(_lexer_name)
675 673
676 674
677 675 #==============================================================================
678 676 # TEST FUNCTIONS AND CREATORS
679 677 #==============================================================================
680 678 def create_test_index(repo_location, config):
681 679 """
682 680 Makes default test index.
683 681 """
684 682 try:
685 683 import rc_testdata
686 684 except ImportError:
687 685 raise ImportError('Failed to import rc_testdata, '
688 686 'please make sure this package is installed from requirements_test.txt')
689 687 rc_testdata.extract_search_index(
690 688 'vcs_search_index', os.path.dirname(config['search.location']))
691 689
692 690
693 691 def create_test_directory(test_path):
694 692 """
695 693 Create test directory if it doesn't exist.
696 694 """
697 695 if not os.path.isdir(test_path):
698 696 log.debug('Creating testdir %s', test_path)
699 697 os.makedirs(test_path)
700 698
701 699
702 700 def create_test_database(test_path, config):
703 701 """
704 702 Makes a fresh database.
705 703 """
706 704 from rhodecode.lib.db_manage import DbManage
707 705 from rhodecode.lib.utils2 import get_encryption_key
708 706
709 707 # PART ONE create db
710 708 dbconf = config['sqlalchemy.db1.url']
711 709 enc_key = get_encryption_key(config)
712 710
713 711 log.debug('making test db %s', dbconf)
714 712
715 713 dbmanage = DbManage(log_sql=False, dbconf=dbconf, root=config['here'],
716 714 tests=True, cli_args={'force_ask': True}, enc_key=enc_key)
717 715 dbmanage.create_tables(override=True)
718 716 dbmanage.set_db_version()
719 717 # for tests dynamically set new root paths based on generated content
720 718 dbmanage.create_settings(dbmanage.config_prompt(test_path))
721 719 dbmanage.create_default_user()
722 720 dbmanage.create_test_admin_and_users()
723 721 dbmanage.create_permissions()
724 722 dbmanage.populate_default_permissions()
725 723 Session().commit()
726 724
727 725
728 726 def create_test_repositories(test_path, config):
729 727 """
730 728 Creates test repositories in the temporary directory. Repositories are
731 729 extracted from archives within the rc_testdata package.
732 730 """
733 731 import rc_testdata
734 732 from rhodecode.tests import HG_REPO, GIT_REPO, SVN_REPO
735 733
736 734 log.debug('making test vcs repositories')
737 735
738 736 idx_path = config['search.location']
739 737 data_path = config['cache_dir']
740 738
741 739 # clean index and data
742 740 if idx_path and os.path.exists(idx_path):
743 741 log.debug('remove %s', idx_path)
744 742 shutil.rmtree(idx_path)
745 743
746 744 if data_path and os.path.exists(data_path):
747 745 log.debug('remove %s', data_path)
748 746 shutil.rmtree(data_path)
749 747
750 748 rc_testdata.extract_hg_dump('vcs_test_hg', jn(test_path, HG_REPO))
751 749 rc_testdata.extract_git_dump('vcs_test_git', jn(test_path, GIT_REPO))
752 750
753 751 # Note: Subversion is in the process of being integrated with the system,
754 752 # until we have a properly packed version of the test svn repository, this
755 753 # tries to copy over the repo from a package "rc_testdata"
756 754 svn_repo_path = rc_testdata.get_svn_repo_archive()
757 755 with tarfile.open(svn_repo_path) as tar:
758 756 tar.extractall(jn(test_path, SVN_REPO))
759 757
760 758
761 759 def password_changed(auth_user, session):
762 760 # Never report password change in case of default user or anonymous user.
763 761 if auth_user.username == User.DEFAULT_USER or auth_user.user_id is None:
764 762 return False
765 763
766 764 password_hash = md5(safe_bytes(auth_user.password)) if auth_user.password else None
767 765 rhodecode_user = session.get('rhodecode_user', {})
768 766 session_password_hash = rhodecode_user.get('password', '')
769 767 return password_hash != session_password_hash
770 768
771 769
772 770 def read_opensource_licenses():
773 771 global _license_cache
774 772
775 773 if not _license_cache:
776 774 licenses = pkg_resources.resource_string(
777 775 'rhodecode', 'config/licenses.json')
778 776 _license_cache = json.loads(licenses)
779 777
780 778 return _license_cache
781 779
782 780
783 781 def generate_platform_uuid():
784 782 """
785 783 Generates platform UUID based on it's name
786 784 """
787 785 import platform
788 786
789 787 try:
790 788 uuid_list = [platform.platform()]
791 789 return sha256_safe(':'.join(uuid_list))
792 790 except Exception as e:
793 791 log.error('Failed to generate host uuid: %s', e)
794 792 return 'UNDEFINED'
795 793
796 794
797 795 def send_test_email(recipients, email_body='TEST EMAIL'):
798 796 """
799 797 Simple code for generating test emails.
800 798 Usage::
801 799
802 800 from rhodecode.lib import utils
803 801 utils.send_test_email()
804 802 """
805 803 from rhodecode.lib.celerylib import tasks, run_task
806 804
807 805 email_body = email_body_plaintext = email_body
808 subject = 'SUBJECT FROM: {}'.format(socket.gethostname())
806 subject = f'SUBJECT FROM: {socket.gethostname()}'
809 807 tasks.send_email(recipients, subject, email_body_plaintext, email_body)
@@ -1,989 +1,987 b''
1
2
3 1 # Copyright (C) 2011-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19
22 20 """
23 21 Some simple helper functions
24 22 """
25 23
26 24 import collections
27 25 import datetime
28 26 import dateutil.relativedelta
29 27 import logging
30 28 import re
31 29 import sys
32 30 import time
33 31 import urllib.request
34 32 import urllib.parse
35 33 import urllib.error
36 34 import urlobject
37 35 import uuid
38 36 import getpass
39 37 import socket
40 38 import errno
41 39 import random
42 40 import functools
43 41 from contextlib import closing
44 42
45 43 import pygments.lexers
46 44 import sqlalchemy
47 45 import sqlalchemy.event
48 46 import sqlalchemy.engine.url
49 47 import sqlalchemy.exc
50 48 import sqlalchemy.sql
51 49 import webob
52 50 from pyramid.settings import asbool
53 51
54 52 import rhodecode
55 53 from rhodecode.translation import _, _pluralize
56 54 from rhodecode.lib.str_utils import safe_str, safe_int, safe_bytes
57 55 from rhodecode.lib.hash_utils import md5, md5_safe, sha1, sha1_safe
58 56 from rhodecode.lib.type_utils import aslist, str2bool, StrictAttributeDict, AttributeDict
59 57
60 58
61 59 def __get_lem(extra_mapping=None):
62 60 """
63 61 Get language extension map based on what's inside pygments lexers
64 62 """
65 63 d = collections.defaultdict(lambda: [])
66 64
67 65 def __clean(s):
68 66 s = s.lstrip('*')
69 67 s = s.lstrip('.')
70 68
71 69 if s.find('[') != -1:
72 70 exts = []
73 71 start, stop = s.find('['), s.find(']')
74 72
75 73 for suffix in s[start + 1:stop]:
76 74 exts.append(s[:s.find('[')] + suffix)
77 75 return [e.lower() for e in exts]
78 76 else:
79 77 return [s.lower()]
80 78
81 79 for lx, t in sorted(pygments.lexers.LEXERS.items()):
82 80 m = list(map(__clean, t[-2]))
83 81 if m:
84 82 m = functools.reduce(lambda x, y: x + y, m)
85 83 for ext in m:
86 84 desc = lx.replace('Lexer', '')
87 85 d[ext].append(desc)
88 86
89 87 data = dict(d)
90 88
91 89 extra_mapping = extra_mapping or {}
92 90 if extra_mapping:
93 91 for k, v in list(extra_mapping.items()):
94 92 if k not in data:
95 93 # register new mapping2lexer
96 94 data[k] = [v]
97 95
98 96 return data
99 97
100 98
101 99 def convert_line_endings(line: str, mode) -> str:
102 100 """
103 101 Converts a given line "line end" accordingly to given mode
104 102
105 103 Available modes are::
106 104 0 - Unix
107 105 1 - Mac
108 106 2 - DOS
109 107
110 108 :param line: given line to convert
111 109 :param mode: mode to convert to
112 110 :return: converted line according to mode
113 111 """
114 112 if mode == 0:
115 113 line = line.replace('\r\n', '\n')
116 114 line = line.replace('\r', '\n')
117 115 elif mode == 1:
118 116 line = line.replace('\r\n', '\r')
119 117 line = line.replace('\n', '\r')
120 118 elif mode == 2:
121 119 line = re.sub('\r(?!\n)|(?<!\r)\n', '\r\n', line)
122 120 return line
123 121
124 122
125 123 def detect_mode(line: str, default) -> int:
126 124 """
127 125 Detects line break for given line, if line break couldn't be found
128 126 given default value is returned
129 127
130 128 :param line: str line
131 129 :param default: default
132 130 :return: value of line end on of 0 - Unix, 1 - Mac, 2 - DOS
133 131 """
134 132 if line.endswith('\r\n'):
135 133 return 2
136 134 elif line.endswith('\n'):
137 135 return 0
138 136 elif line.endswith('\r'):
139 137 return 1
140 138 else:
141 139 return default
142 140
143 141
144 142 def remove_suffix(s, suffix):
145 143 if s.endswith(suffix):
146 144 s = s[:-1 * len(suffix)]
147 145 return s
148 146
149 147
150 148 def remove_prefix(s, prefix):
151 149 if s.startswith(prefix):
152 150 s = s[len(prefix):]
153 151 return s
154 152
155 153
156 154 def find_calling_context(ignore_modules=None, depth=4, output_writer=None, indent=True):
157 155 """
158 156 Look through the calling stack and return the frame which called
159 157 this function and is part of core module ( ie. rhodecode.* )
160 158
161 159 :param ignore_modules: list of modules to ignore eg. ['rhodecode.lib']
162 160 :param depth:
163 161 :param output_writer:
164 162 :param indent:
165 163
166 164 usage::
167 165
168 166 from rhodecode.lib.utils2 import find_calling_context
169 167
170 168 calling_context = find_calling_context(ignore_modules=[
171 169 'rhodecode.lib.caching_query',
172 170 'rhodecode.model.settings',
173 171 ])
174 172
175 173 """
176 174 import inspect
177 175 if not output_writer:
178 176 try:
179 177 from rich import print as pprint
180 178 except ImportError:
181 179 pprint = print
182 180 output_writer = pprint
183 181
184 182 frame = inspect.currentframe()
185 183 cc = []
186 184 try:
187 185 for i in range(depth): # current frame + 3 callers
188 186 frame = frame.f_back
189 187 if not frame:
190 188 break
191 189
192 190 info = inspect.getframeinfo(frame)
193 191 name = frame.f_globals.get('__name__')
194 192 if name not in ignore_modules:
195 193 cc.insert(0, f'CALL_CONTEXT:{i}: file {info.filename}:{info.lineno} -> {info.function}')
196 194 finally:
197 195 # Avoids a reference cycle
198 196 del frame
199 197
200 198 output_writer('* INFO: This code was called from: *')
201 199 for cnt, frm_info in enumerate(cc):
202 200 if not indent:
203 201 cnt = 1
204 202 output_writer(' ' * cnt + frm_info)
205 203
206 204
207 205 def ping_connection(connection, branch):
208 206 if branch:
209 207 # "branch" refers to a sub-connection of a connection,
210 208 # we don't want to bother pinging on these.
211 209 return
212 210
213 211 # turn off "close with result". This flag is only used with
214 212 # "connectionless" execution, otherwise will be False in any case
215 213 save_should_close_with_result = connection.should_close_with_result
216 214 connection.should_close_with_result = False
217 215
218 216 try:
219 217 # run a SELECT 1. use a core select() so that
220 218 # the SELECT of a scalar value without a table is
221 219 # appropriately formatted for the backend
222 220 connection.scalar(sqlalchemy.sql.select([1]))
223 221 except sqlalchemy.exc.DBAPIError as err:
224 222 # catch SQLAlchemy's DBAPIError, which is a wrapper
225 223 # for the DBAPI's exception. It includes a .connection_invalidated
226 224 # attribute which specifies if this connection is a "disconnect"
227 225 # condition, which is based on inspection of the original exception
228 226 # by the dialect in use.
229 227 if err.connection_invalidated:
230 228 # run the same SELECT again - the connection will re-validate
231 229 # itself and establish a new connection. The disconnect detection
232 230 # here also causes the whole connection pool to be invalidated
233 231 # so that all stale connections are discarded.
234 232 connection.scalar(sqlalchemy.sql.select([1]))
235 233 else:
236 234 raise
237 235 finally:
238 236 # restore "close with result"
239 237 connection.should_close_with_result = save_should_close_with_result
240 238
241 239
242 240 def engine_from_config(configuration, prefix='sqlalchemy.', **kwargs):
243 241 """Custom engine_from_config functions."""
244 242 log = logging.getLogger('sqlalchemy.engine')
245 243 use_ping_connection = asbool(configuration.pop('sqlalchemy.db1.ping_connection', None))
246 244 debug = asbool(configuration.pop('sqlalchemy.db1.debug_query', None))
247 245
248 246 engine = sqlalchemy.engine_from_config(configuration, prefix, **kwargs)
249 247
250 248 def color_sql(sql):
251 249 color_seq = '\033[1;33m' # This is yellow: code 33
252 250 normal = '\x1b[0m'
253 251 return ''.join([color_seq, sql, normal])
254 252
255 253 if use_ping_connection:
256 254 log.debug('Adding ping_connection on the engine config.')
257 255 sqlalchemy.event.listen(engine, "engine_connect", ping_connection)
258 256
259 257 if debug:
260 258 # attach events only for debug configuration
261 259 def before_cursor_execute(conn, cursor, statement,
262 260 parameters, context, executemany):
263 261 setattr(conn, 'query_start_time', time.time())
264 262 log.info(color_sql(">>>>> STARTING QUERY >>>>>"))
265 263 find_calling_context(ignore_modules=[
266 264 'rhodecode.lib.caching_query',
267 265 'rhodecode.model.settings',
268 266 ], output_writer=log.info)
269 267
270 268 def after_cursor_execute(conn, cursor, statement,
271 269 parameters, context, executemany):
272 270 delattr(conn, 'query_start_time')
273 271
274 272 sqlalchemy.event.listen(engine, "before_cursor_execute", before_cursor_execute)
275 273 sqlalchemy.event.listen(engine, "after_cursor_execute", after_cursor_execute)
276 274
277 275 return engine
278 276
279 277
280 278 def get_encryption_key(config) -> bytes:
281 279 secret = config.get('rhodecode.encrypted_values.secret')
282 280 default = config['beaker.session.secret']
283 281 enc_key = secret or default
284 282
285 283 return safe_bytes(enc_key)
286 284
287 285
288 286 def age(prevdate, now=None, show_short_version=False, show_suffix=True, short_format=False):
289 287 """
290 288 Turns a datetime into an age string.
291 289 If show_short_version is True, this generates a shorter string with
292 290 an approximate age; ex. '1 day ago', rather than '1 day and 23 hours ago'.
293 291
294 292 * IMPORTANT*
295 293 Code of this function is written in special way so it's easier to
296 294 backport it to javascript. If you mean to update it, please also update
297 295 `jquery.timeago-extension.js` file
298 296
299 297 :param prevdate: datetime object
300 298 :param now: get current time, if not define we use
301 299 `datetime.datetime.now()`
302 300 :param show_short_version: if it should approximate the date and
303 301 return a shorter string
304 302 :param show_suffix:
305 303 :param short_format: show short format, eg 2D instead of 2 days
306 304 :rtype: unicode
307 305 :returns: unicode words describing age
308 306 """
309 307
310 308 def _get_relative_delta(now, prevdate):
311 309 base = dateutil.relativedelta.relativedelta(now, prevdate)
312 310 return {
313 311 'year': base.years,
314 312 'month': base.months,
315 313 'day': base.days,
316 314 'hour': base.hours,
317 315 'minute': base.minutes,
318 316 'second': base.seconds,
319 317 }
320 318
321 319 def _is_leap_year(year):
322 320 return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
323 321
324 322 def get_month(prevdate):
325 323 return prevdate.month
326 324
327 325 def get_year(prevdate):
328 326 return prevdate.year
329 327
330 328 now = now or datetime.datetime.now()
331 329 order = ['year', 'month', 'day', 'hour', 'minute', 'second']
332 330 deltas = {}
333 331 future = False
334 332
335 333 if prevdate > now:
336 334 now_old = now
337 335 now = prevdate
338 336 prevdate = now_old
339 337 future = True
340 338 if future:
341 339 prevdate = prevdate.replace(microsecond=0)
342 340 # Get date parts deltas
343 341 for part in order:
344 342 rel_delta = _get_relative_delta(now, prevdate)
345 343 deltas[part] = rel_delta[part]
346 344
347 345 # Fix negative offsets (there is 1 second between 10:59:59 and 11:00:00,
348 346 # not 1 hour, -59 minutes and -59 seconds)
349 347 offsets = [[5, 60], [4, 60], [3, 24]]
350 348 for element in offsets: # seconds, minutes, hours
351 349 num = element[0]
352 350 length = element[1]
353 351
354 352 part = order[num]
355 353 carry_part = order[num - 1]
356 354
357 355 if deltas[part] < 0:
358 356 deltas[part] += length
359 357 deltas[carry_part] -= 1
360 358
361 359 # Same thing for days except that the increment depends on the (variable)
362 360 # number of days in the month
363 361 month_lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
364 362 if deltas['day'] < 0:
365 363 if get_month(prevdate) == 2 and _is_leap_year(get_year(prevdate)):
366 364 deltas['day'] += 29
367 365 else:
368 366 deltas['day'] += month_lengths[get_month(prevdate) - 1]
369 367
370 368 deltas['month'] -= 1
371 369
372 370 if deltas['month'] < 0:
373 371 deltas['month'] += 12
374 372 deltas['year'] -= 1
375 373
376 374 # Format the result
377 375 if short_format:
378 376 fmt_funcs = {
379 377 'year': lambda d: '%dy' % d,
380 378 'month': lambda d: '%dm' % d,
381 379 'day': lambda d: '%dd' % d,
382 380 'hour': lambda d: '%dh' % d,
383 381 'minute': lambda d: '%dmin' % d,
384 382 'second': lambda d: '%dsec' % d,
385 383 }
386 384 else:
387 385 fmt_funcs = {
388 386 'year': lambda d: _pluralize('${num} year', '${num} years', d, mapping={'num': d}).interpolate(),
389 387 'month': lambda d: _pluralize('${num} month', '${num} months', d, mapping={'num': d}).interpolate(),
390 388 'day': lambda d: _pluralize('${num} day', '${num} days', d, mapping={'num': d}).interpolate(),
391 389 'hour': lambda d: _pluralize('${num} hour', '${num} hours', d, mapping={'num': d}).interpolate(),
392 390 'minute': lambda d: _pluralize('${num} minute', '${num} minutes', d, mapping={'num': d}).interpolate(),
393 391 'second': lambda d: _pluralize('${num} second', '${num} seconds', d, mapping={'num': d}).interpolate(),
394 392 }
395 393
396 394 i = 0
397 395 for part in order:
398 396 value = deltas[part]
399 397 if value != 0:
400 398
401 399 if i < 5:
402 400 sub_part = order[i + 1]
403 401 sub_value = deltas[sub_part]
404 402 else:
405 403 sub_value = 0
406 404
407 405 if sub_value == 0 or show_short_version:
408 406 _val = fmt_funcs[part](value)
409 407 if future:
410 408 if show_suffix:
411 409 return _('in ${ago}', mapping={'ago': _val})
412 410 else:
413 411 return _(_val)
414 412
415 413 else:
416 414 if show_suffix:
417 415 return _('${ago} ago', mapping={'ago': _val})
418 416 else:
419 417 return _(_val)
420 418
421 419 val = fmt_funcs[part](value)
422 420 val_detail = fmt_funcs[sub_part](sub_value)
423 421 mapping = {'val': val, 'detail': val_detail}
424 422
425 423 if short_format:
426 424 datetime_tmpl = _('${val}, ${detail}', mapping=mapping)
427 425 if show_suffix:
428 426 datetime_tmpl = _('${val}, ${detail} ago', mapping=mapping)
429 427 if future:
430 428 datetime_tmpl = _('in ${val}, ${detail}', mapping=mapping)
431 429 else:
432 430 datetime_tmpl = _('${val} and ${detail}', mapping=mapping)
433 431 if show_suffix:
434 432 datetime_tmpl = _('${val} and ${detail} ago', mapping=mapping)
435 433 if future:
436 434 datetime_tmpl = _('in ${val} and ${detail}', mapping=mapping)
437 435
438 436 return datetime_tmpl
439 437 i += 1
440 438 return _('just now')
441 439
442 440
443 441 def age_from_seconds(seconds):
444 442 seconds = safe_int(seconds) or 0
445 443 prevdate = time_to_datetime(time.time() + seconds)
446 444 return age(prevdate, show_suffix=False, show_short_version=True)
447 445
448 446
449 447 def cleaned_uri(uri):
450 448 """
451 449 Quotes '[' and ']' from uri if there is only one of them.
452 450 according to RFC3986 we cannot use such chars in uri
453 451 :param uri:
454 452 :return: uri without this chars
455 453 """
456 454 return urllib.parse.quote(uri, safe='@$:/')
457 455
458 456
459 457 def credentials_filter(uri):
460 458 """
461 459 Returns a url with removed credentials
462 460
463 461 :param uri:
464 462 """
465 463 import urlobject
466 464 if isinstance(uri, rhodecode.lib.encrypt.InvalidDecryptedValue):
467 465 return 'InvalidDecryptionKey'
468 466
469 467 url_obj = urlobject.URLObject(cleaned_uri(uri))
470 468 url_obj = url_obj.without_password().without_username()
471 469
472 470 return url_obj
473 471
474 472
475 473 def get_host_info(request):
476 474 """
477 475 Generate host info, to obtain full url e.g https://server.com
478 476 use this
479 477 `{scheme}://{netloc}`
480 478 """
481 479 if not request:
482 480 return {}
483 481
484 482 qualified_home_url = request.route_url('home')
485 483 parsed_url = urlobject.URLObject(qualified_home_url)
486 484 decoded_path = safe_str(urllib.parse.unquote(parsed_url.path.rstrip('/')))
487 485
488 486 return {
489 487 'scheme': parsed_url.scheme,
490 488 'netloc': parsed_url.netloc+decoded_path,
491 489 'hostname': parsed_url.hostname,
492 490 }
493 491
494 492
495 493 def get_clone_url(request, uri_tmpl, repo_name, repo_id, repo_type, **override):
496 494 qualified_home_url = request.route_url('home')
497 495 parsed_url = urlobject.URLObject(qualified_home_url)
498 496 decoded_path = safe_str(urllib.parse.unquote(parsed_url.path.rstrip('/')))
499 497
500 498 args = {
501 499 'scheme': parsed_url.scheme,
502 500 'user': '',
503 501 'sys_user': getpass.getuser(),
504 502 # path if we use proxy-prefix
505 503 'netloc': parsed_url.netloc+decoded_path,
506 504 'hostname': parsed_url.hostname,
507 505 'prefix': decoded_path,
508 506 'repo': repo_name,
509 507 'repoid': str(repo_id),
510 508 'repo_type': repo_type
511 509 }
512 510 args.update(override)
513 511 args['user'] = urllib.parse.quote(safe_str(args['user']))
514 512
515 513 for k, v in list(args.items()):
516 514 tmpl_key = '{%s}' % k
517 515 uri_tmpl = uri_tmpl.replace(tmpl_key, v)
518 516
519 517 # special case for SVN clone url
520 518 if repo_type == 'svn':
521 519 uri_tmpl = uri_tmpl.replace('ssh://', 'svn+ssh://')
522 520
523 521 # remove leading @ sign if it's present. Case of empty user
524 522 url_obj = urlobject.URLObject(uri_tmpl)
525 523 url = url_obj.with_netloc(url_obj.netloc.lstrip('@'))
526 524
527 525 return safe_str(url)
528 526
529 527
530 528 def get_commit_safe(repo, commit_id=None, commit_idx=None, pre_load=None,
531 529 maybe_unreachable=False, reference_obj=None):
532 530 """
533 531 Safe version of get_commit if this commit doesn't exists for a
534 532 repository it returns a Dummy one instead
535 533
536 534 :param repo: repository instance
537 535 :param commit_id: commit id as str
538 536 :param commit_idx: numeric commit index
539 537 :param pre_load: optional list of commit attributes to load
540 538 :param maybe_unreachable: translate unreachable commits on git repos
541 539 :param reference_obj: explicitly search via a reference obj in git. E.g "branch:123" would mean branch "123"
542 540 """
543 541 # TODO(skreft): remove these circular imports
544 542 from rhodecode.lib.vcs.backends.base import BaseRepository, EmptyCommit
545 543 from rhodecode.lib.vcs.exceptions import RepositoryError
546 544 if not isinstance(repo, BaseRepository):
547 545 raise Exception('You must pass an Repository '
548 546 'object as first argument got %s', type(repo))
549 547
550 548 try:
551 549 commit = repo.get_commit(
552 550 commit_id=commit_id, commit_idx=commit_idx, pre_load=pre_load,
553 551 maybe_unreachable=maybe_unreachable, reference_obj=reference_obj)
554 552 except (RepositoryError, LookupError):
555 553 commit = EmptyCommit()
556 554 return commit
557 555
558 556
559 557 def datetime_to_time(dt):
560 558 if dt:
561 559 return time.mktime(dt.timetuple())
562 560
563 561
564 562 def time_to_datetime(tm):
565 563 if tm:
566 564 if isinstance(tm, str):
567 565 try:
568 566 tm = float(tm)
569 567 except ValueError:
570 568 return
571 569 return datetime.datetime.fromtimestamp(tm)
572 570
573 571
574 572 def time_to_utcdatetime(tm):
575 573 if tm:
576 574 if isinstance(tm, str):
577 575 try:
578 576 tm = float(tm)
579 577 except ValueError:
580 578 return
581 579 return datetime.datetime.utcfromtimestamp(tm)
582 580
583 581
584 582 MENTIONS_REGEX = re.compile(
585 583 # ^@ or @ without any special chars in front
586 584 r'(?:^@|[^a-zA-Z0-9\-\_\.]@)'
587 585 # main body starts with letter, then can be . - _
588 586 r'([a-zA-Z0-9]{1}[a-zA-Z0-9\-\_\.]+)',
589 587 re.VERBOSE | re.MULTILINE)
590 588
591 589
592 590 def extract_mentioned_users(s):
593 591 """
594 592 Returns unique usernames from given string s that have @mention
595 593
596 594 :param s: string to get mentions
597 595 """
598 596 usrs = set()
599 597 for username in MENTIONS_REGEX.findall(s):
600 598 usrs.add(username)
601 599
602 600 return sorted(list(usrs), key=lambda k: k.lower())
603 601
604 602
605 603 def fix_PATH(os_=None):
606 604 """
607 605 Get current active python path, and append it to PATH variable to fix
608 606 issues of subprocess calls and different python versions
609 607 """
610 608 if os_ is None:
611 609 import os
612 610 else:
613 611 os = os_
614 612
615 613 cur_path = os.path.split(sys.executable)[0]
616 614 os_path = os.environ['PATH']
617 615 if not os.environ['PATH'].startswith(cur_path):
618 616 os.environ['PATH'] = f'{cur_path}:{os_path}'
619 617
620 618
621 619 def obfuscate_url_pw(engine):
622 620 _url = engine or ''
623 621 try:
624 622 _url = sqlalchemy.engine.url.make_url(engine)
625 623 except Exception:
626 624 pass
627 625 return repr(_url)
628 626
629 627
630 628 def get_server_url(environ):
631 629 req = webob.Request(environ)
632 630 return req.host_url + req.script_name
633 631
634 632
635 633 def unique_id(hexlen=32):
636 634 alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjklmnpqrstuvwxyz"
637 635 return suuid(truncate_to=hexlen, alphabet=alphabet)
638 636
639 637
640 638 def suuid(url=None, truncate_to=22, alphabet=None):
641 639 """
642 640 Generate and return a short URL safe UUID.
643 641
644 642 If the url parameter is provided, set the namespace to the provided
645 643 URL and generate a UUID.
646 644
647 645 :param url to get the uuid for
648 646 :truncate_to: truncate the basic 22 UUID to shorter version
649 647
650 648 The IDs won't be universally unique any longer, but the probability of
651 649 a collision will still be very low.
652 650 """
653 651 # Define our alphabet.
654 652 _ALPHABET = alphabet or "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
655 653
656 654 # If no URL is given, generate a random UUID.
657 655 if url is None:
658 656 unique_id = uuid.uuid4().int
659 657 else:
660 658 unique_id = uuid.uuid3(uuid.NAMESPACE_URL, url).int
661 659
662 660 alphabet_length = len(_ALPHABET)
663 661 output = []
664 662 while unique_id > 0:
665 663 digit = unique_id % alphabet_length
666 664 output.append(_ALPHABET[digit])
667 665 unique_id = int(unique_id / alphabet_length)
668 666 return "".join(output)[:truncate_to]
669 667
670 668
671 669 def get_current_rhodecode_user(request=None):
672 670 """
673 671 Gets rhodecode user from request
674 672 """
675 673 import pyramid.threadlocal
676 674 pyramid_request = request or pyramid.threadlocal.get_current_request()
677 675
678 676 # web case
679 677 if pyramid_request and hasattr(pyramid_request, 'user'):
680 678 return pyramid_request.user
681 679
682 680 # api case
683 681 if pyramid_request and hasattr(pyramid_request, 'rpc_user'):
684 682 return pyramid_request.rpc_user
685 683
686 684 return None
687 685
688 686
689 687 def action_logger_generic(action, namespace=''):
690 688 """
691 689 A generic logger for actions useful to the system overview, tries to find
692 690 an acting user for the context of the call otherwise reports unknown user
693 691
694 692 :param action: logging message eg 'comment 5 deleted'
695 693 :param type: string
696 694
697 695 :param namespace: namespace of the logging message eg. 'repo.comments'
698 696 :param type: string
699 697
700 698 """
701 699
702 700 logger_name = 'rhodecode.actions'
703 701
704 702 if namespace:
705 703 logger_name += '.' + namespace
706 704
707 705 log = logging.getLogger(logger_name)
708 706
709 707 # get a user if we can
710 708 user = get_current_rhodecode_user()
711 709
712 710 logfunc = log.info
713 711
714 712 if not user:
715 713 user = '<unknown user>'
716 714 logfunc = log.warning
717 715
718 logfunc('Logging action by {}: {}'.format(user, action))
716 logfunc(f'Logging action by {user}: {action}')
719 717
720 718
721 719 def escape_split(text, sep=',', maxsplit=-1):
722 720 r"""
723 721 Allows for escaping of the separator: e.g. arg='foo\, bar'
724 722
725 723 It should be noted that the way bash et. al. do command line parsing, those
726 724 single quotes are required.
727 725 """
728 726 escaped_sep = r'\%s' % sep
729 727
730 728 if escaped_sep not in text:
731 729 return text.split(sep, maxsplit)
732 730
733 731 before, _mid, after = text.partition(escaped_sep)
734 732 startlist = before.split(sep, maxsplit) # a regular split is fine here
735 733 unfinished = startlist[-1]
736 734 startlist = startlist[:-1]
737 735
738 736 # recurse because there may be more escaped separators
739 737 endlist = escape_split(after, sep, maxsplit)
740 738
741 739 # finish building the escaped value. we use endlist[0] becaue the first
742 740 # part of the string sent in recursion is the rest of the escaped value.
743 741 unfinished += sep + endlist[0]
744 742
745 743 return startlist + [unfinished] + endlist[1:] # put together all the parts
746 744
747 745
748 746 class OptionalAttr(object):
749 747 """
750 748 Special Optional Option that defines other attribute. Example::
751 749
752 750 def test(apiuser, userid=Optional(OAttr('apiuser')):
753 751 user = Optional.extract(userid)
754 752 # calls
755 753
756 754 """
757 755
758 756 def __init__(self, attr_name):
759 757 self.attr_name = attr_name
760 758
761 759 def __repr__(self):
762 760 return '<OptionalAttr:%s>' % self.attr_name
763 761
764 762 def __call__(self):
765 763 return self
766 764
767 765
768 766 # alias
769 767 OAttr = OptionalAttr
770 768
771 769
772 770 class Optional(object):
773 771 """
774 772 Defines an optional parameter::
775 773
776 774 param = param.getval() if isinstance(param, Optional) else param
777 775 param = param() if isinstance(param, Optional) else param
778 776
779 777 is equivalent of::
780 778
781 779 param = Optional.extract(param)
782 780
783 781 """
784 782
785 783 def __init__(self, type_):
786 784 self.type_ = type_
787 785
788 786 def __repr__(self):
789 787 return '<Optional:%s>' % self.type_.__repr__()
790 788
791 789 def __call__(self):
792 790 return self.getval()
793 791
794 792 def getval(self):
795 793 """
796 794 returns value from this Optional instance
797 795 """
798 796 if isinstance(self.type_, OAttr):
799 797 # use params name
800 798 return self.type_.attr_name
801 799 return self.type_
802 800
803 801 @classmethod
804 802 def extract(cls, val):
805 803 """
806 804 Extracts value from Optional() instance
807 805
808 806 :param val:
809 807 :return: original value if it's not Optional instance else
810 808 value of instance
811 809 """
812 810 if isinstance(val, cls):
813 811 return val.getval()
814 812 return val
815 813
816 814
817 815 def glob2re(pat):
818 816 import fnmatch
819 817 return fnmatch.translate(pat)
820 818
821 819
822 820 def parse_byte_string(size_str):
823 821 match = re.match(r'(\d+)(MB|KB)', size_str, re.IGNORECASE)
824 822 if not match:
825 823 raise ValueError(f'Given size:{size_str} is invalid, please make sure '
826 824 f'to use format of <num>(MB|KB)')
827 825
828 826 _parts = match.groups()
829 827 num, type_ = _parts
830 828 return int(num) * {'mb': 1024*1024, 'kb': 1024}[type_.lower()]
831 829
832 830
833 831 class CachedProperty(object):
834 832 """
835 833 Lazy Attributes. With option to invalidate the cache by running a method
836 834
837 835 >>> class Foo(object):
838 836 ...
839 837 ... @CachedProperty
840 838 ... def heavy_func(self):
841 839 ... return 'super-calculation'
842 840 ...
843 841 ... foo = Foo()
844 842 ... foo.heavy_func() # first computation
845 843 ... foo.heavy_func() # fetch from cache
846 844 ... foo._invalidate_prop_cache('heavy_func')
847 845
848 846 # at this point calling foo.heavy_func() will be re-computed
849 847 """
850 848
851 849 def __init__(self, func, func_name=None):
852 850
853 851 if func_name is None:
854 852 func_name = func.__name__
855 853 self.data = (func, func_name)
856 854 functools.update_wrapper(self, func)
857 855
858 856 def __get__(self, inst, class_):
859 857 if inst is None:
860 858 return self
861 859
862 860 func, func_name = self.data
863 861 value = func(inst)
864 862 inst.__dict__[func_name] = value
865 863 if '_invalidate_prop_cache' not in inst.__dict__:
866 864 inst.__dict__['_invalidate_prop_cache'] = functools.partial(
867 865 self._invalidate_prop_cache, inst)
868 866 return value
869 867
870 868 def _invalidate_prop_cache(self, inst, name):
871 869 inst.__dict__.pop(name, None)
872 870
873 871
874 872 def retry(func=None, exception=Exception, n_tries=5, delay=5, backoff=1, logger=True):
875 873 """
876 874 Retry decorator with exponential backoff.
877 875
878 876 Parameters
879 877 ----------
880 878 func : typing.Callable, optional
881 879 Callable on which the decorator is applied, by default None
882 880 exception : Exception or tuple of Exceptions, optional
883 881 Exception(s) that invoke retry, by default Exception
884 882 n_tries : int, optional
885 883 Number of tries before giving up, by default 5
886 884 delay : int, optional
887 885 Initial delay between retries in seconds, by default 5
888 886 backoff : int, optional
889 887 Backoff multiplier e.g. value of 2 will double the delay, by default 1
890 888 logger : bool, optional
891 889 Option to log or print, by default False
892 890
893 891 Returns
894 892 -------
895 893 typing.Callable
896 894 Decorated callable that calls itself when exception(s) occur.
897 895
898 896 Examples
899 897 --------
900 898 >>> import random
901 899 >>> @retry(exception=Exception, n_tries=3)
902 900 ... def test_random(text):
903 901 ... x = random.random()
904 902 ... if x < 0.5:
905 903 ... raise Exception("Fail")
906 904 ... else:
907 905 ... print("Success: ", text)
908 906 >>> test_random("It works!")
909 907 """
910 908
911 909 if func is None:
912 910 return functools.partial(
913 911 retry,
914 912 exception=exception,
915 913 n_tries=n_tries,
916 914 delay=delay,
917 915 backoff=backoff,
918 916 logger=logger,
919 917 )
920 918
921 919 @functools.wraps(func)
922 920 def wrapper(*args, **kwargs):
923 921 _n_tries, n_delay = n_tries, delay
924 922 log = logging.getLogger('rhodecode.retry')
925 923
926 924 while _n_tries > 1:
927 925 try:
928 926 return func(*args, **kwargs)
929 927 except exception as e:
930 928 e_details = repr(e)
931 929 msg = "Exception on calling func {func}: {e}, " \
932 930 "Retrying in {n_delay} seconds..."\
933 931 .format(func=func, e=e_details, n_delay=n_delay)
934 932 if logger:
935 933 log.warning(msg)
936 934 else:
937 935 print(msg)
938 936 time.sleep(n_delay)
939 937 _n_tries -= 1
940 938 n_delay *= backoff
941 939
942 940 return func(*args, **kwargs)
943 941
944 942 return wrapper
945 943
946 944
947 945 def user_agent_normalizer(user_agent_raw, safe=True):
948 946 log = logging.getLogger('rhodecode.user_agent_normalizer')
949 947 ua = (user_agent_raw or '').strip().lower()
950 948 ua = ua.replace('"', '')
951 949
952 950 try:
953 951 if 'mercurial/proto-1.0' in ua:
954 952 ua = ua.replace('mercurial/proto-1.0', '')
955 953 ua = ua.replace('(', '').replace(')', '').strip()
956 954 ua = ua.replace('mercurial ', 'mercurial/')
957 955 elif ua.startswith('git'):
958 956 parts = ua.split(' ')
959 957 if parts:
960 958 ua = parts[0]
961 959 ua = re.sub(r'\.windows\.\d', '', ua).strip()
962 960
963 961 return ua
964 962 except Exception:
965 963 log.exception('Failed to parse scm user-agent')
966 964 if not safe:
967 965 raise
968 966
969 967 return ua
970 968
971 969
972 970 def get_available_port(min_port=40000, max_port=55555, use_range=False):
973 971 hostname = ''
974 972 for _ in range(min_port, max_port):
975 973 pick_port = 0
976 974 if use_range:
977 975 pick_port = random.randint(min_port, max_port)
978 976
979 977 with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
980 978 try:
981 979 s.bind((hostname, pick_port))
982 980 s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
983 981 return s.getsockname()[1]
984 982 except OSError:
985 983 continue
986 984 except socket.error as e:
987 985 if e.args[0] in [errno.EADDRINUSE, errno.ECONNREFUSED]:
988 986 continue
989 987 raise
@@ -1,1987 +1,1985 b''
1
2
3 1 # Copyright (C) 2014-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 """
22 20 Base module for all VCS systems
23 21 """
24 22 import os
25 23 import re
26 24 import time
27 25 import shutil
28 26 import datetime
29 27 import fnmatch
30 28 import itertools
31 29 import logging
32 30 import dataclasses
33 31 import warnings
34 32
35 33 from zope.cachedescriptors.property import Lazy as LazyProperty
36 34
37 35
38 36 import rhodecode
39 37 from rhodecode.translation import lazy_ugettext
40 38 from rhodecode.lib.utils2 import safe_str, CachedProperty
41 39 from rhodecode.lib.vcs.utils import author_name, author_email
42 40 from rhodecode.lib.vcs.conf import settings
43 41 from rhodecode.lib.vcs.exceptions import (
44 42 CommitError, EmptyRepositoryError, NodeAlreadyAddedError,
45 43 NodeAlreadyChangedError, NodeAlreadyExistsError, NodeAlreadyRemovedError,
46 44 NodeDoesNotExistError, NodeNotChangedError, VCSError,
47 45 ImproperArchiveTypeError, BranchDoesNotExistError, CommitDoesNotExistError,
48 46 RepositoryError)
49 47
50 48
51 49 log = logging.getLogger(__name__)
52 50
53 51
54 52 FILEMODE_DEFAULT = 0o100644
55 53 FILEMODE_EXECUTABLE = 0o100755
56 54 EMPTY_COMMIT_ID = '0' * 40
57 55
58 56
59 57 @dataclasses.dataclass
60 58 class Reference:
61 59 type: str
62 60 name: str
63 61 commit_id: str
64 62
65 63 def __iter__(self):
66 64 yield self.type
67 65 yield self.name
68 66 yield self.commit_id
69 67
70 68 @property
71 69 def branch(self):
72 70 if self.type == 'branch':
73 71 return self.name
74 72
75 73 @property
76 74 def bookmark(self):
77 75 if self.type == 'book':
78 76 return self.name
79 77
80 78 @property
81 79 def to_str(self):
82 80 return reference_to_unicode(self)
83 81
84 82 def asdict(self):
85 83 return dict(
86 84 type=self.type,
87 85 name=self.name,
88 86 commit_id=self.commit_id
89 87 )
90 88
91 89
92 90 def unicode_to_reference(raw: str):
93 91 """
94 92 Convert a unicode (or string) to a reference object.
95 93 If unicode evaluates to False it returns None.
96 94 """
97 95 if raw:
98 96 refs = raw.split(':')
99 97 return Reference(*refs)
100 98 else:
101 99 return None
102 100
103 101
104 102 def reference_to_unicode(ref: Reference):
105 103 """
106 104 Convert a reference object to unicode.
107 105 If reference is None it returns None.
108 106 """
109 107 if ref:
110 108 return ':'.join(ref)
111 109 else:
112 110 return None
113 111
114 112
115 113 class MergeFailureReason(object):
116 114 """
117 115 Enumeration with all the reasons why the server side merge could fail.
118 116
119 117 DO NOT change the number of the reasons, as they may be stored in the
120 118 database.
121 119
122 120 Changing the name of a reason is acceptable and encouraged to deprecate old
123 121 reasons.
124 122 """
125 123
126 124 # Everything went well.
127 125 NONE = 0
128 126
129 127 # An unexpected exception was raised. Check the logs for more details.
130 128 UNKNOWN = 1
131 129
132 130 # The merge was not successful, there are conflicts.
133 131 MERGE_FAILED = 2
134 132
135 133 # The merge succeeded but we could not push it to the target repository.
136 134 PUSH_FAILED = 3
137 135
138 136 # The specified target is not a head in the target repository.
139 137 TARGET_IS_NOT_HEAD = 4
140 138
141 139 # The source repository contains more branches than the target. Pushing
142 140 # the merge will create additional branches in the target.
143 141 HG_SOURCE_HAS_MORE_BRANCHES = 5
144 142
145 143 # The target reference has multiple heads. That does not allow to correctly
146 144 # identify the target location. This could only happen for mercurial
147 145 # branches.
148 146 HG_TARGET_HAS_MULTIPLE_HEADS = 6
149 147
150 148 # The target repository is locked
151 149 TARGET_IS_LOCKED = 7
152 150
153 151 # Deprecated, use MISSING_TARGET_REF or MISSING_SOURCE_REF instead.
154 152 # A involved commit could not be found.
155 153 _DEPRECATED_MISSING_COMMIT = 8
156 154
157 155 # The target repo reference is missing.
158 156 MISSING_TARGET_REF = 9
159 157
160 158 # The source repo reference is missing.
161 159 MISSING_SOURCE_REF = 10
162 160
163 161 # The merge was not successful, there are conflicts related to sub
164 162 # repositories.
165 163 SUBREPO_MERGE_FAILED = 11
166 164
167 165
168 166 class UpdateFailureReason(object):
169 167 """
170 168 Enumeration with all the reasons why the pull request update could fail.
171 169
172 170 DO NOT change the number of the reasons, as they may be stored in the
173 171 database.
174 172
175 173 Changing the name of a reason is acceptable and encouraged to deprecate old
176 174 reasons.
177 175 """
178 176
179 177 # Everything went well.
180 178 NONE = 0
181 179
182 180 # An unexpected exception was raised. Check the logs for more details.
183 181 UNKNOWN = 1
184 182
185 183 # The pull request is up to date.
186 184 NO_CHANGE = 2
187 185
188 186 # The pull request has a reference type that is not supported for update.
189 187 WRONG_REF_TYPE = 3
190 188
191 189 # Update failed because the target reference is missing.
192 190 MISSING_TARGET_REF = 4
193 191
194 192 # Update failed because the source reference is missing.
195 193 MISSING_SOURCE_REF = 5
196 194
197 195
198 196 class MergeResponse(object):
199 197
200 198 # uses .format(**metadata) for variables
201 199 MERGE_STATUS_MESSAGES = {
202 200 MergeFailureReason.NONE: lazy_ugettext(
203 201 'This pull request can be automatically merged.'),
204 202 MergeFailureReason.UNKNOWN: lazy_ugettext(
205 203 'This pull request cannot be merged because of an unhandled exception. '
206 204 '{exception}'),
207 205 MergeFailureReason.MERGE_FAILED: lazy_ugettext(
208 206 'This pull request cannot be merged because of merge conflicts. {unresolved_files}'),
209 207 MergeFailureReason.PUSH_FAILED: lazy_ugettext(
210 208 'This pull request could not be merged because push to '
211 209 'target:`{target}@{merge_commit}` failed.'),
212 210 MergeFailureReason.TARGET_IS_NOT_HEAD: lazy_ugettext(
213 211 'This pull request cannot be merged because the target '
214 212 '`{target_ref.name}` is not a head.'),
215 213 MergeFailureReason.HG_SOURCE_HAS_MORE_BRANCHES: lazy_ugettext(
216 214 'This pull request cannot be merged because the source contains '
217 215 'more branches than the target.'),
218 216 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS: lazy_ugettext(
219 217 'This pull request cannot be merged because the target `{target_ref.name}` '
220 218 'has multiple heads: `{heads}`.'),
221 219 MergeFailureReason.TARGET_IS_LOCKED: lazy_ugettext(
222 220 'This pull request cannot be merged because the target repository is '
223 221 'locked by {locked_by}.'),
224 222
225 223 MergeFailureReason.MISSING_TARGET_REF: lazy_ugettext(
226 224 'This pull request cannot be merged because the target '
227 225 'reference `{target_ref.name}` is missing.'),
228 226 MergeFailureReason.MISSING_SOURCE_REF: lazy_ugettext(
229 227 'This pull request cannot be merged because the source '
230 228 'reference `{source_ref.name}` is missing.'),
231 229 MergeFailureReason.SUBREPO_MERGE_FAILED: lazy_ugettext(
232 230 'This pull request cannot be merged because of conflicts related '
233 231 'to sub repositories.'),
234 232
235 233 # Deprecations
236 234 MergeFailureReason._DEPRECATED_MISSING_COMMIT: lazy_ugettext(
237 235 'This pull request cannot be merged because the target or the '
238 236 'source reference is missing.'),
239 237
240 238 }
241 239
242 240 def __init__(self, possible, executed, merge_ref, failure_reason, metadata=None):
243 241 self.possible = possible
244 242 self.executed = executed
245 243 self.merge_ref = merge_ref
246 244 self.failure_reason = failure_reason
247 245 self.metadata = metadata or {}
248 246
249 247 def __repr__(self):
250 248 return f'<MergeResponse:{self.label} {self.failure_reason}>'
251 249
252 250 def __eq__(self, other):
253 251 same_instance = isinstance(other, self.__class__)
254 252 return same_instance \
255 253 and self.possible == other.possible \
256 254 and self.executed == other.executed \
257 255 and self.failure_reason == other.failure_reason
258 256
259 257 @property
260 258 def label(self):
261 259 label_dict = dict((v, k) for k, v in MergeFailureReason.__dict__.items() if
262 260 not k.startswith('_'))
263 261 return label_dict.get(self.failure_reason)
264 262
265 263 @property
266 264 def merge_status_message(self):
267 265 """
268 266 Return a human friendly error message for the given merge status code.
269 267 """
270 268 msg = safe_str(self.MERGE_STATUS_MESSAGES[self.failure_reason])
271 269
272 270 try:
273 271 return msg.format(**self.metadata)
274 272 except Exception:
275 273 log.exception('Failed to format %s message', self)
276 274 return msg
277 275
278 276 def asdict(self):
279 277 data = {}
280 278 for k in ['possible', 'executed', 'merge_ref', 'failure_reason',
281 279 'merge_status_message']:
282 280 data[k] = getattr(self, k)
283 281 return data
284 282
285 283
286 284 class TargetRefMissing(ValueError):
287 285 pass
288 286
289 287
290 288 class SourceRefMissing(ValueError):
291 289 pass
292 290
293 291
294 292 class BaseRepository(object):
295 293 """
296 294 Base Repository for final backends
297 295
298 296 .. attribute:: DEFAULT_BRANCH_NAME
299 297
300 298 name of default branch (i.e. "trunk" for svn, "master" for git etc.
301 299
302 300 .. attribute:: commit_ids
303 301
304 302 list of all available commit ids, in ascending order
305 303
306 304 .. attribute:: path
307 305
308 306 absolute path to the repository
309 307
310 308 .. attribute:: bookmarks
311 309
312 310 Mapping from name to :term:`Commit ID` of the bookmark. Empty in case
313 311 there are no bookmarks or the backend implementation does not support
314 312 bookmarks.
315 313
316 314 .. attribute:: tags
317 315
318 316 Mapping from name to :term:`Commit ID` of the tag.
319 317
320 318 """
321 319
322 320 DEFAULT_BRANCH_NAME = None
323 321 DEFAULT_CONTACT = "Unknown"
324 322 DEFAULT_DESCRIPTION = "unknown"
325 323 EMPTY_COMMIT_ID = '0' * 40
326 324 COMMIT_ID_PAT = re.compile(r'[0-9a-fA-F]{40}')
327 325
328 326 path = None
329 327
330 328 _is_empty = None
331 329 _commit_ids = {}
332 330
333 331 def __init__(self, repo_path, config=None, create=False, **kwargs):
334 332 """
335 333 Initializes repository. Raises RepositoryError if repository could
336 334 not be find at the given ``repo_path`` or directory at ``repo_path``
337 335 exists and ``create`` is set to True.
338 336
339 337 :param repo_path: local path of the repository
340 338 :param config: repository configuration
341 339 :param create=False: if set to True, would try to create repository.
342 340 :param src_url=None: if set, should be proper url from which repository
343 341 would be cloned; requires ``create`` parameter to be set to True -
344 342 raises RepositoryError if src_url is set and create evaluates to
345 343 False
346 344 """
347 345 raise NotImplementedError
348 346
349 347 def __repr__(self):
350 return '<{} at {}>'.format(self.__class__.__name__, self.path)
348 return f'<{self.__class__.__name__} at {self.path}>'
351 349
352 350 def __len__(self):
353 351 return self.count()
354 352
355 353 def __eq__(self, other):
356 354 same_instance = isinstance(other, self.__class__)
357 355 return same_instance and other.path == self.path
358 356
359 357 def __ne__(self, other):
360 358 return not self.__eq__(other)
361 359
362 360 def get_create_shadow_cache_pr_path(self, db_repo):
363 361 path = db_repo.cached_diffs_dir
364 362 if not os.path.exists(path):
365 363 os.makedirs(path, 0o755)
366 364 return path
367 365
368 366 @classmethod
369 367 def get_default_config(cls, default=None):
370 368 config = Config()
371 369 if default and isinstance(default, list):
372 370 for section, key, val in default:
373 371 config.set(section, key, val)
374 372 return config
375 373
376 374 @LazyProperty
377 375 def _remote(self):
378 376 raise NotImplementedError
379 377
380 378 def _heads(self, branch=None):
381 379 return []
382 380
383 381 @LazyProperty
384 382 def EMPTY_COMMIT(self):
385 383 return EmptyCommit(self.EMPTY_COMMIT_ID)
386 384
387 385 @LazyProperty
388 386 def alias(self):
389 387 for k, v in settings.BACKENDS.items():
390 388 if v.split('.')[-1] == str(self.__class__.__name__):
391 389 return k
392 390
393 391 @LazyProperty
394 392 def name(self):
395 393 return safe_str(os.path.basename(self.path))
396 394
397 395 @LazyProperty
398 396 def description(self):
399 397 raise NotImplementedError
400 398
401 399 def refs(self):
402 400 """
403 401 returns a `dict` with branches, bookmarks, tags, and closed_branches
404 402 for this repository
405 403 """
406 404 return dict(
407 405 branches=self.branches,
408 406 branches_closed=self.branches_closed,
409 407 tags=self.tags,
410 408 bookmarks=self.bookmarks
411 409 )
412 410
413 411 @LazyProperty
414 412 def branches(self):
415 413 """
416 414 A `dict` which maps branch names to commit ids.
417 415 """
418 416 raise NotImplementedError
419 417
420 418 @LazyProperty
421 419 def branches_closed(self):
422 420 """
423 421 A `dict` which maps tags names to commit ids.
424 422 """
425 423 raise NotImplementedError
426 424
427 425 @LazyProperty
428 426 def bookmarks(self):
429 427 """
430 428 A `dict` which maps tags names to commit ids.
431 429 """
432 430 raise NotImplementedError
433 431
434 432 @LazyProperty
435 433 def tags(self):
436 434 """
437 435 A `dict` which maps tags names to commit ids.
438 436 """
439 437 raise NotImplementedError
440 438
441 439 @LazyProperty
442 440 def size(self):
443 441 """
444 442 Returns combined size in bytes for all repository files
445 443 """
446 444 tip = self.get_commit()
447 445 return tip.size
448 446
449 447 def size_at_commit(self, commit_id):
450 448 commit = self.get_commit(commit_id)
451 449 return commit.size
452 450
453 451 def _check_for_empty(self):
454 452 no_commits = len(self._commit_ids) == 0
455 453 if no_commits:
456 454 # check on remote to be sure
457 455 return self._remote.is_empty()
458 456 else:
459 457 return False
460 458
461 459 def is_empty(self):
462 460 if rhodecode.is_test:
463 461 return self._check_for_empty()
464 462
465 463 if self._is_empty is None:
466 464 # cache empty for production, but not tests
467 465 self._is_empty = self._check_for_empty()
468 466
469 467 return self._is_empty
470 468
471 469 @staticmethod
472 470 def check_url(url, config):
473 471 """
474 472 Function will check given url and try to verify if it's a valid
475 473 link.
476 474 """
477 475 raise NotImplementedError
478 476
479 477 @staticmethod
480 478 def is_valid_repository(path):
481 479 """
482 480 Check if given `path` contains a valid repository of this backend
483 481 """
484 482 raise NotImplementedError
485 483
486 484 # ==========================================================================
487 485 # COMMITS
488 486 # ==========================================================================
489 487
490 488 @CachedProperty
491 489 def commit_ids(self):
492 490 raise NotImplementedError
493 491
494 492 def append_commit_id(self, commit_id):
495 493 if commit_id not in self.commit_ids:
496 494 self._rebuild_cache(self.commit_ids + [commit_id])
497 495
498 496 # clear cache
499 497 self._invalidate_prop_cache('commit_ids')
500 498 self._is_empty = False
501 499
502 500 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None,
503 501 translate_tag=None, maybe_unreachable=False, reference_obj=None):
504 502 """
505 503 Returns instance of `BaseCommit` class. If `commit_id` and `commit_idx`
506 504 are both None, most recent commit is returned.
507 505
508 506 :param pre_load: Optional. List of commit attributes to load.
509 507
510 508 :raises ``EmptyRepositoryError``: if there are no commits
511 509 """
512 510 raise NotImplementedError
513 511
514 512 def __iter__(self):
515 513 for commit_id in self.commit_ids:
516 514 yield self.get_commit(commit_id=commit_id)
517 515
518 516 def get_commits(
519 517 self, start_id=None, end_id=None, start_date=None, end_date=None,
520 518 branch_name=None, show_hidden=False, pre_load=None, translate_tags=None):
521 519 """
522 520 Returns iterator of `BaseCommit` objects from start to end
523 521 not inclusive. This should behave just like a list, ie. end is not
524 522 inclusive.
525 523
526 524 :param start_id: None or str, must be a valid commit id
527 525 :param end_id: None or str, must be a valid commit id
528 526 :param start_date:
529 527 :param end_date:
530 528 :param branch_name:
531 529 :param show_hidden:
532 530 :param pre_load:
533 531 :param translate_tags:
534 532 """
535 533 raise NotImplementedError
536 534
537 535 def __getitem__(self, key):
538 536 """
539 537 Allows index based access to the commit objects of this repository.
540 538 """
541 539 pre_load = ["author", "branch", "date", "message", "parents"]
542 540 if isinstance(key, slice):
543 541 return self._get_range(key, pre_load)
544 542 return self.get_commit(commit_idx=key, pre_load=pre_load)
545 543
546 544 def _get_range(self, slice_obj, pre_load):
547 545 for commit_id in self.commit_ids.__getitem__(slice_obj):
548 546 yield self.get_commit(commit_id=commit_id, pre_load=pre_load)
549 547
550 548 def count(self):
551 549 return len(self.commit_ids)
552 550
553 551 def tag(self, name, user, commit_id=None, message=None, date=None, **opts):
554 552 """
555 553 Creates and returns a tag for the given ``commit_id``.
556 554
557 555 :param name: name for new tag
558 556 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
559 557 :param commit_id: commit id for which new tag would be created
560 558 :param message: message of the tag's commit
561 559 :param date: date of tag's commit
562 560
563 561 :raises TagAlreadyExistError: if tag with same name already exists
564 562 """
565 563 raise NotImplementedError
566 564
567 565 def remove_tag(self, name, user, message=None, date=None):
568 566 """
569 567 Removes tag with the given ``name``.
570 568
571 569 :param name: name of the tag to be removed
572 570 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
573 571 :param message: message of the tag's removal commit
574 572 :param date: date of tag's removal commit
575 573
576 574 :raises TagDoesNotExistError: if tag with given name does not exists
577 575 """
578 576 raise NotImplementedError
579 577
580 578 def get_diff(
581 579 self, commit1, commit2, path=None, ignore_whitespace=False,
582 580 context=3, path1=None):
583 581 """
584 582 Returns (git like) *diff*, as plain text. Shows changes introduced by
585 583 `commit2` since `commit1`.
586 584
587 585 :param commit1: Entry point from which diff is shown. Can be
588 586 ``self.EMPTY_COMMIT`` - in this case, patch showing all
589 587 the changes since empty state of the repository until `commit2`
590 588 :param commit2: Until which commit changes should be shown.
591 589 :param path: Can be set to a path of a file to create a diff of that
592 590 file. If `path1` is also set, this value is only associated to
593 591 `commit2`.
594 592 :param ignore_whitespace: If set to ``True``, would not show whitespace
595 593 changes. Defaults to ``False``.
596 594 :param context: How many lines before/after changed lines should be
597 595 shown. Defaults to ``3``.
598 596 :param path1: Can be set to a path to associate with `commit1`. This
599 597 parameter works only for backends which support diff generation for
600 598 different paths. Other backends will raise a `ValueError` if `path1`
601 599 is set and has a different value than `path`.
602 600 :param file_path: filter this diff by given path pattern
603 601 """
604 602 raise NotImplementedError
605 603
606 604 def strip(self, commit_id, branch=None):
607 605 """
608 606 Strip given commit_id from the repository
609 607 """
610 608 raise NotImplementedError
611 609
612 610 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
613 611 """
614 612 Return a latest common ancestor commit if one exists for this repo
615 613 `commit_id1` vs `commit_id2` from `repo2`.
616 614
617 615 :param commit_id1: Commit it from this repository to use as a
618 616 target for the comparison.
619 617 :param commit_id2: Source commit id to use for comparison.
620 618 :param repo2: Source repository to use for comparison.
621 619 """
622 620 raise NotImplementedError
623 621
624 622 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
625 623 """
626 624 Compare this repository's revision `commit_id1` with `commit_id2`.
627 625
628 626 Returns a tuple(commits, ancestor) that would be merged from
629 627 `commit_id2`. Doing a normal compare (``merge=False``), ``None``
630 628 will be returned as ancestor.
631 629
632 630 :param commit_id1: Commit it from this repository to use as a
633 631 target for the comparison.
634 632 :param commit_id2: Source commit id to use for comparison.
635 633 :param repo2: Source repository to use for comparison.
636 634 :param merge: If set to ``True`` will do a merge compare which also
637 635 returns the common ancestor.
638 636 :param pre_load: Optional. List of commit attributes to load.
639 637 """
640 638 raise NotImplementedError
641 639
642 640 def merge(self, repo_id, workspace_id, target_ref, source_repo, source_ref,
643 641 user_name='', user_email='', message='', dry_run=False,
644 642 use_rebase=False, close_branch=False):
645 643 """
646 644 Merge the revisions specified in `source_ref` from `source_repo`
647 645 onto the `target_ref` of this repository.
648 646
649 647 `source_ref` and `target_ref` are named tupls with the following
650 648 fields `type`, `name` and `commit_id`.
651 649
652 650 Returns a MergeResponse named tuple with the following fields
653 651 'possible', 'executed', 'source_commit', 'target_commit',
654 652 'merge_commit'.
655 653
656 654 :param repo_id: `repo_id` target repo id.
657 655 :param workspace_id: `workspace_id` unique identifier.
658 656 :param target_ref: `target_ref` points to the commit on top of which
659 657 the `source_ref` should be merged.
660 658 :param source_repo: The repository that contains the commits to be
661 659 merged.
662 660 :param source_ref: `source_ref` points to the topmost commit from
663 661 the `source_repo` which should be merged.
664 662 :param user_name: Merge commit `user_name`.
665 663 :param user_email: Merge commit `user_email`.
666 664 :param message: Merge commit `message`.
667 665 :param dry_run: If `True` the merge will not take place.
668 666 :param use_rebase: If `True` commits from the source will be rebased
669 667 on top of the target instead of being merged.
670 668 :param close_branch: If `True` branch will be close before merging it
671 669 """
672 670 if dry_run:
673 671 message = message or settings.MERGE_DRY_RUN_MESSAGE
674 672 user_email = user_email or settings.MERGE_DRY_RUN_EMAIL
675 673 user_name = user_name or settings.MERGE_DRY_RUN_USER
676 674 else:
677 675 if not user_name:
678 676 raise ValueError('user_name cannot be empty')
679 677 if not user_email:
680 678 raise ValueError('user_email cannot be empty')
681 679 if not message:
682 680 raise ValueError('message cannot be empty')
683 681
684 682 try:
685 683 return self._merge_repo(
686 684 repo_id, workspace_id, target_ref, source_repo,
687 685 source_ref, message, user_name, user_email, dry_run=dry_run,
688 686 use_rebase=use_rebase, close_branch=close_branch)
689 687 except RepositoryError as exc:
690 688 log.exception('Unexpected failure when running merge, dry-run=%s', dry_run)
691 689 return MergeResponse(
692 690 False, False, None, MergeFailureReason.UNKNOWN,
693 691 metadata={'exception': str(exc)})
694 692
695 693 def _merge_repo(self, repo_id, workspace_id, target_ref,
696 694 source_repo, source_ref, merge_message,
697 695 merger_name, merger_email, dry_run=False,
698 696 use_rebase=False, close_branch=False):
699 697 """Internal implementation of merge."""
700 698 raise NotImplementedError
701 699
702 700 def _maybe_prepare_merge_workspace(
703 701 self, repo_id, workspace_id, target_ref, source_ref):
704 702 """
705 703 Create the merge workspace.
706 704
707 705 :param workspace_id: `workspace_id` unique identifier.
708 706 """
709 707 raise NotImplementedError
710 708
711 709 @classmethod
712 710 def _get_legacy_shadow_repository_path(cls, repo_path, workspace_id):
713 711 """
714 712 Legacy version that was used before. We still need it for
715 713 backward compat
716 714 """
717 715 return os.path.join(
718 716 os.path.dirname(repo_path),
719 '.__shadow_{}_{}'.format(os.path.basename(repo_path), workspace_id))
717 f'.__shadow_{os.path.basename(repo_path)}_{workspace_id}')
720 718
721 719 @classmethod
722 720 def _get_shadow_repository_path(cls, repo_path, repo_id, workspace_id):
723 721 # The name of the shadow repository must start with '.', so it is
724 722 # skipped by 'rhodecode.lib.utils.get_filesystem_repos'.
725 723 legacy_repository_path = cls._get_legacy_shadow_repository_path(repo_path, workspace_id)
726 724 if os.path.exists(legacy_repository_path):
727 725 return legacy_repository_path
728 726 else:
729 727 return os.path.join(
730 728 os.path.dirname(repo_path),
731 '.__shadow_repo_{}_{}'.format(repo_id, workspace_id))
729 f'.__shadow_repo_{repo_id}_{workspace_id}')
732 730
733 731 def cleanup_merge_workspace(self, repo_id, workspace_id):
734 732 """
735 733 Remove merge workspace.
736 734
737 735 This function MUST not fail in case there is no workspace associated to
738 736 the given `workspace_id`.
739 737
740 738 :param workspace_id: `workspace_id` unique identifier.
741 739 """
742 740 shadow_repository_path = self._get_shadow_repository_path(
743 741 self.path, repo_id, workspace_id)
744 742 shadow_repository_path_del = '{}.{}.delete'.format(
745 743 shadow_repository_path, time.time())
746 744
747 745 # move the shadow repo, so it never conflicts with the one used.
748 746 # we use this method because shutil.rmtree had some edge case problems
749 747 # removing symlinked repositories
750 748 if not os.path.isdir(shadow_repository_path):
751 749 return
752 750
753 751 shutil.move(shadow_repository_path, shadow_repository_path_del)
754 752 try:
755 753 shutil.rmtree(shadow_repository_path_del, ignore_errors=False)
756 754 except Exception:
757 755 log.exception('Failed to gracefully remove shadow repo under %s',
758 756 shadow_repository_path_del)
759 757 shutil.rmtree(shadow_repository_path_del, ignore_errors=True)
760 758
761 759 # ========== #
762 760 # COMMIT API #
763 761 # ========== #
764 762
765 763 @LazyProperty
766 764 def in_memory_commit(self):
767 765 """
768 766 Returns :class:`InMemoryCommit` object for this repository.
769 767 """
770 768 raise NotImplementedError
771 769
772 770 # ======================== #
773 771 # UTILITIES FOR SUBCLASSES #
774 772 # ======================== #
775 773
776 774 def _validate_diff_commits(self, commit1, commit2):
777 775 """
778 776 Validates that the given commits are related to this repository.
779 777
780 778 Intended as a utility for sub classes to have a consistent validation
781 779 of input parameters in methods like :meth:`get_diff`.
782 780 """
783 781 self._validate_commit(commit1)
784 782 self._validate_commit(commit2)
785 783 if (isinstance(commit1, EmptyCommit) and
786 784 isinstance(commit2, EmptyCommit)):
787 785 raise ValueError("Cannot compare two empty commits")
788 786
789 787 def _validate_commit(self, commit):
790 788 if not isinstance(commit, BaseCommit):
791 789 raise TypeError(
792 790 "%s is not of type BaseCommit" % repr(commit))
793 791 if commit.repository != self and not isinstance(commit, EmptyCommit):
794 792 raise ValueError(
795 793 "Commit %s must be a valid commit from this repository %s, "
796 794 "related to this repository instead %s." %
797 795 (commit, self, commit.repository))
798 796
799 797 def _validate_commit_id(self, commit_id):
800 798 if not isinstance(commit_id, str):
801 799 raise TypeError(f"commit_id must be a string value got {type(commit_id)} instead")
802 800
803 801 def _validate_commit_idx(self, commit_idx):
804 802 if not isinstance(commit_idx, int):
805 803 raise TypeError(f"commit_idx must be a numeric value, got {type(commit_idx)}")
806 804
807 805 def _validate_branch_name(self, branch_name):
808 806 if branch_name and branch_name not in self.branches_all:
809 msg = ("Branch {} not found in {}".format(branch_name, self))
807 msg = (f"Branch {branch_name} not found in {self}")
810 808 raise BranchDoesNotExistError(msg)
811 809
812 810 #
813 811 # Supporting deprecated API parts
814 812 # TODO: johbo: consider to move this into a mixin
815 813 #
816 814
817 815 @property
818 816 def EMPTY_CHANGESET(self):
819 817 warnings.warn(
820 818 "Use EMPTY_COMMIT or EMPTY_COMMIT_ID instead", DeprecationWarning)
821 819 return self.EMPTY_COMMIT_ID
822 820
823 821 @property
824 822 def revisions(self):
825 823 warnings.warn("Use commits attribute instead", DeprecationWarning)
826 824 return self.commit_ids
827 825
828 826 @revisions.setter
829 827 def revisions(self, value):
830 828 warnings.warn("Use commits attribute instead", DeprecationWarning)
831 829 self.commit_ids = value
832 830
833 831 def get_changeset(self, revision=None, pre_load=None):
834 832 warnings.warn("Use get_commit instead", DeprecationWarning)
835 833 commit_id = None
836 834 commit_idx = None
837 835 if isinstance(revision, str):
838 836 commit_id = revision
839 837 else:
840 838 commit_idx = revision
841 839 return self.get_commit(
842 840 commit_id=commit_id, commit_idx=commit_idx, pre_load=pre_load)
843 841
844 842 def get_changesets(
845 843 self, start=None, end=None, start_date=None, end_date=None,
846 844 branch_name=None, pre_load=None):
847 845 warnings.warn("Use get_commits instead", DeprecationWarning)
848 846 start_id = self._revision_to_commit(start)
849 847 end_id = self._revision_to_commit(end)
850 848 return self.get_commits(
851 849 start_id=start_id, end_id=end_id, start_date=start_date,
852 850 end_date=end_date, branch_name=branch_name, pre_load=pre_load)
853 851
854 852 def _revision_to_commit(self, revision):
855 853 """
856 854 Translates a revision to a commit_id
857 855
858 856 Helps to support the old changeset based API which allows to use
859 857 commit ids and commit indices interchangeable.
860 858 """
861 859 if revision is None:
862 860 return revision
863 861
864 862 if isinstance(revision, str):
865 863 commit_id = revision
866 864 else:
867 865 commit_id = self.commit_ids[revision]
868 866 return commit_id
869 867
870 868 @property
871 869 def in_memory_changeset(self):
872 870 warnings.warn("Use in_memory_commit instead", DeprecationWarning)
873 871 return self.in_memory_commit
874 872
875 873 def get_path_permissions(self, username):
876 874 """
877 875 Returns a path permission checker or None if not supported
878 876
879 877 :param username: session user name
880 878 :return: an instance of BasePathPermissionChecker or None
881 879 """
882 880 return None
883 881
884 882 def install_hooks(self, force=False):
885 883 return self._remote.install_hooks(force)
886 884
887 885 def get_hooks_info(self):
888 886 return self._remote.get_hooks_info()
889 887
890 888 def vcsserver_invalidate_cache(self, delete=False):
891 889 return self._remote.vcsserver_invalidate_cache(delete)
892 890
893 891
894 892 class BaseCommit(object):
895 893 """
896 894 Each backend should implement it's commit representation.
897 895
898 896 **Attributes**
899 897
900 898 ``repository``
901 899 repository object within which commit exists
902 900
903 901 ``id``
904 902 The commit id, may be ``raw_id`` or i.e. for mercurial's tip
905 903 just ``tip``.
906 904
907 905 ``raw_id``
908 906 raw commit representation (i.e. full 40 length sha for git
909 907 backend)
910 908
911 909 ``short_id``
912 910 shortened (if apply) version of ``raw_id``; it would be simple
913 911 shortcut for ``raw_id[:12]`` for git/mercurial backends or same
914 912 as ``raw_id`` for subversion
915 913
916 914 ``idx``
917 915 commit index
918 916
919 917 ``files``
920 918 list of ``FileNode`` (``Node`` with NodeKind.FILE) objects
921 919
922 920 ``dirs``
923 921 list of ``DirNode`` (``Node`` with NodeKind.DIR) objects
924 922
925 923 ``nodes``
926 924 combined list of ``Node`` objects
927 925
928 926 ``author``
929 927 author of the commit, as unicode
930 928
931 929 ``message``
932 930 message of the commit, as unicode
933 931
934 932 ``parents``
935 933 list of parent commits
936 934
937 935 """
938 936 repository = None
939 937 branch = None
940 938
941 939 """
942 940 Depending on the backend this should be set to the branch name of the
943 941 commit. Backends not supporting branches on commits should leave this
944 942 value as ``None``.
945 943 """
946 944
947 945 _ARCHIVE_PREFIX_TEMPLATE = '{repo_name}-{short_id}'
948 946 """
949 947 This template is used to generate a default prefix for repository archives
950 948 if no prefix has been specified.
951 949 """
952 950
953 951 def __repr__(self):
954 952 return self.__str__()
955 953
956 954 def __str__(self):
957 955 return f'<{self.__class__.__name__} at {self.idx}:{self.short_id}>'
958 956
959 957 def __eq__(self, other):
960 958 same_instance = isinstance(other, self.__class__)
961 959 return same_instance and self.raw_id == other.raw_id
962 960
963 961 def __json__(self):
964 962 parents = []
965 963 try:
966 964 for parent in self.parents:
967 965 parents.append({'raw_id': parent.raw_id})
968 966 except NotImplementedError:
969 967 # empty commit doesn't have parents implemented
970 968 pass
971 969
972 970 return {
973 971 'short_id': self.short_id,
974 972 'raw_id': self.raw_id,
975 973 'revision': self.idx,
976 974 'message': self.message,
977 975 'date': self.date,
978 976 'author': self.author,
979 977 'parents': parents,
980 978 'branch': self.branch
981 979 }
982 980
983 981 def __getstate__(self):
984 982 d = self.__dict__.copy()
985 983 d.pop('_remote', None)
986 984 d.pop('repository', None)
987 985 return d
988 986
989 987 def get_remote(self):
990 988 return self._remote
991 989
992 990 def serialize(self):
993 991 return self.__json__()
994 992
995 993 def _get_refs(self):
996 994 return {
997 995 'branches': [self.branch] if self.branch else [],
998 996 'bookmarks': getattr(self, 'bookmarks', []),
999 997 'tags': self.tags
1000 998 }
1001 999
1002 1000 @LazyProperty
1003 1001 def last(self):
1004 1002 """
1005 1003 ``True`` if this is last commit in repository, ``False``
1006 1004 otherwise; trying to access this attribute while there is no
1007 1005 commits would raise `EmptyRepositoryError`
1008 1006 """
1009 1007 if self.repository is None:
1010 1008 raise CommitError("Cannot check if it's most recent commit")
1011 1009 return self.raw_id == self.repository.commit_ids[-1]
1012 1010
1013 1011 @LazyProperty
1014 1012 def parents(self):
1015 1013 """
1016 1014 Returns list of parent commits.
1017 1015 """
1018 1016 raise NotImplementedError
1019 1017
1020 1018 @LazyProperty
1021 1019 def first_parent(self):
1022 1020 """
1023 1021 Returns list of parent commits.
1024 1022 """
1025 1023 return self.parents[0] if self.parents else EmptyCommit()
1026 1024
1027 1025 @property
1028 1026 def merge(self):
1029 1027 """
1030 1028 Returns boolean if commit is a merge.
1031 1029 """
1032 1030 return len(self.parents) > 1
1033 1031
1034 1032 @LazyProperty
1035 1033 def children(self):
1036 1034 """
1037 1035 Returns list of child commits.
1038 1036 """
1039 1037 raise NotImplementedError
1040 1038
1041 1039 @LazyProperty
1042 1040 def id(self):
1043 1041 """
1044 1042 Returns string identifying this commit.
1045 1043 """
1046 1044 raise NotImplementedError
1047 1045
1048 1046 @LazyProperty
1049 1047 def raw_id(self):
1050 1048 """
1051 1049 Returns raw string identifying this commit.
1052 1050 """
1053 1051 raise NotImplementedError
1054 1052
1055 1053 @LazyProperty
1056 1054 def short_id(self):
1057 1055 """
1058 1056 Returns shortened version of ``raw_id`` attribute, as string,
1059 1057 identifying this commit, useful for presentation to users.
1060 1058 """
1061 1059 raise NotImplementedError
1062 1060
1063 1061 @LazyProperty
1064 1062 def idx(self):
1065 1063 """
1066 1064 Returns integer identifying this commit.
1067 1065 """
1068 1066 raise NotImplementedError
1069 1067
1070 1068 @LazyProperty
1071 1069 def committer(self):
1072 1070 """
1073 1071 Returns committer for this commit
1074 1072 """
1075 1073 raise NotImplementedError
1076 1074
1077 1075 @LazyProperty
1078 1076 def committer_name(self):
1079 1077 """
1080 1078 Returns committer name for this commit
1081 1079 """
1082 1080
1083 1081 return author_name(self.committer)
1084 1082
1085 1083 @LazyProperty
1086 1084 def committer_email(self):
1087 1085 """
1088 1086 Returns committer email address for this commit
1089 1087 """
1090 1088
1091 1089 return author_email(self.committer)
1092 1090
1093 1091 @LazyProperty
1094 1092 def author(self):
1095 1093 """
1096 1094 Returns author for this commit
1097 1095 """
1098 1096
1099 1097 raise NotImplementedError
1100 1098
1101 1099 @LazyProperty
1102 1100 def author_name(self):
1103 1101 """
1104 1102 Returns author name for this commit
1105 1103 """
1106 1104
1107 1105 return author_name(self.author)
1108 1106
1109 1107 @LazyProperty
1110 1108 def author_email(self):
1111 1109 """
1112 1110 Returns author email address for this commit
1113 1111 """
1114 1112
1115 1113 return author_email(self.author)
1116 1114
1117 1115 def get_file_mode(self, path: bytes):
1118 1116 """
1119 1117 Returns stat mode of the file at `path`.
1120 1118 """
1121 1119 raise NotImplementedError
1122 1120
1123 1121 def is_link(self, path):
1124 1122 """
1125 1123 Returns ``True`` if given `path` is a symlink
1126 1124 """
1127 1125 raise NotImplementedError
1128 1126
1129 1127 def is_node_binary(self, path):
1130 1128 """
1131 1129 Returns ``True`` is given path is a binary file
1132 1130 """
1133 1131 raise NotImplementedError
1134 1132
1135 1133 def node_md5_hash(self, path):
1136 1134 """
1137 1135 Returns md5 hash of a node data
1138 1136 """
1139 1137 raise NotImplementedError
1140 1138
1141 1139 def get_file_content(self, path) -> bytes:
1142 1140 """
1143 1141 Returns content of the file at the given `path`.
1144 1142 """
1145 1143 raise NotImplementedError
1146 1144
1147 1145 def get_file_content_streamed(self, path):
1148 1146 """
1149 1147 returns a streaming response from vcsserver with file content
1150 1148 """
1151 1149 raise NotImplementedError
1152 1150
1153 1151 def get_file_size(self, path):
1154 1152 """
1155 1153 Returns size of the file at the given `path`.
1156 1154 """
1157 1155 raise NotImplementedError
1158 1156
1159 1157 def get_path_commit(self, path, pre_load=None):
1160 1158 """
1161 1159 Returns last commit of the file at the given `path`.
1162 1160
1163 1161 :param pre_load: Optional. List of commit attributes to load.
1164 1162 """
1165 1163 commits = self.get_path_history(path, limit=1, pre_load=pre_load)
1166 1164 if not commits:
1167 1165 raise RepositoryError(
1168 1166 'Failed to fetch history for path {}. '
1169 1167 'Please check if such path exists in your repository'.format(
1170 1168 path))
1171 1169 return commits[0]
1172 1170
1173 1171 def get_path_history(self, path, limit=None, pre_load=None):
1174 1172 """
1175 1173 Returns history of file as reversed list of :class:`BaseCommit`
1176 1174 objects for which file at given `path` has been modified.
1177 1175
1178 1176 :param limit: Optional. Allows to limit the size of the returned
1179 1177 history. This is intended as a hint to the underlying backend, so
1180 1178 that it can apply optimizations depending on the limit.
1181 1179 :param pre_load: Optional. List of commit attributes to load.
1182 1180 """
1183 1181 raise NotImplementedError
1184 1182
1185 1183 def get_file_annotate(self, path, pre_load=None):
1186 1184 """
1187 1185 Returns a generator of four element tuples with
1188 1186 lineno, sha, commit lazy loader and line
1189 1187
1190 1188 :param pre_load: Optional. List of commit attributes to load.
1191 1189 """
1192 1190 raise NotImplementedError
1193 1191
1194 1192 def get_nodes(self, path, pre_load=None):
1195 1193 """
1196 1194 Returns combined ``DirNode`` and ``FileNode`` objects list representing
1197 1195 state of commit at the given ``path``.
1198 1196
1199 1197 :raises ``CommitError``: if node at the given ``path`` is not
1200 1198 instance of ``DirNode``
1201 1199 """
1202 1200 raise NotImplementedError
1203 1201
1204 1202 def get_node(self, path):
1205 1203 """
1206 1204 Returns ``Node`` object from the given ``path``.
1207 1205
1208 1206 :raises ``NodeDoesNotExistError``: if there is no node at the given
1209 1207 ``path``
1210 1208 """
1211 1209 raise NotImplementedError
1212 1210
1213 1211 def get_largefile_node(self, path):
1214 1212 """
1215 1213 Returns the path to largefile from Mercurial/Git-lfs storage.
1216 1214 or None if it's not a largefile node
1217 1215 """
1218 1216 return None
1219 1217
1220 1218 def archive_repo(self, archive_name_key, kind='tgz', subrepos=None,
1221 1219 archive_dir_name=None, write_metadata=False, mtime=None,
1222 1220 archive_at_path='/', cache_config=None):
1223 1221 """
1224 1222 Creates an archive containing the contents of the repository.
1225 1223
1226 1224 :param archive_name_key: unique key under this archive should be generated
1227 1225 :param kind: one of following: ``"tbz2"``, ``"tgz"``, ``"zip"``.
1228 1226 :param archive_dir_name: name of root directory in archive.
1229 1227 Default is repository name and commit's short_id joined with dash:
1230 1228 ``"{repo_name}-{short_id}"``.
1231 1229 :param write_metadata: write a metadata file into archive.
1232 1230 :param mtime: custom modification time for archive creation, defaults
1233 1231 to time.time() if not given.
1234 1232 :param archive_at_path: pack files at this path (default '/')
1235 1233 :param cache_config: config spec to send to vcsserver to configure the backend to store files
1236 1234
1237 1235 :raise VCSError: If prefix has a problem.
1238 1236 """
1239 1237 cache_config = cache_config or {}
1240 1238 allowed_kinds = [x[0] for x in settings.ARCHIVE_SPECS]
1241 1239 if kind not in allowed_kinds:
1242 1240 raise ImproperArchiveTypeError(
1243 1241 'Archive kind (%s) not supported use one of %s' %
1244 1242 (kind, allowed_kinds))
1245 1243
1246 1244 archive_dir_name = self._validate_archive_prefix(archive_dir_name)
1247 1245 mtime = mtime is not None or time.mktime(self.date.timetuple())
1248 1246 commit_id = self.raw_id
1249 1247
1250 1248 return self.repository._remote.archive_repo(
1251 1249 archive_name_key, kind, mtime, archive_at_path,
1252 1250 archive_dir_name, commit_id, cache_config)
1253 1251
1254 1252 def _validate_archive_prefix(self, archive_dir_name):
1255 1253 if archive_dir_name is None:
1256 1254 archive_dir_name = self._ARCHIVE_PREFIX_TEMPLATE.format(
1257 1255 repo_name=safe_str(self.repository.name),
1258 1256 short_id=self.short_id)
1259 1257 elif not isinstance(archive_dir_name, str):
1260 1258 raise ValueError(f"archive_dir_name is not str object but: {type(archive_dir_name)}")
1261 1259 elif archive_dir_name.startswith('/'):
1262 1260 raise VCSError("Prefix cannot start with leading slash")
1263 1261 elif archive_dir_name.strip() == '':
1264 1262 raise VCSError("Prefix cannot be empty")
1265 1263 elif not archive_dir_name.isascii():
1266 1264 raise VCSError("Prefix cannot contain non ascii characters")
1267 1265 return archive_dir_name
1268 1266
1269 1267 @LazyProperty
1270 1268 def root(self):
1271 1269 """
1272 1270 Returns ``RootNode`` object for this commit.
1273 1271 """
1274 1272 return self.get_node('')
1275 1273
1276 1274 def next(self, branch=None):
1277 1275 """
1278 1276 Returns next commit from current, if branch is gives it will return
1279 1277 next commit belonging to this branch
1280 1278
1281 1279 :param branch: show commits within the given named branch
1282 1280 """
1283 1281 indexes = range(self.idx + 1, self.repository.count())
1284 1282 return self._find_next(indexes, branch)
1285 1283
1286 1284 def prev(self, branch=None):
1287 1285 """
1288 1286 Returns previous commit from current, if branch is gives it will
1289 1287 return previous commit belonging to this branch
1290 1288
1291 1289 :param branch: show commit within the given named branch
1292 1290 """
1293 1291 indexes = range(self.idx - 1, -1, -1)
1294 1292 return self._find_next(indexes, branch)
1295 1293
1296 1294 def _find_next(self, indexes, branch=None):
1297 1295 if branch and self.branch != branch:
1298 1296 raise VCSError('Branch option used on commit not belonging '
1299 1297 'to that branch')
1300 1298
1301 1299 for next_idx in indexes:
1302 1300 commit = self.repository.get_commit(commit_idx=next_idx)
1303 1301 if branch and branch != commit.branch:
1304 1302 continue
1305 1303 return commit
1306 1304 raise CommitDoesNotExistError
1307 1305
1308 1306 def diff(self, ignore_whitespace=True, context=3):
1309 1307 """
1310 1308 Returns a `Diff` object representing the change made by this commit.
1311 1309 """
1312 1310 parent = self.first_parent
1313 1311 diff = self.repository.get_diff(
1314 1312 parent, self,
1315 1313 ignore_whitespace=ignore_whitespace,
1316 1314 context=context)
1317 1315 return diff
1318 1316
1319 1317 @LazyProperty
1320 1318 def added(self):
1321 1319 """
1322 1320 Returns list of added ``FileNode`` objects.
1323 1321 """
1324 1322 raise NotImplementedError
1325 1323
1326 1324 @LazyProperty
1327 1325 def changed(self):
1328 1326 """
1329 1327 Returns list of modified ``FileNode`` objects.
1330 1328 """
1331 1329 raise NotImplementedError
1332 1330
1333 1331 @LazyProperty
1334 1332 def removed(self):
1335 1333 """
1336 1334 Returns list of removed ``FileNode`` objects.
1337 1335 """
1338 1336 raise NotImplementedError
1339 1337
1340 1338 @LazyProperty
1341 1339 def size(self):
1342 1340 """
1343 1341 Returns total number of bytes from contents of all filenodes.
1344 1342 """
1345 1343 return sum(node.size for node in self.get_filenodes_generator())
1346 1344
1347 1345 def walk(self, topurl=''):
1348 1346 """
1349 1347 Similar to os.walk method. Insted of filesystem it walks through
1350 1348 commit starting at given ``topurl``. Returns generator of tuples
1351 1349 (top_node, dirnodes, filenodes).
1352 1350 """
1353 1351 from rhodecode.lib.vcs.nodes import DirNode
1354 1352
1355 1353 if isinstance(topurl, DirNode):
1356 1354 top_node = topurl
1357 1355 else:
1358 1356 top_node = self.get_node(topurl)
1359 1357
1360 1358 has_default_pre_load = False
1361 1359 if isinstance(top_node, DirNode):
1362 1360 # used to inject as we walk same defaults as given top_node
1363 1361 default_pre_load = top_node.default_pre_load
1364 1362 has_default_pre_load = True
1365 1363
1366 1364 if not top_node.is_dir():
1367 1365 return
1368 1366 yield top_node, top_node.dirs, top_node.files
1369 1367 for dir_node in top_node.dirs:
1370 1368 if has_default_pre_load:
1371 1369 dir_node.default_pre_load = default_pre_load
1372 1370 yield from self.walk(dir_node)
1373 1371
1374 1372 def get_filenodes_generator(self):
1375 1373 """
1376 1374 Returns generator that yields *all* file nodes.
1377 1375 """
1378 1376 for topnode, dirs, files in self.walk():
1379 1377 yield from files
1380 1378
1381 1379 #
1382 1380 # Utilities for sub classes to support consistent behavior
1383 1381 #
1384 1382
1385 1383 def no_node_at_path(self, path):
1386 1384 return NodeDoesNotExistError(
1387 1385 f"There is no file nor directory at the given path: "
1388 1386 f"`{safe_str(path)}` at commit {self.short_id}")
1389 1387
1390 1388 def _fix_path(self, path: str) -> str:
1391 1389 """
1392 1390 Paths are stored without trailing slash so we need to get rid off it if
1393 1391 needed.
1394 1392 """
1395 1393 return safe_str(path).rstrip('/')
1396 1394
1397 1395 #
1398 1396 # Deprecated API based on changesets
1399 1397 #
1400 1398
1401 1399 @property
1402 1400 def revision(self):
1403 1401 warnings.warn("Use idx instead", DeprecationWarning)
1404 1402 return self.idx
1405 1403
1406 1404 @revision.setter
1407 1405 def revision(self, value):
1408 1406 warnings.warn("Use idx instead", DeprecationWarning)
1409 1407 self.idx = value
1410 1408
1411 1409 def get_file_changeset(self, path):
1412 1410 warnings.warn("Use get_path_commit instead", DeprecationWarning)
1413 1411 return self.get_path_commit(path)
1414 1412
1415 1413
1416 1414 class BaseChangesetClass(type):
1417 1415
1418 1416 def __instancecheck__(self, instance):
1419 1417 return isinstance(instance, BaseCommit)
1420 1418
1421 1419
1422 1420 class BaseChangeset(BaseCommit, metaclass=BaseChangesetClass):
1423 1421
1424 1422 def __new__(cls, *args, **kwargs):
1425 1423 warnings.warn(
1426 1424 "Use BaseCommit instead of BaseChangeset", DeprecationWarning)
1427 1425 return super().__new__(cls, *args, **kwargs)
1428 1426
1429 1427
1430 1428 class BaseInMemoryCommit(object):
1431 1429 """
1432 1430 Represents differences between repository's state (most recent head) and
1433 1431 changes made *in place*.
1434 1432
1435 1433 **Attributes**
1436 1434
1437 1435 ``repository``
1438 1436 repository object for this in-memory-commit
1439 1437
1440 1438 ``added``
1441 1439 list of ``FileNode`` objects marked as *added*
1442 1440
1443 1441 ``changed``
1444 1442 list of ``FileNode`` objects marked as *changed*
1445 1443
1446 1444 ``removed``
1447 1445 list of ``FileNode`` or ``RemovedFileNode`` objects marked to be
1448 1446 *removed*
1449 1447
1450 1448 ``parents``
1451 1449 list of :class:`BaseCommit` instances representing parents of
1452 1450 in-memory commit. Should always be 2-element sequence.
1453 1451
1454 1452 """
1455 1453
1456 1454 def __init__(self, repository):
1457 1455 self.repository = repository
1458 1456 self.added = []
1459 1457 self.changed = []
1460 1458 self.removed = []
1461 1459 self.parents = []
1462 1460
1463 1461 def add(self, *filenodes):
1464 1462 """
1465 1463 Marks given ``FileNode`` objects as *to be committed*.
1466 1464
1467 1465 :raises ``NodeAlreadyExistsError``: if node with same path exists at
1468 1466 latest commit
1469 1467 :raises ``NodeAlreadyAddedError``: if node with same path is already
1470 1468 marked as *added*
1471 1469 """
1472 1470 # Check if not already marked as *added* first
1473 1471 for node in filenodes:
1474 1472 if node.path in (n.path for n in self.added):
1475 1473 raise NodeAlreadyAddedError(
1476 1474 "Such FileNode %s is already marked for addition"
1477 1475 % node.path)
1478 1476 for node in filenodes:
1479 1477 self.added.append(node)
1480 1478
1481 1479 def change(self, *filenodes):
1482 1480 """
1483 1481 Marks given ``FileNode`` objects to be *changed* in next commit.
1484 1482
1485 1483 :raises ``EmptyRepositoryError``: if there are no commits yet
1486 1484 :raises ``NodeAlreadyExistsError``: if node with same path is already
1487 1485 marked to be *changed*
1488 1486 :raises ``NodeAlreadyRemovedError``: if node with same path is already
1489 1487 marked to be *removed*
1490 1488 :raises ``NodeDoesNotExistError``: if node doesn't exist in latest
1491 1489 commit
1492 1490 :raises ``NodeNotChangedError``: if node hasn't really be changed
1493 1491 """
1494 1492 for node in filenodes:
1495 1493 if node.path in (n.path for n in self.removed):
1496 1494 raise NodeAlreadyRemovedError(
1497 1495 "Node at %s is already marked as removed" % node.path)
1498 1496 try:
1499 1497 self.repository.get_commit()
1500 1498 except EmptyRepositoryError:
1501 1499 raise EmptyRepositoryError(
1502 1500 "Nothing to change - try to *add* new nodes rather than "
1503 1501 "changing them")
1504 1502 for node in filenodes:
1505 1503 if node.path in (n.path for n in self.changed):
1506 1504 raise NodeAlreadyChangedError(
1507 1505 "Node at '%s' is already marked as changed" % node.path)
1508 1506 self.changed.append(node)
1509 1507
1510 1508 def remove(self, *filenodes):
1511 1509 """
1512 1510 Marks given ``FileNode`` (or ``RemovedFileNode``) objects to be
1513 1511 *removed* in next commit.
1514 1512
1515 1513 :raises ``NodeAlreadyRemovedError``: if node has been already marked to
1516 1514 be *removed*
1517 1515 :raises ``NodeAlreadyChangedError``: if node has been already marked to
1518 1516 be *changed*
1519 1517 """
1520 1518 for node in filenodes:
1521 1519 if node.path in (n.path for n in self.removed):
1522 1520 raise NodeAlreadyRemovedError(
1523 1521 "Node is already marked to for removal at %s" % node.path)
1524 1522 if node.path in (n.path for n in self.changed):
1525 1523 raise NodeAlreadyChangedError(
1526 1524 "Node is already marked to be changed at %s" % node.path)
1527 1525 # We only mark node as *removed* - real removal is done by
1528 1526 # commit method
1529 1527 self.removed.append(node)
1530 1528
1531 1529 def reset(self):
1532 1530 """
1533 1531 Resets this instance to initial state (cleans ``added``, ``changed``
1534 1532 and ``removed`` lists).
1535 1533 """
1536 1534 self.added = []
1537 1535 self.changed = []
1538 1536 self.removed = []
1539 1537 self.parents = []
1540 1538
1541 1539 def get_ipaths(self):
1542 1540 """
1543 1541 Returns generator of paths from nodes marked as added, changed or
1544 1542 removed.
1545 1543 """
1546 1544 for node in itertools.chain(self.added, self.changed, self.removed):
1547 1545 yield node.path
1548 1546
1549 1547 def get_paths(self):
1550 1548 """
1551 1549 Returns list of paths from nodes marked as added, changed or removed.
1552 1550 """
1553 1551 return list(self.get_ipaths())
1554 1552
1555 1553 def check_integrity(self, parents=None):
1556 1554 """
1557 1555 Checks in-memory commit's integrity. Also, sets parents if not
1558 1556 already set.
1559 1557
1560 1558 :raises CommitError: if any error occurs (i.e.
1561 1559 ``NodeDoesNotExistError``).
1562 1560 """
1563 1561 if not self.parents:
1564 1562 parents = parents or []
1565 1563 if len(parents) == 0:
1566 1564 try:
1567 1565 parents = [self.repository.get_commit(), None]
1568 1566 except EmptyRepositoryError:
1569 1567 parents = [None, None]
1570 1568 elif len(parents) == 1:
1571 1569 parents += [None]
1572 1570 self.parents = parents
1573 1571
1574 1572 # Local parents, only if not None
1575 1573 parents = [p for p in self.parents if p]
1576 1574
1577 1575 # Check nodes marked as added
1578 1576 for p in parents:
1579 1577 for node in self.added:
1580 1578 try:
1581 1579 p.get_node(node.path)
1582 1580 except NodeDoesNotExistError:
1583 1581 pass
1584 1582 else:
1585 1583 raise NodeAlreadyExistsError(
1586 "Node `{}` already exists at {}".format(node.path, p))
1584 f"Node `{node.path}` already exists at {p}")
1587 1585
1588 1586 # Check nodes marked as changed
1589 1587 missing = set(self.changed)
1590 1588 not_changed = set(self.changed)
1591 1589 if self.changed and not parents:
1592 1590 raise NodeDoesNotExistError(str(self.changed[0].path))
1593 1591 for p in parents:
1594 1592 for node in self.changed:
1595 1593 try:
1596 1594 old = p.get_node(node.path)
1597 1595 missing.remove(node)
1598 1596 # if content actually changed, remove node from not_changed
1599 1597 if old.content != node.content:
1600 1598 not_changed.remove(node)
1601 1599 except NodeDoesNotExistError:
1602 1600 pass
1603 1601 if self.changed and missing:
1604 1602 raise NodeDoesNotExistError(
1605 1603 "Node `%s` marked as modified but missing in parents: %s"
1606 1604 % (node.path, parents))
1607 1605
1608 1606 if self.changed and not_changed:
1609 1607 raise NodeNotChangedError(
1610 1608 "Node `%s` wasn't actually changed (parents: %s)"
1611 1609 % (not_changed.pop().path, parents))
1612 1610
1613 1611 # Check nodes marked as removed
1614 1612 if self.removed and not parents:
1615 1613 raise NodeDoesNotExistError(
1616 1614 "Cannot remove node at %s as there "
1617 1615 "were no parents specified" % self.removed[0].path)
1618 1616 really_removed = set()
1619 1617 for p in parents:
1620 1618 for node in self.removed:
1621 1619 try:
1622 1620 p.get_node(node.path)
1623 1621 really_removed.add(node)
1624 1622 except CommitError:
1625 1623 pass
1626 1624 not_removed = set(self.removed) - really_removed
1627 1625 if not_removed:
1628 1626 # TODO: johbo: This code branch does not seem to be covered
1629 1627 raise NodeDoesNotExistError(
1630 1628 "Cannot remove node at %s from "
1631 1629 "following parents: %s" % (not_removed, parents))
1632 1630
1633 1631 def commit(self, message, author, parents=None, branch=None, date=None, **kwargs):
1634 1632 """
1635 1633 Performs in-memory commit (doesn't check workdir in any way) and
1636 1634 returns newly created :class:`BaseCommit`. Updates repository's
1637 1635 attribute `commits`.
1638 1636
1639 1637 .. note::
1640 1638
1641 1639 While overriding this method each backend's should call
1642 1640 ``self.check_integrity(parents)`` in the first place.
1643 1641
1644 1642 :param message: message of the commit
1645 1643 :param author: full username, i.e. "Joe Doe <joe.doe@example.com>"
1646 1644 :param parents: single parent or sequence of parents from which commit
1647 1645 would be derived
1648 1646 :param date: ``datetime.datetime`` instance. Defaults to
1649 1647 ``datetime.datetime.now()``.
1650 1648 :param branch: branch name, as string. If none given, default backend's
1651 1649 branch would be used.
1652 1650
1653 1651 :raises ``CommitError``: if any error occurs while committing
1654 1652 """
1655 1653 raise NotImplementedError
1656 1654
1657 1655
1658 1656 class BaseInMemoryChangesetClass(type):
1659 1657
1660 1658 def __instancecheck__(self, instance):
1661 1659 return isinstance(instance, BaseInMemoryCommit)
1662 1660
1663 1661
1664 1662 class BaseInMemoryChangeset(BaseInMemoryCommit, metaclass=BaseInMemoryChangesetClass):
1665 1663
1666 1664 def __new__(cls, *args, **kwargs):
1667 1665 warnings.warn(
1668 1666 "Use BaseCommit instead of BaseInMemoryCommit", DeprecationWarning)
1669 1667 return super().__new__(cls, *args, **kwargs)
1670 1668
1671 1669
1672 1670 class EmptyCommit(BaseCommit):
1673 1671 """
1674 1672 An dummy empty commit. It's possible to pass hash when creating
1675 1673 an EmptyCommit
1676 1674 """
1677 1675
1678 1676 def __init__(
1679 1677 self, commit_id=EMPTY_COMMIT_ID, repo=None, alias=None, idx=-1,
1680 1678 message='', author='', date=None):
1681 1679 self._empty_commit_id = commit_id
1682 1680 # TODO: johbo: Solve idx parameter, default value does not make
1683 1681 # too much sense
1684 1682 self.idx = idx
1685 1683 self.message = message
1686 1684 self.author = author
1687 1685 self.date = date or datetime.datetime.fromtimestamp(0)
1688 1686 self.repository = repo
1689 1687 self.alias = alias
1690 1688
1691 1689 @LazyProperty
1692 1690 def raw_id(self):
1693 1691 """
1694 1692 Returns raw string identifying this commit, useful for web
1695 1693 representation.
1696 1694 """
1697 1695
1698 1696 return self._empty_commit_id
1699 1697
1700 1698 @LazyProperty
1701 1699 def branch(self):
1702 1700 if self.alias:
1703 1701 from rhodecode.lib.vcs.backends import get_backend
1704 1702 return get_backend(self.alias).DEFAULT_BRANCH_NAME
1705 1703
1706 1704 @LazyProperty
1707 1705 def short_id(self):
1708 1706 return self.raw_id[:12]
1709 1707
1710 1708 @LazyProperty
1711 1709 def id(self):
1712 1710 return self.raw_id
1713 1711
1714 1712 def get_path_commit(self, path, pre_load=None):
1715 1713 return self
1716 1714
1717 1715 def get_file_content(self, path) -> bytes:
1718 1716 return b''
1719 1717
1720 1718 def get_file_content_streamed(self, path):
1721 1719 yield self.get_file_content(path)
1722 1720
1723 1721 def get_file_size(self, path):
1724 1722 return 0
1725 1723
1726 1724
1727 1725 class EmptyChangesetClass(type):
1728 1726
1729 1727 def __instancecheck__(self, instance):
1730 1728 return isinstance(instance, EmptyCommit)
1731 1729
1732 1730
1733 1731 class EmptyChangeset(EmptyCommit, metaclass=EmptyChangesetClass):
1734 1732
1735 1733 def __new__(cls, *args, **kwargs):
1736 1734 warnings.warn(
1737 1735 "Use EmptyCommit instead of EmptyChangeset", DeprecationWarning)
1738 1736 return super(EmptyCommit, cls).__new__(cls, *args, **kwargs)
1739 1737
1740 1738 def __init__(self, cs=EMPTY_COMMIT_ID, repo=None, requested_revision=None,
1741 1739 alias=None, revision=-1, message='', author='', date=None):
1742 1740 if requested_revision is not None:
1743 1741 warnings.warn(
1744 1742 "Parameter requested_revision not supported anymore",
1745 1743 DeprecationWarning)
1746 1744 super().__init__(
1747 1745 commit_id=cs, repo=repo, alias=alias, idx=revision,
1748 1746 message=message, author=author, date=date)
1749 1747
1750 1748 @property
1751 1749 def revision(self):
1752 1750 warnings.warn("Use idx instead", DeprecationWarning)
1753 1751 return self.idx
1754 1752
1755 1753 @revision.setter
1756 1754 def revision(self, value):
1757 1755 warnings.warn("Use idx instead", DeprecationWarning)
1758 1756 self.idx = value
1759 1757
1760 1758
1761 1759 class EmptyRepository(BaseRepository):
1762 1760 def __init__(self, repo_path=None, config=None, create=False, **kwargs):
1763 1761 pass
1764 1762
1765 1763 def get_diff(self, *args, **kwargs):
1766 1764 from rhodecode.lib.vcs.backends.git.diff import GitDiff
1767 1765 return GitDiff(b'')
1768 1766
1769 1767
1770 1768 class CollectionGenerator(object):
1771 1769
1772 1770 def __init__(self, repo, commit_ids, collection_size=None, pre_load=None, translate_tag=None):
1773 1771 self.repo = repo
1774 1772 self.commit_ids = commit_ids
1775 1773 self.collection_size = collection_size
1776 1774 self.pre_load = pre_load
1777 1775 self.translate_tag = translate_tag
1778 1776
1779 1777 def __len__(self):
1780 1778 if self.collection_size is not None:
1781 1779 return self.collection_size
1782 1780 return self.commit_ids.__len__()
1783 1781
1784 1782 def __iter__(self):
1785 1783 for commit_id in self.commit_ids:
1786 1784 # TODO: johbo: Mercurial passes in commit indices or commit ids
1787 1785 yield self._commit_factory(commit_id)
1788 1786
1789 1787 def _commit_factory(self, commit_id):
1790 1788 """
1791 1789 Allows backends to override the way commits are generated.
1792 1790 """
1793 1791 return self.repo.get_commit(
1794 1792 commit_id=commit_id, pre_load=self.pre_load,
1795 1793 translate_tag=self.translate_tag)
1796 1794
1797 1795 def __getitem__(self, key):
1798 1796 """Return either a single element by index, or a sliced collection."""
1799 1797
1800 1798 if isinstance(key, slice):
1801 1799 commit_ids = self.commit_ids[key.start:key.stop]
1802 1800
1803 1801 else:
1804 1802 # single item
1805 1803 commit_ids = self.commit_ids[key]
1806 1804
1807 1805 return self.__class__(
1808 1806 self.repo, commit_ids, pre_load=self.pre_load,
1809 1807 translate_tag=self.translate_tag)
1810 1808
1811 1809 def __repr__(self):
1812 1810 return '<CollectionGenerator[len:%s]>' % (self.__len__())
1813 1811
1814 1812
1815 1813 class Config(object):
1816 1814 """
1817 1815 Represents the configuration for a repository.
1818 1816
1819 1817 The API is inspired by :class:`ConfigParser.ConfigParser` from the
1820 1818 standard library. It implements only the needed subset.
1821 1819 """
1822 1820
1823 1821 def __init__(self):
1824 1822 self._values = {}
1825 1823
1826 1824 def copy(self):
1827 1825 clone = Config()
1828 1826 for section, values in self._values.items():
1829 1827 clone._values[section] = values.copy()
1830 1828 return clone
1831 1829
1832 1830 def __repr__(self):
1833 1831 return '<Config({} sections) at {}>'.format(
1834 1832 len(self._values), hex(id(self)))
1835 1833
1836 1834 def items(self, section):
1837 1835 return self._values.get(section, {}).items()
1838 1836
1839 1837 def get(self, section, option):
1840 1838 return self._values.get(section, {}).get(option)
1841 1839
1842 1840 def set(self, section, option, value):
1843 1841 section_values = self._values.setdefault(section, {})
1844 1842 section_values[option] = value
1845 1843
1846 1844 def clear_section(self, section):
1847 1845 self._values[section] = {}
1848 1846
1849 1847 def serialize(self):
1850 1848 """
1851 1849 Creates a list of three tuples (section, key, value) representing
1852 1850 this config object.
1853 1851 """
1854 1852 items = []
1855 1853 for section in self._values:
1856 1854 for option, value in self._values[section].items():
1857 1855 items.append(
1858 1856 (safe_str(section), safe_str(option), safe_str(value)))
1859 1857 return items
1860 1858
1861 1859
1862 1860 class Diff(object):
1863 1861 """
1864 1862 Represents a diff result from a repository backend.
1865 1863
1866 1864 Subclasses have to provide a backend specific value for
1867 1865 :attr:`_header_re` and :attr:`_meta_re`.
1868 1866 """
1869 1867 _meta_re = None
1870 1868 _header_re: bytes = re.compile(br"")
1871 1869
1872 1870 def __init__(self, raw_diff: bytes):
1873 1871 if not isinstance(raw_diff, bytes):
1874 1872 raise Exception(f'raw_diff must be bytes - got {type(raw_diff)}')
1875 1873
1876 1874 self.raw = memoryview(raw_diff)
1877 1875
1878 1876 def get_header_re(self):
1879 1877 return self._header_re
1880 1878
1881 1879 def chunks(self):
1882 1880 """
1883 1881 split the diff in chunks of separate --git a/file b/file chunks
1884 1882 to make diffs consistent we must prepend with \n, and make sure
1885 1883 we can detect last chunk as this was also has special rule
1886 1884 """
1887 1885
1888 1886 diff_parts = (b'\n' + bytes(self.raw)).split(b'\ndiff --git')
1889 1887
1890 1888 chunks = diff_parts[1:]
1891 1889 total_chunks = len(chunks)
1892 1890
1893 1891 def diff_iter(_chunks):
1894 1892 for cur_chunk, chunk in enumerate(_chunks, start=1):
1895 1893 yield DiffChunk(chunk, self, cur_chunk == total_chunks)
1896 1894 return diff_iter(chunks)
1897 1895
1898 1896
1899 1897 class DiffChunk(object):
1900 1898
1901 1899 def __init__(self, chunk: bytes, diff_obj: Diff, is_last_chunk: bool):
1902 1900 self.diff_obj = diff_obj
1903 1901
1904 1902 # since we split by \ndiff --git that part is lost from original diff
1905 1903 # we need to re-apply it at the end, EXCEPT ! if it's last chunk
1906 1904 if not is_last_chunk:
1907 1905 chunk += b'\n'
1908 1906 header_re = self.diff_obj.get_header_re()
1909 1907 match = header_re.match(chunk)
1910 1908 self.header = match.groupdict()
1911 1909 self.diff = chunk[match.end():]
1912 1910 self.raw = chunk
1913 1911
1914 1912 @property
1915 1913 def header_as_str(self):
1916 1914 if self.header:
1917 1915 def safe_str_on_bytes(val):
1918 1916 if isinstance(val, bytes):
1919 1917 return safe_str(val)
1920 1918 return val
1921 1919 return {safe_str(k): safe_str_on_bytes(v) for k, v in self.header.items()}
1922 1920
1923 1921 def __repr__(self):
1924 1922 return f'DiffChunk({self.header_as_str})'
1925 1923
1926 1924
1927 1925 class BasePathPermissionChecker(object):
1928 1926
1929 1927 @staticmethod
1930 1928 def create_from_patterns(includes, excludes):
1931 1929 if includes and '*' in includes and not excludes:
1932 1930 return AllPathPermissionChecker()
1933 1931 elif excludes and '*' in excludes:
1934 1932 return NonePathPermissionChecker()
1935 1933 else:
1936 1934 return PatternPathPermissionChecker(includes, excludes)
1937 1935
1938 1936 @property
1939 1937 def has_full_access(self):
1940 1938 raise NotImplementedError()
1941 1939
1942 1940 def has_access(self, path):
1943 1941 raise NotImplementedError()
1944 1942
1945 1943
1946 1944 class AllPathPermissionChecker(BasePathPermissionChecker):
1947 1945
1948 1946 @property
1949 1947 def has_full_access(self):
1950 1948 return True
1951 1949
1952 1950 def has_access(self, path):
1953 1951 return True
1954 1952
1955 1953
1956 1954 class NonePathPermissionChecker(BasePathPermissionChecker):
1957 1955
1958 1956 @property
1959 1957 def has_full_access(self):
1960 1958 return False
1961 1959
1962 1960 def has_access(self, path):
1963 1961 return False
1964 1962
1965 1963
1966 1964 class PatternPathPermissionChecker(BasePathPermissionChecker):
1967 1965
1968 1966 def __init__(self, includes, excludes):
1969 1967 self.includes = includes
1970 1968 self.excludes = excludes
1971 1969 self.includes_re = [] if not includes else [
1972 1970 re.compile(fnmatch.translate(pattern)) for pattern in includes]
1973 1971 self.excludes_re = [] if not excludes else [
1974 1972 re.compile(fnmatch.translate(pattern)) for pattern in excludes]
1975 1973
1976 1974 @property
1977 1975 def has_full_access(self):
1978 1976 return '*' in self.includes and not self.excludes
1979 1977
1980 1978 def has_access(self, path):
1981 1979 for regex in self.excludes_re:
1982 1980 if regex.match(path):
1983 1981 return False
1984 1982 for regex in self.includes_re:
1985 1983 if regex.match(path):
1986 1984 return True
1987 1985 return False
@@ -1,1053 +1,1053 b''
1 1 # Copyright (C) 2014-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
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 Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 """
20 20 GIT repository module
21 21 """
22 22
23 23 import logging
24 24 import os
25 25 import re
26 26
27 27 from zope.cachedescriptors.property import Lazy as LazyProperty
28 28
29 29 from collections import OrderedDict
30 30 from rhodecode.lib.datelib import (
31 31 utcdate_fromtimestamp, makedate, date_astimestamp)
32 32 from rhodecode.lib.hash_utils import safe_str
33 33 from rhodecode.lib.utils2 import CachedProperty
34 34 from rhodecode.lib.vcs import connection, path as vcspath
35 35 from rhodecode.lib.vcs.backends.base import (
36 36 BaseRepository, CollectionGenerator, Config, MergeResponse,
37 37 MergeFailureReason, Reference)
38 38 from rhodecode.lib.vcs.backends.git.commit import GitCommit
39 39 from rhodecode.lib.vcs.backends.git.diff import GitDiff
40 40 from rhodecode.lib.vcs.backends.git.inmemory import GitInMemoryCommit
41 41 from rhodecode.lib.vcs.exceptions import (
42 42 CommitDoesNotExistError, EmptyRepositoryError,
43 43 RepositoryError, TagAlreadyExistError, TagDoesNotExistError, VCSError, UnresolvedFilesInRepo)
44 44
45 45
46 46 SHA_PATTERN = re.compile(r'^([0-9a-fA-F]{12}|[0-9a-fA-F]{40})$')
47 47
48 48 log = logging.getLogger(__name__)
49 49
50 50
51 51 class GitRepository(BaseRepository):
52 52 """
53 53 Git repository backend.
54 54 """
55 55 DEFAULT_BRANCH_NAME = os.environ.get('GIT_DEFAULT_BRANCH_NAME') or 'master'
56 56 DEFAULT_REF = f'branch:{DEFAULT_BRANCH_NAME}'
57 57
58 58 contact = BaseRepository.DEFAULT_CONTACT
59 59
60 60 def __init__(self, repo_path, config=None, create=False, src_url=None,
61 61 do_workspace_checkout=False, with_wire=None, bare=False):
62 62
63 63 self.path = safe_str(os.path.abspath(repo_path))
64 64 self.config = config if config else self.get_default_config()
65 65 self.with_wire = with_wire or {"cache": False} # default should not use cache
66 66
67 67 self._init_repo(create, src_url, do_workspace_checkout, bare)
68 68
69 69 # caches
70 70 self._commit_ids = {}
71 71
72 72 @LazyProperty
73 73 def _remote(self):
74 74 repo_id = self.path
75 75 return connection.Git(self.path, repo_id, self.config, with_wire=self.with_wire)
76 76
77 77 @LazyProperty
78 78 def bare(self):
79 79 return self._remote.bare()
80 80
81 81 @LazyProperty
82 82 def head(self):
83 83 return self._remote.head()
84 84
85 85 @CachedProperty
86 86 def commit_ids(self):
87 87 """
88 88 Returns list of commit ids, in ascending order. Being lazy
89 89 attribute allows external tools to inject commit ids from cache.
90 90 """
91 91 commit_ids = self._get_all_commit_ids()
92 92 self._rebuild_cache(commit_ids)
93 93 return commit_ids
94 94
95 95 def _rebuild_cache(self, commit_ids):
96 96 self._commit_ids = {commit_id: index
97 97 for index, commit_id in enumerate(commit_ids)}
98 98
99 99 def run_git_command(self, cmd, **opts):
100 100 """
101 101 Runs given ``cmd`` as git command and returns tuple
102 102 (stdout, stderr).
103 103
104 104 :param cmd: git command to be executed
105 105 :param opts: env options to pass into Subprocess command
106 106 """
107 107 if not isinstance(cmd, list):
108 108 raise ValueError(f'cmd must be a list, got {type(cmd)} instead')
109 109
110 110 skip_stderr_log = opts.pop('skip_stderr_log', False)
111 111 out, err = self._remote.run_git_command(cmd, **opts)
112 112 if err and not skip_stderr_log:
113 113 log.debug('Stderr output of git command "%s":\n%s', cmd, err)
114 114 return out, err
115 115
116 116 @staticmethod
117 117 def check_url(url, config):
118 118 """
119 119 Function will check given url and try to verify if it's a valid
120 120 link. Sometimes it may happened that git will issue basic
121 121 auth request that can cause whole API to hang when used from python
122 122 or other external calls.
123 123
124 124 On failures it'll raise urllib2.HTTPError, exception is also thrown
125 125 when the return code is non 200
126 126 """
127 127 # check first if it's not an url
128 128 if os.path.isdir(url) or url.startswith('file:'):
129 129 return True
130 130
131 131 if '+' in url.split('://', 1)[0]:
132 132 url = url.split('+', 1)[1]
133 133
134 134 # Request the _remote to verify the url
135 135 return connection.Git.check_url(url, config.serialize())
136 136
137 137 @staticmethod
138 138 def is_valid_repository(path):
139 139 if os.path.isdir(os.path.join(path, '.git')):
140 140 return True
141 141 # check case of bare repository
142 142 try:
143 143 GitRepository(path)
144 144 return True
145 145 except VCSError:
146 146 pass
147 147 return False
148 148
149 149 def _init_repo(self, create, src_url=None, do_workspace_checkout=False,
150 150 bare=False):
151 151 if create and os.path.exists(self.path):
152 152 raise RepositoryError(
153 153 "Cannot create repository at %s, location already exist"
154 154 % self.path)
155 155
156 156 if bare and do_workspace_checkout:
157 157 raise RepositoryError("Cannot update a bare repository")
158 158 try:
159 159
160 160 if src_url:
161 161 # check URL before any actions
162 162 GitRepository.check_url(src_url, self.config)
163 163
164 164 if create:
165 165 os.makedirs(self.path, mode=0o755)
166 166
167 167 if bare:
168 168 self._remote.init_bare()
169 169 else:
170 170 self._remote.init()
171 171
172 172 if src_url and bare:
173 173 # bare repository only allows a fetch and checkout is not allowed
174 174 self.fetch(src_url, commit_ids=None)
175 175 elif src_url:
176 176 self.pull(src_url, commit_ids=None,
177 177 update_after=do_workspace_checkout)
178 178
179 179 else:
180 180 if not self._remote.assert_correct_path():
181 181 raise RepositoryError(
182 182 'Path "%s" does not contain a Git repository' %
183 183 (self.path,))
184 184
185 185 # TODO: johbo: check if we have to translate the OSError here
186 186 except OSError as err:
187 187 raise RepositoryError(err)
188 188
189 189 def _get_all_commit_ids(self):
190 190 return self._remote.get_all_commit_ids()
191 191
192 192 def _get_commit_ids(self, filters=None):
193 193 # we must check if this repo is not empty, since later command
194 194 # fails if it is. And it's cheaper to ask than throw the subprocess
195 195 # errors
196 196
197 197 head = self._remote.head(show_exc=False)
198 198
199 199 if not head:
200 200 return []
201 201
202 202 rev_filter = ['--branches', '--tags']
203 203 extra_filter = []
204 204
205 205 if filters:
206 206 if filters.get('since'):
207 207 extra_filter.append('--since=%s' % (filters['since']))
208 208 if filters.get('until'):
209 209 extra_filter.append('--until=%s' % (filters['until']))
210 210 if filters.get('branch_name'):
211 211 rev_filter = []
212 212 extra_filter.append(filters['branch_name'])
213 213 rev_filter.extend(extra_filter)
214 214
215 215 # if filters.get('start') or filters.get('end'):
216 216 # # skip is offset, max-count is limit
217 217 # if filters.get('start'):
218 218 # extra_filter += ' --skip=%s' % filters['start']
219 219 # if filters.get('end'):
220 220 # extra_filter += ' --max-count=%s' % (filters['end'] - (filters['start'] or 0))
221 221
222 222 cmd = ['rev-list', '--reverse', '--date-order'] + rev_filter
223 223 try:
224 224 output, __ = self.run_git_command(cmd)
225 225 except RepositoryError:
226 226 # Can be raised for empty repositories
227 227 return []
228 228 return output.splitlines()
229 229
230 230 def _lookup_commit(self, commit_id_or_idx, translate_tag=True, maybe_unreachable=False, reference_obj=None):
231 231
232 232 def is_null(value):
233 233 return len(value) == commit_id_or_idx.count('0')
234 234
235 235 if commit_id_or_idx in (None, '', 'tip', 'HEAD', 'head', -1):
236 236 return self.commit_ids[-1]
237 237
238 238 commit_missing_err = "Commit {} does not exist for `{}`".format(
239 239 *map(safe_str, [commit_id_or_idx, self.name]))
240 240
241 241 is_bstr = isinstance(commit_id_or_idx, str)
242 242 is_branch = reference_obj and reference_obj.branch
243 243
244 244 lookup_ok = False
245 245 if is_bstr:
246 246 # Need to call remote to translate id for tagging scenarios,
247 247 # or branch that are numeric
248 248 try:
249 249 remote_data = self._remote.get_object(commit_id_or_idx,
250 250 maybe_unreachable=maybe_unreachable)
251 251 commit_id_or_idx = remote_data["commit_id"]
252 252 lookup_ok = True
253 253 except (CommitDoesNotExistError,):
254 254 lookup_ok = False
255 255
256 256 if lookup_ok is False:
257 257 is_numeric_idx = \
258 258 (is_bstr and commit_id_or_idx.isdigit() and len(commit_id_or_idx) < 12) \
259 259 or isinstance(commit_id_or_idx, int)
260 260 if not is_branch and (is_numeric_idx or is_null(commit_id_or_idx)):
261 261 try:
262 262 commit_id_or_idx = self.commit_ids[int(commit_id_or_idx)]
263 263 lookup_ok = True
264 264 except Exception:
265 265 raise CommitDoesNotExistError(commit_missing_err)
266 266
267 267 # we failed regular lookup, and by integer number lookup
268 268 if lookup_ok is False:
269 269 raise CommitDoesNotExistError(commit_missing_err)
270 270
271 271 # Ensure we return full id
272 272 if not SHA_PATTERN.match(str(commit_id_or_idx)):
273 273 raise CommitDoesNotExistError(
274 274 "Given commit id %s not recognized" % commit_id_or_idx)
275 275 return commit_id_or_idx
276 276
277 277 def get_hook_location(self):
278 278 """
279 279 returns absolute path to location where hooks are stored
280 280 """
281 281 loc = os.path.join(self.path, 'hooks')
282 282 if not self.bare:
283 283 loc = os.path.join(self.path, '.git', 'hooks')
284 284 return loc
285 285
286 286 @LazyProperty
287 287 def last_change(self):
288 288 """
289 289 Returns last change made on this repository as
290 290 `datetime.datetime` object.
291 291 """
292 292 try:
293 293 return self.get_commit().date
294 294 except RepositoryError:
295 295 tzoffset = makedate()[1]
296 296 return utcdate_fromtimestamp(self._get_fs_mtime(), tzoffset)
297 297
298 298 def _get_fs_mtime(self):
299 299 idx_loc = '' if self.bare else '.git'
300 300 # fallback to filesystem
301 301 in_path = os.path.join(self.path, idx_loc, "index")
302 302 he_path = os.path.join(self.path, idx_loc, "HEAD")
303 303 if os.path.exists(in_path):
304 304 return os.stat(in_path).st_mtime
305 305 else:
306 306 return os.stat(he_path).st_mtime
307 307
308 308 @LazyProperty
309 309 def description(self):
310 310 description = self._remote.get_description()
311 311 return safe_str(description or self.DEFAULT_DESCRIPTION)
312 312
313 313 def _get_refs_entries(self, prefix='', reverse=False, strip_prefix=True):
314 314 if self.is_empty():
315 315 return OrderedDict()
316 316
317 317 result = []
318 318 for ref, sha in self._refs.items():
319 319 if ref.startswith(prefix):
320 320 ref_name = ref
321 321 if strip_prefix:
322 322 ref_name = ref[len(prefix):]
323 323 result.append((safe_str(ref_name), sha))
324 324
325 325 def get_name(entry):
326 326 return entry[0]
327 327
328 328 return OrderedDict(sorted(result, key=get_name, reverse=reverse))
329 329
330 330 def _get_branches(self):
331 331 return self._get_refs_entries(prefix='refs/heads/', strip_prefix=True)
332 332
333 333 @CachedProperty
334 334 def branches(self):
335 335 return self._get_branches()
336 336
337 337 @CachedProperty
338 338 def branches_closed(self):
339 339 return {}
340 340
341 341 @CachedProperty
342 342 def bookmarks(self):
343 343 return {}
344 344
345 345 @CachedProperty
346 346 def branches_all(self):
347 347 all_branches = {}
348 348 all_branches.update(self.branches)
349 349 all_branches.update(self.branches_closed)
350 350 return all_branches
351 351
352 352 @CachedProperty
353 353 def tags(self):
354 354 return self._get_tags()
355 355
356 356 def _get_tags(self):
357 357 return self._get_refs_entries(prefix='refs/tags/', strip_prefix=True, reverse=True)
358 358
359 359 def tag(self, name, user, commit_id=None, message=None, date=None,
360 360 **kwargs):
361 361 # TODO: fix this method to apply annotated tags correct with message
362 362 """
363 363 Creates and returns a tag for the given ``commit_id``.
364 364
365 365 :param name: name for new tag
366 366 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
367 367 :param commit_id: commit id for which new tag would be created
368 368 :param message: message of the tag's commit
369 369 :param date: date of tag's commit
370 370
371 371 :raises TagAlreadyExistError: if tag with same name already exists
372 372 """
373 373 if name in self.tags:
374 374 raise TagAlreadyExistError("Tag %s already exists" % name)
375 375 commit = self.get_commit(commit_id=commit_id)
376 message = message or "Added tag {} for commit {}".format(name, commit.raw_id)
376 message = message or f"Added tag {name} for commit {commit.raw_id}"
377 377
378 378 self._remote.set_refs('refs/tags/%s' % name, commit.raw_id)
379 379
380 380 self._invalidate_prop_cache('tags')
381 381 self._invalidate_prop_cache('_refs')
382 382
383 383 return commit
384 384
385 385 def remove_tag(self, name, user, message=None, date=None):
386 386 """
387 387 Removes tag with the given ``name``.
388 388
389 389 :param name: name of the tag to be removed
390 390 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
391 391 :param message: message of the tag's removal commit
392 392 :param date: date of tag's removal commit
393 393
394 394 :raises TagDoesNotExistError: if tag with given name does not exists
395 395 """
396 396 if name not in self.tags:
397 397 raise TagDoesNotExistError("Tag %s does not exist" % name)
398 398
399 399 self._remote.tag_remove(name)
400 400 self._invalidate_prop_cache('tags')
401 401 self._invalidate_prop_cache('_refs')
402 402
403 403 def _get_refs(self):
404 404 return self._remote.get_refs()
405 405
406 406 @CachedProperty
407 407 def _refs(self):
408 408 return self._get_refs()
409 409
410 410 @property
411 411 def _ref_tree(self):
412 412 node = tree = {}
413 413 for ref, sha in self._refs.items():
414 414 path = ref.split('/')
415 415 for bit in path[:-1]:
416 416 node = node.setdefault(bit, {})
417 417 node[path[-1]] = sha
418 418 node = tree
419 419 return tree
420 420
421 421 def get_remote_ref(self, ref_name):
422 422 ref_key = f'refs/remotes/origin/{safe_str(ref_name)}'
423 423 try:
424 424 return self._refs[ref_key]
425 425 except Exception:
426 426 return
427 427
428 428 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None,
429 429 translate_tag=True, maybe_unreachable=False, reference_obj=None):
430 430 """
431 431 Returns `GitCommit` object representing commit from git repository
432 432 at the given `commit_id` or head (most recent commit) if None given.
433 433 """
434 434
435 435 if self.is_empty():
436 436 raise EmptyRepositoryError("There are no commits yet")
437 437
438 438 if commit_id is not None:
439 439 self._validate_commit_id(commit_id)
440 440 try:
441 441 # we have cached idx, use it without contacting the remote
442 442 idx = self._commit_ids[commit_id]
443 443 return GitCommit(self, commit_id, idx, pre_load=pre_load)
444 444 except KeyError:
445 445 pass
446 446
447 447 elif commit_idx is not None:
448 448 self._validate_commit_idx(commit_idx)
449 449 try:
450 450 _commit_id = self.commit_ids[commit_idx]
451 451 if commit_idx < 0:
452 452 commit_idx = self.commit_ids.index(_commit_id)
453 453 return GitCommit(self, _commit_id, commit_idx, pre_load=pre_load)
454 454 except IndexError:
455 455 commit_id = commit_idx
456 456 else:
457 457 commit_id = "tip"
458 458
459 459 if translate_tag:
460 460 commit_id = self._lookup_commit(
461 461 commit_id, maybe_unreachable=maybe_unreachable,
462 462 reference_obj=reference_obj)
463 463
464 464 try:
465 465 idx = self._commit_ids[commit_id]
466 466 except KeyError:
467 467 idx = -1
468 468
469 469 return GitCommit(self, commit_id, idx, pre_load=pre_load)
470 470
471 471 def get_commits(
472 472 self, start_id=None, end_id=None, start_date=None, end_date=None,
473 473 branch_name=None, show_hidden=False, pre_load=None, translate_tags=True):
474 474 """
475 475 Returns generator of `GitCommit` objects from start to end (both
476 476 are inclusive), in ascending date order.
477 477
478 478 :param start_id: None, str(commit_id)
479 479 :param end_id: None, str(commit_id)
480 480 :param start_date: if specified, commits with commit date less than
481 481 ``start_date`` would be filtered out from returned set
482 482 :param end_date: if specified, commits with commit date greater than
483 483 ``end_date`` would be filtered out from returned set
484 484 :param branch_name: if specified, commits not reachable from given
485 485 branch would be filtered out from returned set
486 486 :param show_hidden: Show hidden commits such as obsolete or hidden from
487 487 Mercurial evolve
488 488 :raise BranchDoesNotExistError: If given `branch_name` does not
489 489 exist.
490 490 :raise CommitDoesNotExistError: If commits for given `start` or
491 491 `end` could not be found.
492 492
493 493 """
494 494 if self.is_empty():
495 495 raise EmptyRepositoryError("There are no commits yet")
496 496
497 497 self._validate_branch_name(branch_name)
498 498
499 499 if start_id is not None:
500 500 self._validate_commit_id(start_id)
501 501 if end_id is not None:
502 502 self._validate_commit_id(end_id)
503 503
504 504 start_raw_id = self._lookup_commit(start_id)
505 505 start_pos = self._commit_ids[start_raw_id] if start_id else None
506 506 end_raw_id = self._lookup_commit(end_id)
507 507 end_pos = max(0, self._commit_ids[end_raw_id]) if end_id else None
508 508
509 509 if None not in [start_id, end_id] and start_pos > end_pos:
510 510 raise RepositoryError(
511 511 "Start commit '%s' cannot be after end commit '%s'" %
512 512 (start_id, end_id))
513 513
514 514 if end_pos is not None:
515 515 end_pos += 1
516 516
517 517 filter_ = []
518 518 if branch_name:
519 519 filter_.append({'branch_name': branch_name})
520 520 if start_date and not end_date:
521 521 filter_.append({'since': start_date})
522 522 if end_date and not start_date:
523 523 filter_.append({'until': end_date})
524 524 if start_date and end_date:
525 525 filter_.append({'since': start_date})
526 526 filter_.append({'until': end_date})
527 527
528 528 # if start_pos or end_pos:
529 529 # filter_.append({'start': start_pos})
530 530 # filter_.append({'end': end_pos})
531 531
532 532 if filter_:
533 533 revfilters = {
534 534 'branch_name': branch_name,
535 535 'since': start_date.strftime('%m/%d/%y %H:%M:%S') if start_date else None,
536 536 'until': end_date.strftime('%m/%d/%y %H:%M:%S') if end_date else None,
537 537 'start': start_pos,
538 538 'end': end_pos,
539 539 }
540 540 commit_ids = self._get_commit_ids(filters=revfilters)
541 541
542 542 else:
543 543 commit_ids = self.commit_ids
544 544
545 545 if start_pos or end_pos:
546 546 commit_ids = commit_ids[start_pos: end_pos]
547 547
548 548 return CollectionGenerator(self, commit_ids, pre_load=pre_load,
549 549 translate_tag=translate_tags)
550 550
551 551 def get_diff(
552 552 self, commit1, commit2, path='', ignore_whitespace=False,
553 553 context=3, path1=None):
554 554 """
555 555 Returns (git like) *diff*, as plain text. Shows changes introduced by
556 556 ``commit2`` since ``commit1``.
557 557
558 558 :param commit1: Entry point from which diff is shown. Can be
559 559 ``self.EMPTY_COMMIT`` - in this case, patch showing all
560 560 the changes since empty state of the repository until ``commit2``
561 561 :param commit2: Until which commits changes should be shown.
562 562 :param path:
563 563 :param ignore_whitespace: If set to ``True``, would not show whitespace
564 564 changes. Defaults to ``False``.
565 565 :param context: How many lines before/after changed lines should be
566 566 shown. Defaults to ``3``.
567 567 :param path1:
568 568 """
569 569 self._validate_diff_commits(commit1, commit2)
570 570 if path1 is not None and path1 != path:
571 571 raise ValueError("Diff of two different paths not supported.")
572 572
573 573 if path:
574 574 file_filter = path
575 575 else:
576 576 file_filter = None
577 577
578 578 diff = self._remote.diff(
579 579 commit1.raw_id, commit2.raw_id, file_filter=file_filter,
580 580 opt_ignorews=ignore_whitespace,
581 581 context=context)
582 582
583 583 return GitDiff(diff)
584 584
585 585 def strip(self, commit_id, branch_name):
586 586 commit = self.get_commit(commit_id=commit_id)
587 587 if commit.merge:
588 588 raise Exception('Cannot reset to merge commit')
589 589
590 590 # parent is going to be the new head now
591 591 commit = commit.parents[0]
592 592 self._remote.set_refs('refs/heads/%s' % branch_name, commit.raw_id)
593 593
594 594 # clear cached properties
595 595 self._invalidate_prop_cache('commit_ids')
596 596 self._invalidate_prop_cache('_refs')
597 597 self._invalidate_prop_cache('branches')
598 598
599 599 return len(self.commit_ids)
600 600
601 601 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
602 602 log.debug('Calculating common ancestor between %sc1:%s and %sc2:%s',
603 603 self, commit_id1, repo2, commit_id2)
604 604
605 605 if commit_id1 == commit_id2:
606 606 return commit_id1
607 607
608 608 if self != repo2:
609 609 commits = self._remote.get_missing_revs(
610 610 commit_id1, commit_id2, repo2.path)
611 611 if commits:
612 612 commit = repo2.get_commit(commits[-1])
613 613 if commit.parents:
614 614 ancestor_id = commit.parents[0].raw_id
615 615 else:
616 616 ancestor_id = None
617 617 else:
618 618 # no commits from other repo, ancestor_id is the commit_id2
619 619 ancestor_id = commit_id2
620 620 else:
621 621 output, __ = self.run_git_command(
622 622 ['merge-base', commit_id1, commit_id2])
623 623 ancestor_id = self.COMMIT_ID_PAT.findall(output)[0]
624 624
625 625 log.debug('Found common ancestor with sha: %s', ancestor_id)
626 626
627 627 return ancestor_id
628 628
629 629 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
630 630 repo1 = self
631 631 ancestor_id = None
632 632
633 633 if commit_id1 == commit_id2:
634 634 commits = []
635 635 elif repo1 != repo2:
636 636 missing_ids = self._remote.get_missing_revs(commit_id1, commit_id2,
637 637 repo2.path)
638 638 commits = [
639 639 repo2.get_commit(commit_id=commit_id, pre_load=pre_load)
640 640 for commit_id in reversed(missing_ids)]
641 641 else:
642 642 output, __ = repo1.run_git_command(
643 643 ['log', '--reverse', '--pretty=format: %H', '-s',
644 '{}..{}'.format(commit_id1, commit_id2)])
644 f'{commit_id1}..{commit_id2}'])
645 645 commits = [
646 646 repo1.get_commit(commit_id=commit_id, pre_load=pre_load)
647 647 for commit_id in self.COMMIT_ID_PAT.findall(output)]
648 648
649 649 return commits
650 650
651 651 @LazyProperty
652 652 def in_memory_commit(self):
653 653 """
654 654 Returns ``GitInMemoryCommit`` object for this repository.
655 655 """
656 656 return GitInMemoryCommit(self)
657 657
658 658 def pull(self, url, commit_ids=None, update_after=False):
659 659 """
660 660 Pull changes from external location. Pull is different in GIT
661 661 that fetch since it's doing a checkout
662 662
663 663 :param commit_ids: Optional. Can be set to a list of commit ids
664 664 which shall be pulled from the other repository.
665 665 """
666 666 refs = None
667 667 if commit_ids is not None:
668 668 remote_refs = self._remote.get_remote_refs(url)
669 669 refs = [ref for ref in remote_refs if remote_refs[ref] in commit_ids]
670 670 self._remote.pull(url, refs=refs, update_after=update_after)
671 671 self._remote.invalidate_vcs_cache()
672 672
673 673 def fetch(self, url, commit_ids=None):
674 674 """
675 675 Fetch all git objects from external location.
676 676 """
677 677 self._remote.sync_fetch(url, refs=commit_ids)
678 678 self._remote.invalidate_vcs_cache()
679 679
680 680 def push(self, url):
681 681 refs = None
682 682 self._remote.sync_push(url, refs=refs)
683 683
684 684 def set_refs(self, ref_name, commit_id):
685 685 self._remote.set_refs(ref_name, commit_id)
686 686 self._invalidate_prop_cache('_refs')
687 687
688 688 def remove_ref(self, ref_name):
689 689 self._remote.remove_ref(ref_name)
690 690 self._invalidate_prop_cache('_refs')
691 691
692 692 def run_gc(self, prune=True):
693 693 cmd = ['gc', '--aggressive']
694 694 if prune:
695 695 cmd += ['--prune=now']
696 696 _stdout, stderr = self.run_git_command(cmd, fail_on_stderr=False)
697 697 return stderr
698 698
699 699 def _update_server_info(self):
700 700 """
701 701 runs gits update-server-info command in this repo instance
702 702 """
703 703 self._remote.update_server_info()
704 704
705 705 def _current_branch(self):
706 706 """
707 707 Return the name of the current branch.
708 708
709 709 It only works for non bare repositories (i.e. repositories with a
710 710 working copy)
711 711 """
712 712 if self.bare:
713 713 raise RepositoryError('Bare git repos do not have active branches')
714 714
715 715 if self.is_empty():
716 716 return None
717 717
718 718 stdout, _ = self.run_git_command(['rev-parse', '--abbrev-ref', 'HEAD'])
719 719 return stdout.strip()
720 720
721 721 def _checkout(self, branch_name, create=False, force=False):
722 722 """
723 723 Checkout a branch in the working directory.
724 724
725 725 It tries to create the branch if create is True, failing if the branch
726 726 already exists.
727 727
728 728 It only works for non bare repositories (i.e. repositories with a
729 729 working copy)
730 730 """
731 731 if self.bare:
732 732 raise RepositoryError('Cannot checkout branches in a bare git repo')
733 733
734 734 cmd = ['checkout']
735 735 if force:
736 736 cmd.append('-f')
737 737 if create:
738 738 cmd.append('-b')
739 739 cmd.append(branch_name)
740 740 self.run_git_command(cmd, fail_on_stderr=False)
741 741
742 742 def _create_branch(self, branch_name, commit_id):
743 743 """
744 744 creates a branch in a GIT repo
745 745 """
746 746 self._remote.create_branch(branch_name, commit_id)
747 747
748 748 def _identify(self):
749 749 """
750 750 Return the current state of the working directory.
751 751 """
752 752 if self.bare:
753 753 raise RepositoryError('Bare git repos do not have active branches')
754 754
755 755 if self.is_empty():
756 756 return None
757 757
758 758 stdout, _ = self.run_git_command(['rev-parse', 'HEAD'])
759 759 return stdout.strip()
760 760
761 761 def _local_clone(self, clone_path, branch_name, source_branch=None):
762 762 """
763 763 Create a local clone of the current repo.
764 764 """
765 765 # N.B.(skreft): the --branch option is required as otherwise the shallow
766 766 # clone will only fetch the active branch.
767 767 cmd = ['clone', '--branch', branch_name,
768 768 self.path, os.path.abspath(clone_path)]
769 769
770 770 self.run_git_command(cmd, fail_on_stderr=False)
771 771
772 772 # if we get the different source branch, make sure we also fetch it for
773 773 # merge conditions
774 774 if source_branch and source_branch != branch_name:
775 775 # check if the ref exists.
776 776 shadow_repo = GitRepository(os.path.abspath(clone_path))
777 777 if shadow_repo.get_remote_ref(source_branch):
778 778 cmd = ['fetch', self.path, source_branch]
779 779 self.run_git_command(cmd, fail_on_stderr=False)
780 780
781 781 def _local_fetch(self, repository_path, branch_name, use_origin=False):
782 782 """
783 783 Fetch a branch from a local repository.
784 784 """
785 785 repository_path = os.path.abspath(repository_path)
786 786 if repository_path == self.path:
787 787 raise ValueError('Cannot fetch from the same repository')
788 788
789 789 if use_origin:
790 790 branch_name = '+{branch}:refs/heads/{branch}'.format(
791 791 branch=branch_name)
792 792
793 793 cmd = ['fetch', '--no-tags', '--update-head-ok',
794 794 repository_path, branch_name]
795 795 self.run_git_command(cmd, fail_on_stderr=False)
796 796
797 797 def _local_reset(self, branch_name):
798 798 branch_name = f'{branch_name}'
799 799 cmd = ['reset', '--hard', branch_name, '--']
800 800 self.run_git_command(cmd, fail_on_stderr=False)
801 801
802 802 def _last_fetch_heads(self):
803 803 """
804 804 Return the last fetched heads that need merging.
805 805
806 806 The algorithm is defined at
807 807 https://github.com/git/git/blob/v2.1.3/git-pull.sh#L283
808 808 """
809 809 if not self.bare:
810 810 fetch_heads_path = os.path.join(self.path, '.git', 'FETCH_HEAD')
811 811 else:
812 812 fetch_heads_path = os.path.join(self.path, 'FETCH_HEAD')
813 813
814 814 heads = []
815 815 with open(fetch_heads_path) as f:
816 816 for line in f:
817 817 if ' not-for-merge ' in line:
818 818 continue
819 819 line = re.sub('\t.*', '', line, flags=re.DOTALL)
820 820 heads.append(line)
821 821
822 822 return heads
823 823
824 824 def get_shadow_instance(self, shadow_repository_path, enable_hooks=False, cache=False):
825 825 return GitRepository(shadow_repository_path, with_wire={"cache": cache})
826 826
827 827 def _local_pull(self, repository_path, branch_name, ff_only=True):
828 828 """
829 829 Pull a branch from a local repository.
830 830 """
831 831 if self.bare:
832 832 raise RepositoryError('Cannot pull into a bare git repository')
833 833 # N.B.(skreft): The --ff-only option is to make sure this is a
834 834 # fast-forward (i.e., we are only pulling new changes and there are no
835 835 # conflicts with our current branch)
836 836 # Additionally, that option needs to go before --no-tags, otherwise git
837 837 # pull complains about it being an unknown flag.
838 838 cmd = ['pull']
839 839 if ff_only:
840 840 cmd.append('--ff-only')
841 841 cmd.extend(['--no-tags', repository_path, branch_name])
842 842 self.run_git_command(cmd, fail_on_stderr=False)
843 843
844 844 def _local_merge(self, merge_message, user_name, user_email, heads):
845 845 """
846 846 Merge the given head into the checked out branch.
847 847
848 848 It will force a merge commit.
849 849
850 850 Currently it raises an error if the repo is empty, as it is not possible
851 851 to create a merge commit in an empty repo.
852 852
853 853 :param merge_message: The message to use for the merge commit.
854 854 :param heads: the heads to merge.
855 855 """
856 856 if self.bare:
857 857 raise RepositoryError('Cannot merge into a bare git repository')
858 858
859 859 if not heads:
860 860 return
861 861
862 862 if self.is_empty():
863 863 # TODO(skreft): do something more robust in this case.
864 864 raise RepositoryError('Do not know how to merge into empty repositories yet')
865 865 unresolved = None
866 866
867 867 # N.B.(skreft): the --no-ff option is used to enforce the creation of a
868 868 # commit message. We also specify the user who is doing the merge.
869 869 cmd = ['-c', f'user.name="{user_name}"',
870 870 '-c', f'user.email={user_email}',
871 871 'merge', '--no-ff', '-m', safe_str(merge_message)]
872 872
873 873 merge_cmd = cmd + heads
874 874
875 875 try:
876 876 self.run_git_command(merge_cmd, fail_on_stderr=False)
877 877 except RepositoryError:
878 878 files = self.run_git_command(['diff', '--name-only', '--diff-filter', 'U'],
879 879 fail_on_stderr=False)[0].splitlines()
880 880 # NOTE(marcink): we add U notation for consistent with HG backend output
881 881 unresolved = [f'U {f}' for f in files]
882 882
883 883 # Cleanup any merge leftovers
884 884 self._remote.invalidate_vcs_cache()
885 885 self.run_git_command(['merge', '--abort'], fail_on_stderr=False)
886 886
887 887 if unresolved:
888 888 raise UnresolvedFilesInRepo(unresolved)
889 889 else:
890 890 raise
891 891
892 892 def _local_push(
893 893 self, source_branch, repository_path, target_branch,
894 894 enable_hooks=False, rc_scm_data=None):
895 895 """
896 896 Push the source_branch to the given repository and target_branch.
897 897
898 898 Currently it if the target_branch is not master and the target repo is
899 899 empty, the push will work, but then GitRepository won't be able to find
900 900 the pushed branch or the commits. As the HEAD will be corrupted (i.e.,
901 901 pointing to master, which does not exist).
902 902
903 903 It does not run the hooks in the target repo.
904 904 """
905 905 # TODO(skreft): deal with the case in which the target repo is empty,
906 906 # and the target_branch is not master.
907 907 target_repo = GitRepository(repository_path)
908 908 if (not target_repo.bare and
909 909 target_repo._current_branch() == target_branch):
910 910 # Git prevents pushing to the checked out branch, so simulate it by
911 911 # pulling into the target repository.
912 912 target_repo._local_pull(self.path, source_branch)
913 913 else:
914 914 cmd = ['push', os.path.abspath(repository_path),
915 '{}:{}'.format(source_branch, target_branch)]
915 f'{source_branch}:{target_branch}']
916 916 gitenv = {}
917 917 if rc_scm_data:
918 918 gitenv.update({'RC_SCM_DATA': rc_scm_data})
919 919
920 920 if not enable_hooks:
921 921 gitenv['RC_SKIP_HOOKS'] = '1'
922 922 self.run_git_command(cmd, fail_on_stderr=False, extra_env=gitenv)
923 923
924 924 def _get_new_pr_branch(self, source_branch, target_branch):
925 prefix = 'pr_{}-{}_'.format(source_branch, target_branch)
925 prefix = f'pr_{source_branch}-{target_branch}_'
926 926 pr_branches = []
927 927 for branch in self.branches:
928 928 if branch.startswith(prefix):
929 929 pr_branches.append(int(branch[len(prefix):]))
930 930
931 931 if not pr_branches:
932 932 branch_id = 0
933 933 else:
934 934 branch_id = max(pr_branches) + 1
935 935
936 936 return '%s%d' % (prefix, branch_id)
937 937
938 938 def _maybe_prepare_merge_workspace(
939 939 self, repo_id, workspace_id, target_ref, source_ref):
940 940 shadow_repository_path = self._get_shadow_repository_path(
941 941 self.path, repo_id, workspace_id)
942 942 if not os.path.exists(shadow_repository_path):
943 943 self._local_clone(
944 944 shadow_repository_path, target_ref.name, source_ref.name)
945 945 log.debug('Prepared %s shadow repository in %s',
946 946 self.alias, shadow_repository_path)
947 947
948 948 return shadow_repository_path
949 949
950 950 def _merge_repo(self, repo_id, workspace_id, target_ref,
951 951 source_repo, source_ref, merge_message,
952 952 merger_name, merger_email, dry_run=False,
953 953 use_rebase=False, close_branch=False):
954 954
955 955 log.debug('Executing merge_repo with %s strategy, dry_run mode:%s',
956 956 'rebase' if use_rebase else 'merge', dry_run)
957 957 if target_ref.commit_id != self.branches[target_ref.name]:
958 958 log.warning('Target ref %s commit mismatch %s vs %s', target_ref,
959 959 target_ref.commit_id, self.branches[target_ref.name])
960 960 return MergeResponse(
961 961 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD,
962 962 metadata={'target_ref': target_ref})
963 963
964 964 shadow_repository_path = self._maybe_prepare_merge_workspace(
965 965 repo_id, workspace_id, target_ref, source_ref)
966 966 shadow_repo = self.get_shadow_instance(shadow_repository_path)
967 967
968 968 # checkout source, if it's different. Otherwise we could not
969 969 # fetch proper commits for merge testing
970 970 if source_ref.name != target_ref.name:
971 971 if shadow_repo.get_remote_ref(source_ref.name):
972 972 shadow_repo._checkout(source_ref.name, force=True)
973 973
974 974 # checkout target, and fetch changes
975 975 shadow_repo._checkout(target_ref.name, force=True)
976 976
977 977 # fetch/reset pull the target, in case it is changed
978 978 # this handles even force changes
979 979 shadow_repo._local_fetch(self.path, target_ref.name, use_origin=True)
980 980 shadow_repo._local_reset(target_ref.name)
981 981
982 982 # Need to reload repo to invalidate the cache, or otherwise we cannot
983 983 # retrieve the last target commit.
984 984 shadow_repo = self.get_shadow_instance(shadow_repository_path)
985 985 if target_ref.commit_id != shadow_repo.branches[target_ref.name]:
986 986 log.warning('Shadow Target ref %s commit mismatch %s vs %s',
987 987 target_ref, target_ref.commit_id,
988 988 shadow_repo.branches[target_ref.name])
989 989 return MergeResponse(
990 990 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD,
991 991 metadata={'target_ref': target_ref})
992 992
993 993 # calculate new branch
994 994 pr_branch = shadow_repo._get_new_pr_branch(
995 995 source_ref.name, target_ref.name)
996 996 log.debug('using pull-request merge branch: `%s`', pr_branch)
997 997 # checkout to temp branch, and fetch changes
998 998 shadow_repo._checkout(pr_branch, create=True)
999 999 try:
1000 1000 shadow_repo._local_fetch(source_repo.path, source_ref.name)
1001 1001 except RepositoryError:
1002 1002 log.exception('Failure when doing local fetch on '
1003 1003 'shadow repo: %s', shadow_repo)
1004 1004 return MergeResponse(
1005 1005 False, False, None, MergeFailureReason.MISSING_SOURCE_REF,
1006 1006 metadata={'source_ref': source_ref})
1007 1007
1008 1008 merge_ref = None
1009 1009 merge_failure_reason = MergeFailureReason.NONE
1010 1010 metadata = {}
1011 1011 try:
1012 1012 shadow_repo._local_merge(merge_message, merger_name, merger_email,
1013 1013 [source_ref.commit_id])
1014 1014 merge_possible = True
1015 1015
1016 1016 # Need to invalidate the cache, or otherwise we
1017 1017 # cannot retrieve the merge commit.
1018 1018 shadow_repo = shadow_repo.get_shadow_instance(shadow_repository_path)
1019 1019 merge_commit_id = shadow_repo.branches[pr_branch]
1020 1020
1021 1021 # Set a reference pointing to the merge commit. This reference may
1022 1022 # be used to easily identify the last successful merge commit in
1023 1023 # the shadow repository.
1024 1024 shadow_repo.set_refs('refs/heads/pr-merge', merge_commit_id)
1025 1025 merge_ref = Reference('branch', 'pr-merge', merge_commit_id)
1026 1026 except RepositoryError as e:
1027 1027 log.exception('Failure when doing local merge on git shadow repo')
1028 1028 if isinstance(e, UnresolvedFilesInRepo):
1029 1029 metadata['unresolved_files'] = '\n* conflict: ' + ('\n * conflict: '.join(e.args[0]))
1030 1030
1031 1031 merge_possible = False
1032 1032 merge_failure_reason = MergeFailureReason.MERGE_FAILED
1033 1033
1034 1034 if merge_possible and not dry_run:
1035 1035 try:
1036 1036 shadow_repo._local_push(
1037 1037 pr_branch, self.path, target_ref.name, enable_hooks=True,
1038 1038 rc_scm_data=self.config.get('rhodecode', 'RC_SCM_DATA'))
1039 1039 merge_succeeded = True
1040 1040 except RepositoryError:
1041 1041 log.exception(
1042 1042 'Failure when doing local push from the shadow '
1043 1043 'repository to the target repository at %s.', self.path)
1044 1044 merge_succeeded = False
1045 1045 merge_failure_reason = MergeFailureReason.PUSH_FAILED
1046 1046 metadata['target'] = 'git shadow repo'
1047 1047 metadata['merge_commit'] = pr_branch
1048 1048 else:
1049 1049 merge_succeeded = False
1050 1050
1051 1051 return MergeResponse(
1052 1052 merge_possible, merge_succeeded, merge_ref, merge_failure_reason,
1053 1053 metadata=metadata)
@@ -1,403 +1,403 b''
1 1 # Copyright (C) 2014-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
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 Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 """
20 20 HG commit module
21 21 """
22 22
23 23 import os
24 24
25 25 from zope.cachedescriptors.property import Lazy as LazyProperty
26 26
27 27 from rhodecode.lib.datelib import utcdate_fromtimestamp
28 28 from rhodecode.lib.str_utils import safe_bytes, safe_str
29 29 from rhodecode.lib.vcs import path as vcspath
30 30 from rhodecode.lib.vcs.backends import base
31 31 from rhodecode.lib.vcs.exceptions import CommitError
32 32 from rhodecode.lib.vcs.nodes import (
33 33 AddedFileNodesGenerator, ChangedFileNodesGenerator, DirNode, FileNode,
34 34 NodeKind, RemovedFileNodesGenerator, RootNode, SubModuleNode,
35 35 LargeFileNode)
36 36 from rhodecode.lib.vcs.utils.paths import get_dirs_for_path
37 37
38 38
39 39 class MercurialCommit(base.BaseCommit):
40 40 """
41 41 Represents state of the repository at the single commit.
42 42 """
43 43
44 44 _filter_pre_load = [
45 45 # git specific property not supported here
46 46 "_commit",
47 47 ]
48 48
49 49 def __init__(self, repository, raw_id, idx, pre_load=None):
50 50 raw_id = safe_str(raw_id)
51 51
52 52 self.repository = repository
53 53 self._remote = repository._remote
54 54
55 55 self.raw_id = raw_id
56 56 self.idx = idx
57 57
58 58 self._set_bulk_properties(pre_load)
59 59
60 60 # caches
61 61 self.nodes = {}
62 62 self._stat_modes = {} # stat info for paths
63 63
64 64 def _set_bulk_properties(self, pre_load):
65 65 if not pre_load:
66 66 return
67 67 pre_load = [entry for entry in pre_load
68 68 if entry not in self._filter_pre_load]
69 69 if not pre_load:
70 70 return
71 71
72 72 result = self._remote.bulk_request(self.raw_id, pre_load)
73 73
74 74 for attr, value in result.items():
75 75 if attr in ["author", "branch", "message"]:
76 76 value = safe_str(value)
77 77 elif attr == "affected_files":
78 78 value = list(map(safe_str, value))
79 79 elif attr == "date":
80 80 value = utcdate_fromtimestamp(*value)
81 81 elif attr in ["children", "parents"]:
82 82 value = self._make_commits(value)
83 83 elif attr in ["phase"]:
84 84 value = self._get_phase_text(value)
85 85 self.__dict__[attr] = value
86 86
87 87 @LazyProperty
88 88 def tags(self):
89 89 tags = [name for name, commit_id in self.repository.tags.items()
90 90 if commit_id == self.raw_id]
91 91 return tags
92 92
93 93 @LazyProperty
94 94 def branch(self):
95 95 return safe_str(self._remote.ctx_branch(self.raw_id))
96 96
97 97 @LazyProperty
98 98 def bookmarks(self):
99 99 bookmarks = [
100 100 name for name, commit_id in self.repository.bookmarks.items()
101 101 if commit_id == self.raw_id]
102 102 return bookmarks
103 103
104 104 @LazyProperty
105 105 def message(self):
106 106 return safe_str(self._remote.ctx_description(self.raw_id))
107 107
108 108 @LazyProperty
109 109 def committer(self):
110 110 return safe_str(self.author)
111 111
112 112 @LazyProperty
113 113 def author(self):
114 114 return safe_str(self._remote.ctx_user(self.raw_id))
115 115
116 116 @LazyProperty
117 117 def date(self):
118 118 return utcdate_fromtimestamp(*self._remote.ctx_date(self.raw_id))
119 119
120 120 @LazyProperty
121 121 def status(self):
122 122 """
123 123 Returns modified, added, removed, deleted files for current commit
124 124 """
125 125 return self._remote.ctx_status(self.raw_id)
126 126
127 127 @LazyProperty
128 128 def _file_paths(self):
129 129 return self._remote.ctx_list(self.raw_id)
130 130
131 131 @LazyProperty
132 132 def _dir_paths(self):
133 133 dir_paths = ['']
134 134 dir_paths.extend(list(set(get_dirs_for_path(*self._file_paths))))
135 135
136 136 return dir_paths
137 137
138 138 @LazyProperty
139 139 def _paths(self):
140 140 return self._dir_paths + self._file_paths
141 141
142 142 @LazyProperty
143 143 def id(self):
144 144 if self.last:
145 145 return 'tip'
146 146 return self.short_id
147 147
148 148 @LazyProperty
149 149 def short_id(self):
150 150 return self.raw_id[:12]
151 151
152 152 def _make_commits(self, commit_ids, pre_load=None):
153 153 return [self.repository.get_commit(commit_id=commit_id, pre_load=pre_load)
154 154 for commit_id in commit_ids]
155 155
156 156 @LazyProperty
157 157 def parents(self):
158 158 """
159 159 Returns list of parent commits.
160 160 """
161 161 parents = self._remote.ctx_parents(self.raw_id)
162 162 return self._make_commits(parents)
163 163
164 164 def _get_phase_text(self, phase_id):
165 165 return {
166 166 0: 'public',
167 167 1: 'draft',
168 168 2: 'secret',
169 169 }.get(phase_id) or ''
170 170
171 171 @LazyProperty
172 172 def phase(self):
173 173 phase_id = self._remote.ctx_phase(self.raw_id)
174 174 phase_text = self._get_phase_text(phase_id)
175 175
176 176 return safe_str(phase_text)
177 177
178 178 @LazyProperty
179 179 def obsolete(self):
180 180 obsolete = self._remote.ctx_obsolete(self.raw_id)
181 181 return obsolete
182 182
183 183 @LazyProperty
184 184 def hidden(self):
185 185 hidden = self._remote.ctx_hidden(self.raw_id)
186 186 return hidden
187 187
188 188 @LazyProperty
189 189 def children(self):
190 190 """
191 191 Returns list of child commits.
192 192 """
193 193 children = self._remote.ctx_children(self.raw_id)
194 194 return self._make_commits(children)
195 195
196 196 def _get_kind(self, path):
197 197 path = self._fix_path(path)
198 198 if path in self._file_paths:
199 199 return NodeKind.FILE
200 200 elif path in self._dir_paths:
201 201 return NodeKind.DIR
202 202 else:
203 203 raise CommitError(f"Node does not exist at the given path '{path}'")
204 204
205 205 def _assert_is_path(self, path) -> str:
206 206 path = self._fix_path(path)
207 207 if self._get_kind(path) != NodeKind.FILE:
208 208 raise CommitError(f"File does not exist for commit {self.raw_id} at '{path}'")
209 209
210 210 return path
211 211
212 212 def get_file_mode(self, path: bytes):
213 213 """
214 214 Returns stat mode of the file at the given ``path``.
215 215 """
216 216 path = self._assert_is_path(path)
217 217
218 218 if path not in self._stat_modes:
219 219 self._stat_modes[path] = self._remote.fctx_flags(self.raw_id, path)
220 220
221 221 if 'x' in self._stat_modes[path]:
222 222 return base.FILEMODE_EXECUTABLE
223 223 return base.FILEMODE_DEFAULT
224 224
225 225 def is_link(self, path):
226 226 path = self._assert_is_path(path)
227 227 if path not in self._stat_modes:
228 228 self._stat_modes[path] = self._remote.fctx_flags(self.raw_id, path)
229 229
230 230 return 'l' in self._stat_modes[path]
231 231
232 232 def is_node_binary(self, path):
233 233 path = self._assert_is_path(path)
234 234 return self._remote.is_binary(self.raw_id, path)
235 235
236 236 def node_md5_hash(self, path):
237 237 path = self._assert_is_path(path)
238 238 return self._remote.md5_hash(self.raw_id, path)
239 239
240 240 def get_file_content(self, path):
241 241 """
242 242 Returns content of the file at given ``path``.
243 243 """
244 244 path = self._assert_is_path(path)
245 245 return self._remote.fctx_node_data(self.raw_id, path)
246 246
247 247 def get_file_content_streamed(self, path):
248 248 path = self._assert_is_path(path)
249 249 stream_method = getattr(self._remote, 'stream:fctx_node_data')
250 250 return stream_method(self.raw_id, path)
251 251
252 252 def get_file_size(self, path):
253 253 """
254 254 Returns size of the file at given ``path``.
255 255 """
256 256 path = self._assert_is_path(path)
257 257 return self._remote.fctx_size(self.raw_id, path)
258 258
259 259 def get_path_history(self, path, limit=None, pre_load=None):
260 260 """
261 261 Returns history of file as reversed list of `MercurialCommit` objects
262 262 for which file at given ``path`` has been modified.
263 263 """
264 264 path = self._assert_is_path(path)
265 265 hist = self._remote.node_history(self.raw_id, path, limit)
266 266 return [
267 267 self.repository.get_commit(commit_id=commit_id, pre_load=pre_load)
268 268 for commit_id in hist]
269 269
270 270 def get_file_annotate(self, path, pre_load=None):
271 271 """
272 272 Returns a generator of four element tuples with
273 273 lineno, commit_id, commit lazy loader and line
274 274 """
275 275 result = self._remote.fctx_annotate(self.raw_id, path)
276 276
277 277 for ln_no, commit_id, content in result:
278 278 yield (
279 279 ln_no, commit_id,
280 280 lambda: self.repository.get_commit(commit_id=commit_id, pre_load=pre_load),
281 281 content)
282 282
283 283 def get_nodes(self, path, pre_load=None):
284 284 """
285 285 Returns combined ``DirNode`` and ``FileNode`` objects list representing
286 286 state of commit at the given ``path``. If node at the given ``path``
287 287 is not instance of ``DirNode``, CommitError would be raised.
288 288 """
289 289
290 290 if self._get_kind(path) != NodeKind.DIR:
291 291 raise CommitError(
292 "Directory does not exist for idx {} at '{}'".format(self.raw_id, path))
292 f"Directory does not exist for idx {self.raw_id} at '{path}'")
293 293 path = self._fix_path(path)
294 294
295 295 filenodes = [
296 296 FileNode(safe_bytes(f), commit=self, pre_load=pre_load) for f in self._file_paths
297 297 if os.path.dirname(f) == path]
298 298 # TODO: johbo: Check if this can be done in a more obvious way
299 299 dirs = path == '' and '' or [
300 300 d for d in self._dir_paths
301 301 if d and vcspath.dirname(d) == path]
302 302 dirnodes = [
303 303 DirNode(safe_bytes(d), commit=self) for d in dirs
304 304 if os.path.dirname(d) == path]
305 305
306 306 alias = self.repository.alias
307 307 for k, vals in self._submodules.items():
308 308 if vcspath.dirname(k) == path:
309 309 loc = vals[0]
310 310 commit = vals[1]
311 311 dirnodes.append(SubModuleNode(k, url=loc, commit=commit, alias=alias))
312 312
313 313 nodes = dirnodes + filenodes
314 314 for node in nodes:
315 315 if node.path not in self.nodes:
316 316 self.nodes[node.path] = node
317 317 nodes.sort()
318 318
319 319 return nodes
320 320
321 321 def get_node(self, path, pre_load=None):
322 322 """
323 323 Returns `Node` object from the given `path`. If there is no node at
324 324 the given `path`, `NodeDoesNotExistError` would be raised.
325 325 """
326 326 path = self._fix_path(path)
327 327
328 328 if path not in self.nodes:
329 329 if path in self._file_paths:
330 330 node = FileNode(safe_bytes(path), commit=self, pre_load=pre_load)
331 331 elif path in self._dir_paths:
332 332 if path == '':
333 333 node = RootNode(commit=self)
334 334 else:
335 335 node = DirNode(safe_bytes(path), commit=self)
336 336 else:
337 337 raise self.no_node_at_path(path)
338 338
339 339 # cache node
340 340 self.nodes[path] = node
341 341 return self.nodes[path]
342 342
343 343 def get_largefile_node(self, path):
344 344 pointer_spec = self._remote.is_large_file(self.raw_id, path)
345 345 if pointer_spec:
346 346 # content of that file regular FileNode is the hash of largefile
347 347 file_id = self.get_file_content(path).strip()
348 348
349 349 if self._remote.in_largefiles_store(file_id):
350 350 lf_path = self._remote.store_path(file_id)
351 351 return LargeFileNode(safe_bytes(lf_path), commit=self, org_path=path)
352 352 elif self._remote.in_user_cache(file_id):
353 353 lf_path = self._remote.store_path(file_id)
354 354 self._remote.link(file_id, path)
355 355 return LargeFileNode(safe_bytes(lf_path), commit=self, org_path=path)
356 356
357 357 @LazyProperty
358 358 def _submodules(self):
359 359 """
360 360 Returns a dictionary with submodule information from substate file
361 361 of hg repository.
362 362 """
363 363 return self._remote.ctx_substate(self.raw_id)
364 364
365 365 @LazyProperty
366 366 def affected_files(self):
367 367 """
368 368 Gets a fast accessible file changes for given commit
369 369 """
370 370 return self._remote.ctx_files(self.raw_id)
371 371
372 372 @property
373 373 def added(self):
374 374 """
375 375 Returns list of added ``FileNode`` objects.
376 376 """
377 377 return AddedFileNodesGenerator(self.added_paths, self)
378 378
379 379 @LazyProperty
380 380 def added_paths(self):
381 381 return [n for n in self.status[1]]
382 382
383 383 @property
384 384 def changed(self):
385 385 """
386 386 Returns list of modified ``FileNode`` objects.
387 387 """
388 388 return ChangedFileNodesGenerator(self.changed_paths, self)
389 389
390 390 @LazyProperty
391 391 def changed_paths(self):
392 392 return [n for n in self.status[0]]
393 393
394 394 @property
395 395 def removed(self):
396 396 """
397 397 Returns list of removed ``FileNode`` objects.
398 398 """
399 399 return RemovedFileNodesGenerator(self.removed_paths, self)
400 400
401 401 @LazyProperty
402 402 def removed_paths(self):
403 403 return [n for n in self.status[2]]
@@ -1,1013 +1,1013 b''
1 1 # Copyright (C) 2014-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
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 Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 """
20 20 HG repository module
21 21 """
22 22 import os
23 23 import logging
24 24 import binascii
25 25 import configparser
26 26 import urllib.request
27 27 import urllib.parse
28 28 import urllib.error
29 29
30 30 from zope.cachedescriptors.property import Lazy as LazyProperty
31 31
32 32 from collections import OrderedDict
33 33 from rhodecode.lib.datelib import (
34 34 date_to_timestamp_plus_offset, utcdate_fromtimestamp, makedate)
35 35 from rhodecode.lib.str_utils import safe_str
36 36 from rhodecode.lib.utils2 import CachedProperty
37 37 from rhodecode.lib.vcs import connection, exceptions
38 38 from rhodecode.lib.vcs.backends.base import (
39 39 BaseRepository, CollectionGenerator, Config, MergeResponse,
40 40 MergeFailureReason, Reference, BasePathPermissionChecker)
41 41 from rhodecode.lib.vcs.backends.hg.commit import MercurialCommit
42 42 from rhodecode.lib.vcs.backends.hg.diff import MercurialDiff
43 43 from rhodecode.lib.vcs.backends.hg.inmemory import MercurialInMemoryCommit
44 44 from rhodecode.lib.vcs.exceptions import (
45 45 EmptyRepositoryError, RepositoryError, TagAlreadyExistError,
46 46 TagDoesNotExistError, CommitDoesNotExistError, SubrepoMergeError, UnresolvedFilesInRepo)
47 47
48 48 hexlify = binascii.hexlify
49 49 nullid = "\0" * 20
50 50
51 51 log = logging.getLogger(__name__)
52 52
53 53
54 54 class MercurialRepository(BaseRepository):
55 55 """
56 56 Mercurial repository backend
57 57 """
58 58 DEFAULT_BRANCH_NAME = 'default'
59 59
60 60 def __init__(self, repo_path, config=None, create=False, src_url=None,
61 61 do_workspace_checkout=False, with_wire=None, bare=False):
62 62 """
63 63 Raises RepositoryError if repository could not be find at the given
64 64 ``repo_path``.
65 65
66 66 :param repo_path: local path of the repository
67 67 :param config: config object containing the repo configuration
68 68 :param create=False: if set to True, would try to create repository if
69 69 it does not exist rather than raising exception
70 70 :param src_url=None: would try to clone repository from given location
71 71 :param do_workspace_checkout=False: sets update of working copy after
72 72 making a clone
73 73 :param bare: not used, compatible with other VCS
74 74 """
75 75
76 76 self.path = safe_str(os.path.abspath(repo_path))
77 77 # mercurial since 4.4.X requires certain configuration to be present
78 78 # because sometimes we init the repos with config we need to meet
79 79 # special requirements
80 80 self.config = config if config else self.get_default_config(
81 81 default=[('extensions', 'largefiles', '1')])
82 82 self.with_wire = with_wire or {"cache": False} # default should not use cache
83 83
84 84 self._init_repo(create, src_url, do_workspace_checkout)
85 85
86 86 # caches
87 87 self._commit_ids = {}
88 88
89 89 @LazyProperty
90 90 def _remote(self):
91 91 repo_id = self.path
92 92 return connection.Hg(self.path, repo_id, self.config, with_wire=self.with_wire)
93 93
94 94 @CachedProperty
95 95 def commit_ids(self):
96 96 """
97 97 Returns list of commit ids, in ascending order. Being lazy
98 98 attribute allows external tools to inject shas from cache.
99 99 """
100 100 commit_ids = self._get_all_commit_ids()
101 101 self._rebuild_cache(commit_ids)
102 102 return commit_ids
103 103
104 104 def _rebuild_cache(self, commit_ids):
105 105 self._commit_ids = {commit_id: index
106 106 for index, commit_id in enumerate(commit_ids)}
107 107
108 108 @CachedProperty
109 109 def branches(self):
110 110 return self._get_branches()
111 111
112 112 @CachedProperty
113 113 def branches_closed(self):
114 114 return self._get_branches(active=False, closed=True)
115 115
116 116 @CachedProperty
117 117 def branches_all(self):
118 118 all_branches = {}
119 119 all_branches.update(self.branches)
120 120 all_branches.update(self.branches_closed)
121 121 return all_branches
122 122
123 123 def _get_branches(self, active=True, closed=False):
124 124 """
125 125 Gets branches for this repository
126 126 Returns only not closed active branches by default
127 127
128 128 :param active: return also active branches
129 129 :param closed: return also closed branches
130 130
131 131 """
132 132 if self.is_empty():
133 133 return {}
134 134
135 135 def get_name(ctx):
136 136 return ctx[0]
137 137
138 138 _branches = [(n, h,) for n, h in
139 139 self._remote.branches(active, closed).items()]
140 140
141 141 return OrderedDict(sorted(_branches, key=get_name, reverse=False))
142 142
143 143 @CachedProperty
144 144 def tags(self):
145 145 """
146 146 Gets tags for this repository
147 147 """
148 148 return self._get_tags()
149 149
150 150 def _get_tags(self):
151 151 if self.is_empty():
152 152 return {}
153 153
154 154 def get_name(ctx):
155 155 return ctx[0]
156 156
157 157 _tags = [(n, h,) for n, h in
158 158 self._remote.tags().items()]
159 159
160 160 return OrderedDict(sorted(_tags, key=get_name, reverse=True))
161 161
162 162 def tag(self, name, user, commit_id=None, message=None, date=None, **kwargs):
163 163 """
164 164 Creates and returns a tag for the given ``commit_id``.
165 165
166 166 :param name: name for new tag
167 167 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
168 168 :param commit_id: commit id for which new tag would be created
169 169 :param message: message of the tag's commit
170 170 :param date: date of tag's commit
171 171
172 172 :raises TagAlreadyExistError: if tag with same name already exists
173 173 """
174 174 if name in self.tags:
175 175 raise TagAlreadyExistError("Tag %s already exists" % name)
176 176
177 177 commit = self.get_commit(commit_id=commit_id)
178 178 local = kwargs.setdefault('local', False)
179 179
180 180 if message is None:
181 message = "Added tag {} for commit {}".format(name, commit.short_id)
181 message = f"Added tag {name} for commit {commit.short_id}"
182 182
183 183 date, tz = date_to_timestamp_plus_offset(date)
184 184
185 185 self._remote.tag(name, commit.raw_id, message, local, user, date, tz)
186 186 self._remote.invalidate_vcs_cache()
187 187
188 188 # Reinitialize tags
189 189 self._invalidate_prop_cache('tags')
190 190 tag_id = self.tags[name]
191 191
192 192 return self.get_commit(commit_id=tag_id)
193 193
194 194 def remove_tag(self, name, user, message=None, date=None):
195 195 """
196 196 Removes tag with the given `name`.
197 197
198 198 :param name: name of the tag to be removed
199 199 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
200 200 :param message: message of the tag's removal commit
201 201 :param date: date of tag's removal commit
202 202
203 203 :raises TagDoesNotExistError: if tag with given name does not exists
204 204 """
205 205 if name not in self.tags:
206 206 raise TagDoesNotExistError("Tag %s does not exist" % name)
207 207
208 208 if message is None:
209 209 message = "Removed tag %s" % name
210 210 local = False
211 211
212 212 date, tz = date_to_timestamp_plus_offset(date)
213 213
214 214 self._remote.tag(name, nullid, message, local, user, date, tz)
215 215 self._remote.invalidate_vcs_cache()
216 216 self._invalidate_prop_cache('tags')
217 217
218 218 @LazyProperty
219 219 def bookmarks(self):
220 220 """
221 221 Gets bookmarks for this repository
222 222 """
223 223 return self._get_bookmarks()
224 224
225 225 def _get_bookmarks(self):
226 226 if self.is_empty():
227 227 return {}
228 228
229 229 def get_name(ctx):
230 230 return ctx[0]
231 231
232 232 _bookmarks = [
233 233 (n, h) for n, h in
234 234 self._remote.bookmarks().items()]
235 235
236 236 return OrderedDict(sorted(_bookmarks, key=get_name))
237 237
238 238 def _get_all_commit_ids(self):
239 239 return self._remote.get_all_commit_ids('visible')
240 240
241 241 def get_diff(
242 242 self, commit1, commit2, path='', ignore_whitespace=False,
243 243 context=3, path1=None):
244 244 """
245 245 Returns (git like) *diff*, as plain text. Shows changes introduced by
246 246 `commit2` since `commit1`.
247 247
248 248 :param commit1: Entry point from which diff is shown. Can be
249 249 ``self.EMPTY_COMMIT`` - in this case, patch showing all
250 250 the changes since empty state of the repository until `commit2`
251 251 :param commit2: Until which commit changes should be shown.
252 252 :param ignore_whitespace: If set to ``True``, would not show whitespace
253 253 changes. Defaults to ``False``.
254 254 :param context: How many lines before/after changed lines should be
255 255 shown. Defaults to ``3``.
256 256 """
257 257 self._validate_diff_commits(commit1, commit2)
258 258 if path1 is not None and path1 != path:
259 259 raise ValueError("Diff of two different paths not supported.")
260 260
261 261 if path:
262 262 file_filter = [self.path, path]
263 263 else:
264 264 file_filter = None
265 265
266 266 diff = self._remote.diff(
267 267 commit1.raw_id, commit2.raw_id, file_filter=file_filter,
268 268 opt_git=True, opt_ignorews=ignore_whitespace,
269 269 context=context)
270 270 return MercurialDiff(diff)
271 271
272 272 def strip(self, commit_id, branch=None):
273 273 self._remote.strip(commit_id, update=False, backup="none")
274 274
275 275 self._remote.invalidate_vcs_cache()
276 276 # clear cache
277 277 self._invalidate_prop_cache('commit_ids')
278 278
279 279 return len(self.commit_ids)
280 280
281 281 def verify(self):
282 282 verify = self._remote.verify()
283 283
284 284 self._remote.invalidate_vcs_cache()
285 285 return verify
286 286
287 287 def hg_update_cache(self):
288 288 update_cache = self._remote.hg_update_cache()
289 289
290 290 self._remote.invalidate_vcs_cache()
291 291 return update_cache
292 292
293 293 def hg_rebuild_fn_cache(self):
294 294 update_cache = self._remote.hg_rebuild_fn_cache()
295 295
296 296 self._remote.invalidate_vcs_cache()
297 297 return update_cache
298 298
299 299 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
300 300 log.debug('Calculating common ancestor between %sc1:%s and %sc2:%s',
301 301 self, commit_id1, repo2, commit_id2)
302 302
303 303 if commit_id1 == commit_id2:
304 304 return commit_id1
305 305
306 306 ancestors = self._remote.revs_from_revspec(
307 307 "ancestor(id(%s), id(%s))", commit_id1, commit_id2,
308 308 other_path=repo2.path)
309 309
310 310 ancestor_id = repo2[ancestors[0]].raw_id if ancestors else None
311 311
312 312 log.debug('Found common ancestor with sha: %s', ancestor_id)
313 313 return ancestor_id
314 314
315 315 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
316 316 if commit_id1 == commit_id2:
317 317 commits = []
318 318 else:
319 319 if merge:
320 320 indexes = self._remote.revs_from_revspec(
321 321 "ancestors(id(%s)) - ancestors(id(%s)) - id(%s)",
322 322 commit_id2, commit_id1, commit_id1, other_path=repo2.path)
323 323 else:
324 324 indexes = self._remote.revs_from_revspec(
325 325 "id(%s)..id(%s) - id(%s)", commit_id1, commit_id2,
326 326 commit_id1, other_path=repo2.path)
327 327
328 328 commits = [repo2.get_commit(commit_idx=idx, pre_load=pre_load)
329 329 for idx in indexes]
330 330
331 331 return commits
332 332
333 333 @staticmethod
334 334 def check_url(url, config):
335 335 """
336 336 Function will check given url and try to verify if it's a valid
337 337 link. Sometimes it may happened that mercurial will issue basic
338 338 auth request that can cause whole API to hang when used from python
339 339 or other external calls.
340 340
341 341 On failures it'll raise urllib2.HTTPError, exception is also thrown
342 342 when the return code is non 200
343 343 """
344 344 # check first if it's not an local url
345 345 if os.path.isdir(url) or url.startswith('file:'):
346 346 return True
347 347
348 348 # Request the _remote to verify the url
349 349 return connection.Hg.check_url(url, config.serialize())
350 350
351 351 @staticmethod
352 352 def is_valid_repository(path):
353 353 return os.path.isdir(os.path.join(path, '.hg'))
354 354
355 355 def _init_repo(self, create, src_url=None, do_workspace_checkout=False):
356 356 """
357 357 Function will check for mercurial repository in given path. If there
358 358 is no repository in that path it will raise an exception unless
359 359 `create` parameter is set to True - in that case repository would
360 360 be created.
361 361
362 362 If `src_url` is given, would try to clone repository from the
363 363 location at given clone_point. Additionally it'll make update to
364 364 working copy accordingly to `do_workspace_checkout` flag.
365 365 """
366 366 if create and os.path.exists(self.path):
367 367 raise RepositoryError(
368 368 f"Cannot create repository at {self.path}, location already exist")
369 369
370 370 if src_url:
371 371 url = str(self._get_url(src_url))
372 372 MercurialRepository.check_url(url, self.config)
373 373
374 374 self._remote.clone(url, self.path, do_workspace_checkout)
375 375
376 376 # Don't try to create if we've already cloned repo
377 377 create = False
378 378
379 379 if create:
380 380 os.makedirs(self.path, mode=0o755)
381 381
382 382 self._remote.localrepository(create)
383 383
384 384 @LazyProperty
385 385 def in_memory_commit(self):
386 386 return MercurialInMemoryCommit(self)
387 387
388 388 @LazyProperty
389 389 def description(self):
390 390 description = self._remote.get_config_value(
391 391 'web', 'description', untrusted=True)
392 392 return safe_str(description or self.DEFAULT_DESCRIPTION)
393 393
394 394 @LazyProperty
395 395 def contact(self):
396 396 contact = (
397 397 self._remote.get_config_value("web", "contact") or
398 398 self._remote.get_config_value("ui", "username"))
399 399 return safe_str(contact or self.DEFAULT_CONTACT)
400 400
401 401 @LazyProperty
402 402 def last_change(self):
403 403 """
404 404 Returns last change made on this repository as
405 405 `datetime.datetime` object.
406 406 """
407 407 try:
408 408 return self.get_commit().date
409 409 except RepositoryError:
410 410 tzoffset = makedate()[1]
411 411 return utcdate_fromtimestamp(self._get_fs_mtime(), tzoffset)
412 412
413 413 def _get_fs_mtime(self):
414 414 # fallback to filesystem
415 415 cl_path = os.path.join(self.path, '.hg', "00changelog.i")
416 416 st_path = os.path.join(self.path, '.hg', "store")
417 417 if os.path.exists(cl_path):
418 418 return os.stat(cl_path).st_mtime
419 419 else:
420 420 return os.stat(st_path).st_mtime
421 421
422 422 def _get_url(self, url):
423 423 """
424 424 Returns normalized url. If schema is not given, would fall
425 425 to filesystem
426 426 (``file:///``) schema.
427 427 """
428 428 if url != 'default' and '://' not in url:
429 429 url = "file:" + urllib.request.pathname2url(url)
430 430 return url
431 431
432 432 def get_hook_location(self):
433 433 """
434 434 returns absolute path to location where hooks are stored
435 435 """
436 436 return os.path.join(self.path, '.hg', '.hgrc')
437 437
438 438 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None,
439 439 translate_tag=None, maybe_unreachable=False, reference_obj=None):
440 440 """
441 441 Returns ``MercurialCommit`` object representing repository's
442 442 commit at the given `commit_id` or `commit_idx`.
443 443 """
444 444 if self.is_empty():
445 445 raise EmptyRepositoryError("There are no commits yet")
446 446
447 447 if commit_id is not None:
448 448 self._validate_commit_id(commit_id)
449 449 try:
450 450 # we have cached idx, use it without contacting the remote
451 451 idx = self._commit_ids[commit_id]
452 452 return MercurialCommit(self, commit_id, idx, pre_load=pre_load)
453 453 except KeyError:
454 454 pass
455 455
456 456 elif commit_idx is not None:
457 457 self._validate_commit_idx(commit_idx)
458 458 try:
459 459 _commit_id = self.commit_ids[commit_idx]
460 460 if commit_idx < 0:
461 461 commit_idx = self.commit_ids.index(_commit_id)
462 462
463 463 return MercurialCommit(self, _commit_id, commit_idx, pre_load=pre_load)
464 464 except IndexError:
465 465 commit_id = commit_idx
466 466 else:
467 467 commit_id = "tip"
468 468
469 469 # case here is no cached version, do an actual lookup instead
470 470 try:
471 471 raw_id, idx = self._remote.lookup(commit_id, both=True)
472 472 except CommitDoesNotExistError:
473 473 msg = "Commit {} does not exist for `{}`".format(
474 474 *map(safe_str, [commit_id, self.name]))
475 475 raise CommitDoesNotExistError(msg)
476 476
477 477 return MercurialCommit(self, raw_id, idx, pre_load=pre_load)
478 478
479 479 def get_commits(
480 480 self, start_id=None, end_id=None, start_date=None, end_date=None,
481 481 branch_name=None, show_hidden=False, pre_load=None, translate_tags=None):
482 482 """
483 483 Returns generator of ``MercurialCommit`` objects from start to end
484 484 (both are inclusive)
485 485
486 486 :param start_id: None, str(commit_id)
487 487 :param end_id: None, str(commit_id)
488 488 :param start_date: if specified, commits with commit date less than
489 489 ``start_date`` would be filtered out from returned set
490 490 :param end_date: if specified, commits with commit date greater than
491 491 ``end_date`` would be filtered out from returned set
492 492 :param branch_name: if specified, commits not reachable from given
493 493 branch would be filtered out from returned set
494 494 :param show_hidden: Show hidden commits such as obsolete or hidden from
495 495 Mercurial evolve
496 496 :raise BranchDoesNotExistError: If given ``branch_name`` does not
497 497 exist.
498 498 :raise CommitDoesNotExistError: If commit for given ``start`` or
499 499 ``end`` could not be found.
500 500 """
501 501 # actually we should check now if it's not an empty repo
502 502 if self.is_empty():
503 503 raise EmptyRepositoryError("There are no commits yet")
504 504 self._validate_branch_name(branch_name)
505 505
506 506 branch_ancestors = False
507 507 if start_id is not None:
508 508 self._validate_commit_id(start_id)
509 509 c_start = self.get_commit(commit_id=start_id)
510 510 start_pos = self._commit_ids[c_start.raw_id]
511 511 else:
512 512 start_pos = None
513 513
514 514 if end_id is not None:
515 515 self._validate_commit_id(end_id)
516 516 c_end = self.get_commit(commit_id=end_id)
517 517 end_pos = max(0, self._commit_ids[c_end.raw_id])
518 518 else:
519 519 end_pos = None
520 520
521 521 if None not in [start_id, end_id] and start_pos > end_pos:
522 522 raise RepositoryError(
523 523 "Start commit '%s' cannot be after end commit '%s'" %
524 524 (start_id, end_id))
525 525
526 526 if end_pos is not None:
527 527 end_pos += 1
528 528
529 529 commit_filter = []
530 530
531 531 if branch_name and not branch_ancestors:
532 commit_filter.append('branch("{}")'.format(branch_name))
532 commit_filter.append(f'branch("{branch_name}")')
533 533 elif branch_name and branch_ancestors:
534 commit_filter.append('ancestors(branch("{}"))'.format(branch_name))
534 commit_filter.append(f'ancestors(branch("{branch_name}"))')
535 535
536 536 if start_date and not end_date:
537 commit_filter.append('date(">{}")'.format(start_date))
537 commit_filter.append(f'date(">{start_date}")')
538 538 if end_date and not start_date:
539 commit_filter.append('date("<{}")'.format(end_date))
539 commit_filter.append(f'date("<{end_date}")')
540 540 if start_date and end_date:
541 541 commit_filter.append(
542 'date(">{}") and date("<{}")'.format(start_date, end_date))
542 f'date(">{start_date}") and date("<{end_date}")')
543 543
544 544 if not show_hidden:
545 545 commit_filter.append('not obsolete()')
546 546 commit_filter.append('not hidden()')
547 547
548 548 # TODO: johbo: Figure out a simpler way for this solution
549 549 collection_generator = CollectionGenerator
550 550 if commit_filter:
551 551 commit_filter = ' and '.join(map(safe_str, commit_filter))
552 552 revisions = self._remote.rev_range([commit_filter])
553 553 collection_generator = MercurialIndexBasedCollectionGenerator
554 554 else:
555 555 revisions = self.commit_ids
556 556
557 557 if start_pos or end_pos:
558 558 revisions = revisions[start_pos:end_pos]
559 559
560 560 return collection_generator(self, revisions, pre_load=pre_load)
561 561
562 562 def pull(self, url, commit_ids=None):
563 563 """
564 564 Pull changes from external location.
565 565
566 566 :param commit_ids: Optional. Can be set to a list of commit ids
567 567 which shall be pulled from the other repository.
568 568 """
569 569 url = self._get_url(url)
570 570 self._remote.pull(url, commit_ids=commit_ids)
571 571 self._remote.invalidate_vcs_cache()
572 572
573 573 def fetch(self, url, commit_ids=None):
574 574 """
575 575 Backward compatibility with GIT fetch==pull
576 576 """
577 577 return self.pull(url, commit_ids=commit_ids)
578 578
579 579 def push(self, url):
580 580 url = self._get_url(url)
581 581 self._remote.sync_push(url)
582 582
583 583 def _local_clone(self, clone_path):
584 584 """
585 585 Create a local clone of the current repo.
586 586 """
587 587 self._remote.clone(self.path, clone_path, update_after_clone=True,
588 588 hooks=False)
589 589
590 590 def _update(self, revision, clean=False):
591 591 """
592 592 Update the working copy to the specified revision.
593 593 """
594 594 log.debug('Doing checkout to commit: `%s` for %s', revision, self)
595 595 self._remote.update(revision, clean=clean)
596 596
597 597 def _identify(self):
598 598 """
599 599 Return the current state of the working directory.
600 600 """
601 601 return self._remote.identify().strip().rstrip('+')
602 602
603 603 def _heads(self, branch=None):
604 604 """
605 605 Return the commit ids of the repository heads.
606 606 """
607 607 return self._remote.heads(branch=branch).strip().split(' ')
608 608
609 609 def _ancestor(self, revision1, revision2):
610 610 """
611 611 Return the common ancestor of the two revisions.
612 612 """
613 613 return self._remote.ancestor(revision1, revision2)
614 614
615 615 def _local_push(
616 616 self, revision, repository_path, push_branches=False,
617 617 enable_hooks=False):
618 618 """
619 619 Push the given revision to the specified repository.
620 620
621 621 :param push_branches: allow to create branches in the target repo.
622 622 """
623 623 self._remote.push(
624 624 [revision], repository_path, hooks=enable_hooks,
625 625 push_branches=push_branches)
626 626
627 627 def _local_merge(self, target_ref, merge_message, user_name, user_email,
628 628 source_ref, use_rebase=False, close_commit_id=None, dry_run=False):
629 629 """
630 630 Merge the given source_revision into the checked out revision.
631 631
632 632 Returns the commit id of the merge and a boolean indicating if the
633 633 commit needs to be pushed.
634 634 """
635 635 source_ref_commit_id = source_ref.commit_id
636 636 target_ref_commit_id = target_ref.commit_id
637 637
638 638 # update our workdir to target ref, for proper merge
639 639 self._update(target_ref_commit_id, clean=True)
640 640
641 641 ancestor = self._ancestor(target_ref_commit_id, source_ref_commit_id)
642 642 is_the_same_branch = self._is_the_same_branch(target_ref, source_ref)
643 643
644 644 if close_commit_id:
645 645 # NOTE(marcink): if we get the close commit, this is our new source
646 646 # which will include the close commit itself.
647 647 source_ref_commit_id = close_commit_id
648 648
649 649 if ancestor == source_ref_commit_id:
650 650 # Nothing to do, the changes were already integrated
651 651 return target_ref_commit_id, False
652 652
653 653 elif ancestor == target_ref_commit_id and is_the_same_branch:
654 654 # In this case we should force a commit message
655 655 return source_ref_commit_id, True
656 656
657 657 unresolved = None
658 658 if use_rebase:
659 659 try:
660 bookmark_name = 'rcbook{}{}'.format(source_ref_commit_id, target_ref_commit_id)
660 bookmark_name = f'rcbook{source_ref_commit_id}{target_ref_commit_id}'
661 661 self.bookmark(bookmark_name, revision=source_ref.commit_id)
662 662 self._remote.rebase(
663 663 source=source_ref_commit_id, dest=target_ref_commit_id)
664 664 self._remote.invalidate_vcs_cache()
665 665 self._update(bookmark_name, clean=True)
666 666 return self._identify(), True
667 667 except RepositoryError as e:
668 668 # The rebase-abort may raise another exception which 'hides'
669 669 # the original one, therefore we log it here.
670 670 log.exception('Error while rebasing shadow repo during merge.')
671 671 if 'unresolved conflicts' in safe_str(e):
672 672 unresolved = self._remote.get_unresolved_files()
673 673 log.debug('unresolved files: %s', unresolved)
674 674
675 675 # Cleanup any rebase leftovers
676 676 self._remote.invalidate_vcs_cache()
677 677 self._remote.rebase(abort=True)
678 678 self._remote.invalidate_vcs_cache()
679 679 self._remote.update(clean=True)
680 680 if unresolved:
681 681 raise UnresolvedFilesInRepo(unresolved)
682 682 else:
683 683 raise
684 684 else:
685 685 try:
686 686 self._remote.merge(source_ref_commit_id)
687 687 self._remote.invalidate_vcs_cache()
688 688 self._remote.commit(
689 689 message=safe_str(merge_message),
690 username=safe_str('{} <{}>'.format(user_name, user_email)))
690 username=safe_str(f'{user_name} <{user_email}>'))
691 691 self._remote.invalidate_vcs_cache()
692 692 return self._identify(), True
693 693 except RepositoryError as e:
694 694 # The merge-abort may raise another exception which 'hides'
695 695 # the original one, therefore we log it here.
696 696 log.exception('Error while merging shadow repo during merge.')
697 697 if 'unresolved merge conflicts' in safe_str(e):
698 698 unresolved = self._remote.get_unresolved_files()
699 699 log.debug('unresolved files: %s', unresolved)
700 700
701 701 # Cleanup any merge leftovers
702 702 self._remote.update(clean=True)
703 703 if unresolved:
704 704 raise UnresolvedFilesInRepo(unresolved)
705 705 else:
706 706 raise
707 707
708 708 def _local_close(self, target_ref, user_name, user_email,
709 709 source_ref, close_message=''):
710 710 """
711 711 Close the branch of the given source_revision
712 712
713 713 Returns the commit id of the close and a boolean indicating if the
714 714 commit needs to be pushed.
715 715 """
716 716 self._update(source_ref.commit_id)
717 717 message = close_message or f"Closing branch: `{source_ref.name}`"
718 718 try:
719 719 self._remote.commit(
720 720 message=safe_str(message),
721 username=safe_str('{} <{}>'.format(user_name, user_email)),
721 username=safe_str(f'{user_name} <{user_email}>'),
722 722 close_branch=True)
723 723 self._remote.invalidate_vcs_cache()
724 724 return self._identify(), True
725 725 except RepositoryError:
726 726 # Cleanup any commit leftovers
727 727 self._remote.update(clean=True)
728 728 raise
729 729
730 730 def _is_the_same_branch(self, target_ref, source_ref):
731 731 return (
732 732 self._get_branch_name(target_ref) ==
733 733 self._get_branch_name(source_ref))
734 734
735 735 def _get_branch_name(self, ref):
736 736 if ref.type == 'branch':
737 737 return ref.name
738 738 return self._remote.ctx_branch(ref.commit_id)
739 739
740 740 def _maybe_prepare_merge_workspace(
741 741 self, repo_id, workspace_id, unused_target_ref, unused_source_ref):
742 742 shadow_repository_path = self._get_shadow_repository_path(
743 743 self.path, repo_id, workspace_id)
744 744 if not os.path.exists(shadow_repository_path):
745 745 self._local_clone(shadow_repository_path)
746 746 log.debug(
747 747 'Prepared shadow repository in %s', shadow_repository_path)
748 748
749 749 return shadow_repository_path
750 750
751 751 def _merge_repo(self, repo_id, workspace_id, target_ref,
752 752 source_repo, source_ref, merge_message,
753 753 merger_name, merger_email, dry_run=False,
754 754 use_rebase=False, close_branch=False):
755 755
756 756 log.debug('Executing merge_repo with %s strategy, dry_run mode:%s',
757 757 'rebase' if use_rebase else 'merge', dry_run)
758 758 if target_ref.commit_id not in self._heads():
759 759 return MergeResponse(
760 760 False, False, None, MergeFailureReason.TARGET_IS_NOT_HEAD,
761 761 metadata={'target_ref': target_ref})
762 762
763 763 try:
764 764 if target_ref.type == 'branch' and len(self._heads(target_ref.name)) != 1:
765 765 heads_all = self._heads(target_ref.name)
766 766 max_heads = 10
767 767 if len(heads_all) > max_heads:
768 768 heads = '\n,'.join(
769 769 heads_all[:max_heads] +
770 ['and {} more.'.format(len(heads_all)-max_heads)])
770 [f'and {len(heads_all)-max_heads} more.'])
771 771 else:
772 772 heads = '\n,'.join(heads_all)
773 773 metadata = {
774 774 'target_ref': target_ref,
775 775 'source_ref': source_ref,
776 776 'heads': heads
777 777 }
778 778 return MergeResponse(
779 779 False, False, None,
780 780 MergeFailureReason.HG_TARGET_HAS_MULTIPLE_HEADS,
781 781 metadata=metadata)
782 782 except CommitDoesNotExistError:
783 783 log.exception('Failure when looking up branch heads on hg target')
784 784 return MergeResponse(
785 785 False, False, None, MergeFailureReason.MISSING_TARGET_REF,
786 786 metadata={'target_ref': target_ref})
787 787
788 788 shadow_repository_path = self._maybe_prepare_merge_workspace(
789 789 repo_id, workspace_id, target_ref, source_ref)
790 790 shadow_repo = self.get_shadow_instance(shadow_repository_path)
791 791
792 792 log.debug('Pulling in target reference %s', target_ref)
793 793 self._validate_pull_reference(target_ref)
794 794 shadow_repo._local_pull(self.path, target_ref)
795 795
796 796 try:
797 797 log.debug('Pulling in source reference %s', source_ref)
798 798 source_repo._validate_pull_reference(source_ref)
799 799 shadow_repo._local_pull(source_repo.path, source_ref)
800 800 except CommitDoesNotExistError:
801 801 log.exception('Failure when doing local pull on hg shadow repo')
802 802 return MergeResponse(
803 803 False, False, None, MergeFailureReason.MISSING_SOURCE_REF,
804 804 metadata={'source_ref': source_ref})
805 805
806 806 merge_ref = None
807 807 merge_commit_id = None
808 808 close_commit_id = None
809 809 merge_failure_reason = MergeFailureReason.NONE
810 810 metadata = {}
811 811
812 812 # enforce that close branch should be used only in case we source from
813 813 # an actual Branch
814 814 close_branch = close_branch and source_ref.type == 'branch'
815 815
816 816 # don't allow to close branch if source and target are the same
817 817 close_branch = close_branch and source_ref.name != target_ref.name
818 818
819 819 needs_push_on_close = False
820 820 if close_branch and not use_rebase and not dry_run:
821 821 try:
822 822 close_commit_id, needs_push_on_close = shadow_repo._local_close(
823 823 target_ref, merger_name, merger_email, source_ref)
824 824 merge_possible = True
825 825 except RepositoryError:
826 826 log.exception('Failure when doing close branch on '
827 827 'shadow repo: %s', shadow_repo)
828 828 merge_possible = False
829 829 merge_failure_reason = MergeFailureReason.MERGE_FAILED
830 830 else:
831 831 merge_possible = True
832 832
833 833 needs_push = False
834 834 if merge_possible:
835 835
836 836 try:
837 837 merge_commit_id, needs_push = shadow_repo._local_merge(
838 838 target_ref, merge_message, merger_name, merger_email,
839 839 source_ref, use_rebase=use_rebase,
840 840 close_commit_id=close_commit_id, dry_run=dry_run)
841 841 merge_possible = True
842 842
843 843 # read the state of the close action, if it
844 844 # maybe required a push
845 845 needs_push = needs_push or needs_push_on_close
846 846
847 847 # Set a bookmark pointing to the merge commit. This bookmark
848 848 # may be used to easily identify the last successful merge
849 849 # commit in the shadow repository.
850 850 shadow_repo.bookmark('pr-merge', revision=merge_commit_id)
851 851 merge_ref = Reference('book', 'pr-merge', merge_commit_id)
852 852 except SubrepoMergeError:
853 853 log.exception(
854 854 'Subrepo merge error during local merge on hg shadow repo.')
855 855 merge_possible = False
856 856 merge_failure_reason = MergeFailureReason.SUBREPO_MERGE_FAILED
857 857 needs_push = False
858 858 except RepositoryError as e:
859 859 log.exception('Failure when doing local merge on hg shadow repo')
860 860 if isinstance(e, UnresolvedFilesInRepo):
861 861 all_conflicts = list(e.args[0])
862 862 max_conflicts = 20
863 863 if len(all_conflicts) > max_conflicts:
864 864 conflicts = all_conflicts[:max_conflicts] \
865 + ['and {} more.'.format(len(all_conflicts)-max_conflicts)]
865 + [f'and {len(all_conflicts)-max_conflicts} more.']
866 866 else:
867 867 conflicts = all_conflicts
868 868 metadata['unresolved_files'] = \
869 869 '\n* conflict: ' + \
870 870 ('\n * conflict: '.join(conflicts))
871 871
872 872 merge_possible = False
873 873 merge_failure_reason = MergeFailureReason.MERGE_FAILED
874 874 needs_push = False
875 875
876 876 if merge_possible and not dry_run:
877 877 if needs_push:
878 878 # In case the target is a bookmark, update it, so after pushing
879 879 # the bookmarks is also updated in the target.
880 880 if target_ref.type == 'book':
881 881 shadow_repo.bookmark(
882 882 target_ref.name, revision=merge_commit_id)
883 883 try:
884 884 shadow_repo_with_hooks = self.get_shadow_instance(
885 885 shadow_repository_path,
886 886 enable_hooks=True)
887 887 # This is the actual merge action, we push from shadow
888 888 # into origin.
889 889 # Note: the push_branches option will push any new branch
890 890 # defined in the source repository to the target. This may
891 891 # be dangerous as branches are permanent in Mercurial.
892 892 # This feature was requested in issue #441.
893 893 shadow_repo_with_hooks._local_push(
894 894 merge_commit_id, self.path, push_branches=True,
895 895 enable_hooks=True)
896 896
897 897 # maybe we also need to push the close_commit_id
898 898 if close_commit_id:
899 899 shadow_repo_with_hooks._local_push(
900 900 close_commit_id, self.path, push_branches=True,
901 901 enable_hooks=True)
902 902 merge_succeeded = True
903 903 except RepositoryError:
904 904 log.exception(
905 905 'Failure when doing local push from the shadow '
906 906 'repository to the target repository at %s.', self.path)
907 907 merge_succeeded = False
908 908 merge_failure_reason = MergeFailureReason.PUSH_FAILED
909 909 metadata['target'] = 'hg shadow repo'
910 910 metadata['merge_commit'] = merge_commit_id
911 911 else:
912 912 merge_succeeded = True
913 913 else:
914 914 merge_succeeded = False
915 915
916 916 return MergeResponse(
917 917 merge_possible, merge_succeeded, merge_ref, merge_failure_reason,
918 918 metadata=metadata)
919 919
920 920 def get_shadow_instance(self, shadow_repository_path, enable_hooks=False, cache=False):
921 921 config = self.config.copy()
922 922 if not enable_hooks:
923 923 config.clear_section('hooks')
924 924 return MercurialRepository(shadow_repository_path, config, with_wire={"cache": cache})
925 925
926 926 def _validate_pull_reference(self, reference):
927 927 if not (reference.name in self.bookmarks or
928 928 reference.name in self.branches or
929 929 self.get_commit(reference.commit_id)):
930 930 raise CommitDoesNotExistError(
931 931 'Unknown branch, bookmark or commit id')
932 932
933 933 def _local_pull(self, repository_path, reference):
934 934 """
935 935 Fetch a branch, bookmark or commit from a local repository.
936 936 """
937 937 repository_path = os.path.abspath(repository_path)
938 938 if repository_path == self.path:
939 939 raise ValueError('Cannot pull from the same repository')
940 940
941 941 reference_type_to_option_name = {
942 942 'book': 'bookmark',
943 943 'branch': 'branch',
944 944 }
945 945 option_name = reference_type_to_option_name.get(
946 946 reference.type, 'revision')
947 947
948 948 if option_name == 'revision':
949 949 ref = reference.commit_id
950 950 else:
951 951 ref = reference.name
952 952
953 953 options = {option_name: [ref]}
954 954 self._remote.pull_cmd(repository_path, hooks=False, **options)
955 955 self._remote.invalidate_vcs_cache()
956 956
957 957 def bookmark(self, bookmark, revision=None):
958 958 if isinstance(bookmark, str):
959 959 bookmark = safe_str(bookmark)
960 960 self._remote.bookmark(bookmark, revision=revision)
961 961 self._remote.invalidate_vcs_cache()
962 962
963 963 def get_path_permissions(self, username):
964 964 hgacl_file = os.path.join(self.path, '.hg/hgacl')
965 965
966 966 def read_patterns(suffix):
967 967 svalue = None
968 968 for section, option in [
969 969 ('narrowacl', username + suffix),
970 970 ('narrowacl', 'default' + suffix),
971 971 ('narrowhgacl', username + suffix),
972 972 ('narrowhgacl', 'default' + suffix)
973 973 ]:
974 974 try:
975 975 svalue = hgacl.get(section, option)
976 976 break # stop at the first value we find
977 977 except configparser.NoOptionError:
978 978 pass
979 979 if not svalue:
980 980 return None
981 981 result = ['/']
982 982 for pattern in svalue.split():
983 983 result.append(pattern)
984 984 if '*' not in pattern and '?' not in pattern:
985 985 result.append(pattern + '/*')
986 986 return result
987 987
988 988 if os.path.exists(hgacl_file):
989 989 try:
990 990 hgacl = configparser.RawConfigParser()
991 991 hgacl.read(hgacl_file)
992 992
993 993 includes = read_patterns('.includes')
994 994 excludes = read_patterns('.excludes')
995 995 return BasePathPermissionChecker.create_from_patterns(
996 996 includes, excludes)
997 997 except BaseException as e:
998 998 msg = 'Cannot read ACL settings from {} on {}: {}'.format(
999 999 hgacl_file, self.name, e)
1000 1000 raise exceptions.RepositoryRequirementError(msg)
1001 1001 else:
1002 1002 return None
1003 1003
1004 1004
1005 1005 class MercurialIndexBasedCollectionGenerator(CollectionGenerator):
1006 1006
1007 1007 def _commit_factory(self, commit_id):
1008 1008 if isinstance(commit_id, int):
1009 1009 return self.repo.get_commit(
1010 1010 commit_idx=commit_id, pre_load=self.pre_load)
1011 1011 else:
1012 1012 return self.repo.get_commit(
1013 1013 commit_id=commit_id, pre_load=self.pre_load)
@@ -1,254 +1,254 b''
1 1 # Copyright (C) 2014-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
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 Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 """
20 20 SVN commit module
21 21 """
22 22
23 23
24 24 import dateutil.parser
25 25 from zope.cachedescriptors.property import Lazy as LazyProperty
26 26
27 27 from rhodecode.lib.str_utils import safe_bytes, safe_str
28 28 from rhodecode.lib.vcs import nodes, path as vcspath
29 29 from rhodecode.lib.vcs.backends import base
30 30 from rhodecode.lib.vcs.exceptions import CommitError
31 31
32 32
33 33 _SVN_PROP_TRUE = '*'
34 34
35 35
36 36 class SubversionCommit(base.BaseCommit):
37 37 """
38 38 Subversion specific implementation of commits
39 39
40 40 .. attribute:: branch
41 41
42 42 The Subversion backend does not support to assign branches to
43 43 specific commits. This attribute has always the value `None`.
44 44
45 45 """
46 46
47 47 def __init__(self, repository, commit_id):
48 48 self.repository = repository
49 49 self.idx = self.repository._get_commit_idx(commit_id)
50 50 self._svn_rev = self.idx + 1
51 51 self._remote = repository._remote
52 52 # TODO: handling of raw_id should be a method on repository itself,
53 53 # which knows how to translate commit index and commit id
54 54 self.raw_id = commit_id
55 55 self.short_id = commit_id
56 self.id = 'r{}'.format(commit_id)
56 self.id = f'r{commit_id}'
57 57
58 58 # TODO: Implement the following placeholder attributes
59 59 self.nodes = {}
60 60 self.tags = []
61 61
62 62 @property
63 63 def author(self):
64 64 return safe_str(self._properties.get('svn:author'))
65 65
66 66 @property
67 67 def date(self):
68 68 return _date_from_svn_properties(self._properties)
69 69
70 70 @property
71 71 def message(self):
72 72 return safe_str(self._properties.get('svn:log'))
73 73
74 74 @LazyProperty
75 75 def _properties(self):
76 76 return self._remote.revision_properties(self._svn_rev)
77 77
78 78 @LazyProperty
79 79 def parents(self):
80 80 parent_idx = self.idx - 1
81 81 if parent_idx >= 0:
82 82 parent = self.repository.get_commit(commit_idx=parent_idx)
83 83 return [parent]
84 84 return []
85 85
86 86 @LazyProperty
87 87 def children(self):
88 88 child_idx = self.idx + 1
89 89 if child_idx < len(self.repository.commit_ids):
90 90 child = self.repository.get_commit(commit_idx=child_idx)
91 91 return [child]
92 92 return []
93 93
94 94 def get_file_mode(self, path: bytes):
95 95 # Note: Subversion flags files which are executable with a special
96 96 # property `svn:executable` which is set to the value ``"*"``.
97 97 if self._get_file_property(path, 'svn:executable') == _SVN_PROP_TRUE:
98 98 return base.FILEMODE_EXECUTABLE
99 99 else:
100 100 return base.FILEMODE_DEFAULT
101 101
102 102 def is_link(self, path):
103 103 # Note: Subversion has a flag for special files, the content of the
104 104 # file contains the type of that file.
105 105 if self._get_file_property(path, 'svn:special') == _SVN_PROP_TRUE:
106 106 return self.get_file_content(path).startswith(b'link')
107 107 return False
108 108
109 109 def is_node_binary(self, path):
110 110 path = self._fix_path(path)
111 111 return self._remote.is_binary(self._svn_rev, safe_str(path))
112 112
113 113 def node_md5_hash(self, path):
114 114 path = self._fix_path(path)
115 115 return self._remote.md5_hash(self._svn_rev, safe_str(path))
116 116
117 117 def _get_file_property(self, path, name):
118 118 file_properties = self._remote.node_properties(
119 119 safe_str(path), self._svn_rev)
120 120 return file_properties.get(name)
121 121
122 122 def get_file_content(self, path):
123 123 path = self._fix_path(path)
124 124 return self._remote.get_file_content(self._svn_rev, safe_str(path))
125 125
126 126 def get_file_content_streamed(self, path):
127 127 path = self._fix_path(path)
128 128
129 129 stream_method = getattr(self._remote, 'stream:get_file_content')
130 130 return stream_method(self._svn_rev, safe_str(path))
131 131
132 132 def get_file_size(self, path):
133 133 path = self._fix_path(path)
134 134 return self._remote.get_file_size(self._svn_rev, safe_str(path))
135 135
136 136 def get_path_history(self, path, limit=None, pre_load=None):
137 137 path = safe_str(self._fix_path(path))
138 138 history = self._remote.node_history(path, self._svn_rev, limit)
139 139 return [
140 140 self.repository.get_commit(commit_id=str(svn_rev))
141 141 for svn_rev in history]
142 142
143 143 def get_file_annotate(self, path, pre_load=None):
144 144 result = self._remote.file_annotate(safe_str(path), self._svn_rev)
145 145
146 146 for zero_based_line_no, svn_rev, content in result:
147 147 commit_id = str(svn_rev)
148 148 line_no = zero_based_line_no + 1
149 149 yield (
150 150 line_no,
151 151 commit_id,
152 152 lambda: self.repository.get_commit(commit_id=commit_id),
153 153 content)
154 154
155 155 def get_node(self, path, pre_load=None):
156 156 path = self._fix_path(path)
157 157 if path not in self.nodes:
158 158
159 159 if path == '':
160 160 node = nodes.RootNode(commit=self)
161 161 else:
162 162 node_type = self._remote.get_node_type(self._svn_rev, safe_str(path))
163 163 if node_type == 'dir':
164 164 node = nodes.DirNode(safe_bytes(path), commit=self)
165 165 elif node_type == 'file':
166 166 node = nodes.FileNode(safe_bytes(path), commit=self, pre_load=pre_load)
167 167 else:
168 168 raise self.no_node_at_path(path)
169 169
170 170 self.nodes[path] = node
171 171 return self.nodes[path]
172 172
173 173 def get_nodes(self, path, pre_load=None):
174 174 if self._get_kind(path) != nodes.NodeKind.DIR:
175 175 raise CommitError(
176 176 f"Directory does not exist for commit {self.raw_id} at '{path}'")
177 177 path = safe_str(self._fix_path(path))
178 178
179 179 path_nodes = []
180 180 for name, kind in self._remote.get_nodes(self._svn_rev, path):
181 181 node_path = vcspath.join(path, name)
182 182 if kind == 'dir':
183 183 node = nodes.DirNode(safe_bytes(node_path), commit=self)
184 184 elif kind == 'file':
185 185 node = nodes.FileNode(safe_bytes(node_path), commit=self, pre_load=pre_load)
186 186 else:
187 187 raise ValueError(f"Node kind {kind} not supported.")
188 188 self.nodes[node_path] = node
189 189 path_nodes.append(node)
190 190
191 191 return path_nodes
192 192
193 193 def _get_kind(self, path):
194 194 path = self._fix_path(path)
195 195 kind = self._remote.get_node_type(self._svn_rev, path)
196 196 if kind == 'file':
197 197 return nodes.NodeKind.FILE
198 198 elif kind == 'dir':
199 199 return nodes.NodeKind.DIR
200 200 else:
201 201 raise CommitError(
202 "Node does not exist at the given path '{}'".format(path))
202 f"Node does not exist at the given path '{path}'")
203 203
204 204 @LazyProperty
205 205 def _changes_cache(self):
206 206 return self._remote.revision_changes(self._svn_rev)
207 207
208 208 @LazyProperty
209 209 def affected_files(self):
210 210 changed_files = set()
211 211 for files in self._changes_cache.values():
212 212 changed_files.update(files)
213 213 return list(changed_files)
214 214
215 215 @LazyProperty
216 216 def id(self):
217 217 return self.raw_id
218 218
219 219 @property
220 220 def added(self):
221 221 return nodes.AddedFileNodesGenerator(self.added_paths, self)
222 222
223 223 @LazyProperty
224 224 def added_paths(self):
225 225 return [n for n in self._changes_cache['added']]
226 226
227 227 @property
228 228 def changed(self):
229 229 return nodes.ChangedFileNodesGenerator(self.changed_paths, self)
230 230
231 231 @LazyProperty
232 232 def changed_paths(self):
233 233 return [n for n in self._changes_cache['changed']]
234 234
235 235 @property
236 236 def removed(self):
237 237 return nodes.RemovedFileNodesGenerator(self.removed_paths, self)
238 238
239 239 @LazyProperty
240 240 def removed_paths(self):
241 241 return [n for n in self._changes_cache['removed']]
242 242
243 243
244 244 def _date_from_svn_properties(properties):
245 245 """
246 246 Parses the date out of given svn properties.
247 247
248 248 :return: :class:`datetime.datetime` instance. The object is naive.
249 249 """
250 250
251 251 aware_date = dateutil.parser.parse(properties.get('svn:date'))
252 252 # final_date = aware_date.astimezone(dateutil.tz.tzlocal())
253 253 final_date = aware_date
254 254 return final_date.replace(tzinfo=None)
@@ -1,367 +1,367 b''
1 1 # Copyright (C) 2014-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
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 Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 """
20 20 SVN repository module
21 21 """
22 22
23 23 import logging
24 24 import os
25 25 import urllib.request
26 26 import urllib.parse
27 27 import urllib.error
28 28
29 29 from zope.cachedescriptors.property import Lazy as LazyProperty
30 30
31 31 from collections import OrderedDict
32 32 from rhodecode.lib.datelib import date_astimestamp
33 33 from rhodecode.lib.str_utils import safe_str
34 34 from rhodecode.lib.utils2 import CachedProperty
35 35 from rhodecode.lib.vcs import connection, path as vcspath
36 36 from rhodecode.lib.vcs.backends import base
37 37 from rhodecode.lib.vcs.backends.svn.commit import (
38 38 SubversionCommit, _date_from_svn_properties)
39 39 from rhodecode.lib.vcs.backends.svn.diff import SubversionDiff
40 40 from rhodecode.lib.vcs.backends.svn.inmemory import SubversionInMemoryCommit
41 41 from rhodecode.lib.vcs.conf import settings
42 42 from rhodecode.lib.vcs.exceptions import (
43 43 CommitDoesNotExistError, EmptyRepositoryError, RepositoryError,
44 44 VCSError, NodeDoesNotExistError)
45 45
46 46
47 47 log = logging.getLogger(__name__)
48 48
49 49
50 50 class SubversionRepository(base.BaseRepository):
51 51 """
52 52 Subversion backend implementation
53 53
54 54 .. important::
55 55
56 56 It is very important to distinguish the commit index and the commit id
57 57 which is assigned by Subversion. The first one is always handled as an
58 58 `int` by this implementation. The commit id assigned by Subversion on
59 59 the other side will always be a `str`.
60 60
61 61 There is a specific trap since the first commit will have the index
62 62 ``0`` but the svn id will be ``"1"``.
63 63
64 64 """
65 65
66 66 # Note: Subversion does not really have a default branch name.
67 67 DEFAULT_BRANCH_NAME = None
68 68
69 69 contact = base.BaseRepository.DEFAULT_CONTACT
70 70 description = base.BaseRepository.DEFAULT_DESCRIPTION
71 71
72 72 def __init__(self, repo_path, config=None, create=False, src_url=None, with_wire=None,
73 73 bare=False, **kwargs):
74 74 self.path = safe_str(os.path.abspath(repo_path))
75 75 self.config = config if config else self.get_default_config()
76 76 self.with_wire = with_wire or {"cache": False} # default should not use cache
77 77
78 78 self._init_repo(create, src_url)
79 79
80 80 # caches
81 81 self._commit_ids = {}
82 82
83 83 @LazyProperty
84 84 def _remote(self):
85 85 repo_id = self.path
86 86 return connection.Svn(self.path, repo_id, self.config, with_wire=self.with_wire)
87 87
88 88 def _init_repo(self, create, src_url):
89 89 if create and os.path.exists(self.path):
90 90 raise RepositoryError(
91 91 f"Cannot create repository at {self.path}, location already exist"
92 92 )
93 93
94 94 if create:
95 95 self._remote.create_repository(settings.SVN_COMPATIBLE_VERSION)
96 96 if src_url:
97 97 src_url = _sanitize_url(src_url)
98 98 self._remote.import_remote_repository(src_url)
99 99 else:
100 100 self._check_path()
101 101
102 102 @CachedProperty
103 103 def commit_ids(self):
104 104 head = self._remote.lookup(None)
105 105 return [str(r) for r in range(1, head + 1)]
106 106
107 107 def _rebuild_cache(self, commit_ids):
108 108 pass
109 109
110 110 def run_svn_command(self, cmd, **opts):
111 111 """
112 112 Runs given ``cmd`` as svn command and returns tuple
113 113 (stdout, stderr).
114 114
115 115 :param cmd: full svn command to be executed
116 116 :param opts: env options to pass into Subprocess command
117 117 """
118 118 if not isinstance(cmd, list):
119 119 raise ValueError(f'cmd must be a list, got {type(cmd)} instead')
120 120
121 121 skip_stderr_log = opts.pop('skip_stderr_log', False)
122 122 out, err = self._remote.run_svn_command(cmd, **opts)
123 123 if err and not skip_stderr_log:
124 124 log.debug('Stderr output of svn command "%s":\n%s', cmd, err)
125 125 return out, err
126 126
127 127 @LazyProperty
128 128 def branches(self):
129 129 return self._tags_or_branches('vcs_svn_branch')
130 130
131 131 @LazyProperty
132 132 def branches_closed(self):
133 133 return {}
134 134
135 135 @LazyProperty
136 136 def bookmarks(self):
137 137 return {}
138 138
139 139 @LazyProperty
140 140 def branches_all(self):
141 141 # TODO: johbo: Implement proper branch support
142 142 all_branches = {}
143 143 all_branches.update(self.branches)
144 144 all_branches.update(self.branches_closed)
145 145 return all_branches
146 146
147 147 @LazyProperty
148 148 def tags(self):
149 149 return self._tags_or_branches('vcs_svn_tag')
150 150
151 151 def _tags_or_branches(self, config_section):
152 152 found_items = {}
153 153
154 154 if self.is_empty():
155 155 return {}
156 156
157 157 for pattern in self._patterns_from_section(config_section):
158 158 pattern = vcspath.sanitize(pattern)
159 159 tip = self.get_commit()
160 160 try:
161 161 if pattern.endswith('*'):
162 162 basedir = tip.get_node(vcspath.dirname(pattern))
163 163 directories = basedir.dirs
164 164 else:
165 165 directories = (tip.get_node(pattern), )
166 166 except NodeDoesNotExistError:
167 167 continue
168 168 found_items.update((safe_str(n.path), self.commit_ids[-1]) for n in directories)
169 169
170 170 def get_name(item):
171 171 return item[0]
172 172
173 173 return OrderedDict(sorted(found_items.items(), key=get_name))
174 174
175 175 def _patterns_from_section(self, section):
176 176 return (pattern for key, pattern in self.config.items(section))
177 177
178 178 def get_common_ancestor(self, commit_id1, commit_id2, repo2):
179 179 if self != repo2:
180 180 raise ValueError(
181 181 "Subversion does not support getting common ancestor of"
182 182 " different repositories.")
183 183
184 184 if int(commit_id1) < int(commit_id2):
185 185 return commit_id1
186 186 return commit_id2
187 187
188 188 def verify(self):
189 189 verify = self._remote.verify()
190 190
191 191 self._remote.invalidate_vcs_cache()
192 192 return verify
193 193
194 194 def compare(self, commit_id1, commit_id2, repo2, merge, pre_load=None):
195 195 # TODO: johbo: Implement better comparison, this is a very naive
196 196 # version which does not allow to compare branches, tags or folders
197 197 # at all.
198 198 if repo2 != self:
199 199 raise ValueError(
200 200 "Subversion does not support comparison of of different "
201 201 "repositories.")
202 202
203 203 if commit_id1 == commit_id2:
204 204 return []
205 205
206 206 commit_idx1 = self._get_commit_idx(commit_id1)
207 207 commit_idx2 = self._get_commit_idx(commit_id2)
208 208
209 209 commits = [
210 210 self.get_commit(commit_idx=idx)
211 211 for idx in range(commit_idx1 + 1, commit_idx2 + 1)]
212 212
213 213 return commits
214 214
215 215 def _get_commit_idx(self, commit_id):
216 216 try:
217 217 svn_rev = int(commit_id)
218 218 except:
219 219 # TODO: johbo: this might be only one case, HEAD, check this
220 220 svn_rev = self._remote.lookup(commit_id)
221 221 commit_idx = svn_rev - 1
222 222 if commit_idx >= len(self.commit_ids):
223 223 raise CommitDoesNotExistError(
224 "Commit at index {} does not exist.".format(commit_idx))
224 f"Commit at index {commit_idx} does not exist.")
225 225 return commit_idx
226 226
227 227 @staticmethod
228 228 def check_url(url, config):
229 229 """
230 230 Check if `url` is a valid source to import a Subversion repository.
231 231 """
232 232 # convert to URL if it's a local directory
233 233 if os.path.isdir(url):
234 234 url = 'file://' + urllib.request.pathname2url(url)
235 235 return connection.Svn.check_url(url, config.serialize())
236 236
237 237 @staticmethod
238 238 def is_valid_repository(path):
239 239 try:
240 240 SubversionRepository(path)
241 241 return True
242 242 except VCSError:
243 243 pass
244 244 return False
245 245
246 246 def _check_path(self):
247 247 if not os.path.exists(self.path):
248 raise VCSError('Path "{}" does not exist!'.format(self.path))
248 raise VCSError(f'Path "{self.path}" does not exist!')
249 249 if not self._remote.is_path_valid_repository(self.path):
250 250 raise VCSError(
251 251 'Path "%s" does not contain a Subversion repository' %
252 252 (self.path, ))
253 253
254 254 @LazyProperty
255 255 def last_change(self):
256 256 """
257 257 Returns last change made on this repository as
258 258 `datetime.datetime` object.
259 259 """
260 260 # Subversion always has a first commit which has id "0" and contains
261 261 # what we are looking for.
262 262 last_id = len(self.commit_ids)
263 263 properties = self._remote.revision_properties(last_id)
264 264 return _date_from_svn_properties(properties)
265 265
266 266 @LazyProperty
267 267 def in_memory_commit(self):
268 268 return SubversionInMemoryCommit(self)
269 269
270 270 def get_hook_location(self):
271 271 """
272 272 returns absolute path to location where hooks are stored
273 273 """
274 274 return os.path.join(self.path, 'hooks')
275 275
276 276 def get_commit(self, commit_id=None, commit_idx=None, pre_load=None,
277 277 translate_tag=None, maybe_unreachable=False, reference_obj=None):
278 278 if self.is_empty():
279 279 raise EmptyRepositoryError("There are no commits yet")
280 280 if commit_id is not None:
281 281 self._validate_commit_id(commit_id)
282 282 elif commit_idx is not None:
283 283 self._validate_commit_idx(commit_idx)
284 284 try:
285 285 commit_id = self.commit_ids[commit_idx]
286 286 except IndexError:
287 287 raise CommitDoesNotExistError(f'No commit with idx: {commit_idx}')
288 288
289 289 commit_id = self._sanitize_commit_id(commit_id)
290 290 commit = SubversionCommit(repository=self, commit_id=commit_id)
291 291 return commit
292 292
293 293 def get_commits(
294 294 self, start_id=None, end_id=None, start_date=None, end_date=None,
295 295 branch_name=None, show_hidden=False, pre_load=None, translate_tags=None):
296 296 if self.is_empty():
297 297 raise EmptyRepositoryError("There are no commit_ids yet")
298 298 self._validate_branch_name(branch_name)
299 299
300 300 if start_id is not None:
301 301 self._validate_commit_id(start_id)
302 302 if end_id is not None:
303 303 self._validate_commit_id(end_id)
304 304
305 305 start_raw_id = self._sanitize_commit_id(start_id)
306 306 start_pos = self.commit_ids.index(start_raw_id) if start_id else None
307 307 end_raw_id = self._sanitize_commit_id(end_id)
308 308 end_pos = max(0, self.commit_ids.index(end_raw_id)) if end_id else None
309 309
310 310 if None not in [start_id, end_id] and start_pos > end_pos:
311 311 raise RepositoryError(
312 312 "Start commit '%s' cannot be after end commit '%s'" %
313 313 (start_id, end_id))
314 314 if end_pos is not None:
315 315 end_pos += 1
316 316
317 317 # Date based filtering
318 318 if start_date or end_date:
319 319 start_raw_id, end_raw_id = self._remote.lookup_interval(
320 320 date_astimestamp(start_date) if start_date else None,
321 321 date_astimestamp(end_date) if end_date else None)
322 322 start_pos = start_raw_id - 1
323 323 end_pos = end_raw_id
324 324
325 325 commit_ids = self.commit_ids
326 326
327 327 # TODO: johbo: Reconsider impact of DEFAULT_BRANCH_NAME here
328 328 if branch_name not in [None, self.DEFAULT_BRANCH_NAME]:
329 329 svn_rev = int(self.commit_ids[-1])
330 330 commit_ids = self._remote.node_history(
331 331 path=branch_name, revision=svn_rev, limit=None)
332 332 commit_ids = [str(i) for i in reversed(commit_ids)]
333 333
334 334 if start_pos or end_pos:
335 335 commit_ids = commit_ids[start_pos:end_pos]
336 336 return base.CollectionGenerator(self, commit_ids, pre_load=pre_load)
337 337
338 338 def _sanitize_commit_id(self, commit_id):
339 339 if commit_id and commit_id.isdigit():
340 340 if int(commit_id) <= len(self.commit_ids):
341 341 return commit_id
342 342 else:
343 343 raise CommitDoesNotExistError(
344 "Commit {} does not exist.".format(commit_id))
344 f"Commit {commit_id} does not exist.")
345 345 if commit_id not in [
346 346 None, 'HEAD', 'tip', self.DEFAULT_BRANCH_NAME]:
347 347 raise CommitDoesNotExistError(
348 "Commit id {} not understood.".format(commit_id))
348 f"Commit id {commit_id} not understood.")
349 349 svn_rev = self._remote.lookup('HEAD')
350 350 return str(svn_rev)
351 351
352 352 def get_diff(
353 353 self, commit1, commit2, path=None, ignore_whitespace=False,
354 354 context=3, path1=None):
355 355 self._validate_diff_commits(commit1, commit2)
356 356 svn_rev1 = int(commit1.raw_id)
357 357 svn_rev2 = int(commit2.raw_id)
358 358 diff = self._remote.diff(
359 359 svn_rev1, svn_rev2, path1=path1, path2=path,
360 360 ignore_whitespace=ignore_whitespace, context=context)
361 361 return SubversionDiff(diff)
362 362
363 363
364 364 def _sanitize_url(url):
365 365 if '://' not in url:
366 366 url = 'file://' + urllib.request.pathname2url(url)
367 367 return url
@@ -1,159 +1,159 b''
1 1 # Copyright (C) 2014-2023 RhodeCode GmbH
2 2 #
3 3 # This program is free software: you can redistribute it and/or modify
4 4 # it under the terms of the GNU Affero General Public License, version 3
5 5 # (only), as published by the Free Software Foundation.
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 Affero General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 #
15 15 # This program is dual-licensed. If you wish to learn more about the
16 16 # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 """
20 20 Utilities aimed to help achieve mostly basic tasks.
21 21 """
22 22
23 23
24 24
25 25
26 26 import re
27 27 import os
28 28 import time
29 29 import datetime
30 30 import logging
31 31
32 32 from rhodecode.lib.vcs.conf import settings
33 33 from rhodecode.lib.vcs.exceptions import VCSError, VCSBackendNotSupportedError
34 34
35 35
36 36 log = logging.getLogger(__name__)
37 37
38 38
39 39 def get_scm(path):
40 40 """
41 41 Returns one of alias from ``ALIASES`` (in order of precedence same as
42 42 shortcuts given in ``ALIASES``) and working dir path for the given
43 43 argument. If no scm-specific directory is found or more than one scm is
44 44 found at that directory, ``VCSError`` is raised.
45 45 """
46 46 if not os.path.isdir(path):
47 47 raise VCSError("Given path %s is not a directory" % path)
48 48
49 49 found_scms = [(scm, path) for scm in get_scms_for_path(path)]
50 50
51 51 if len(found_scms) > 1:
52 52 found = ', '.join(x[0] for x in found_scms)
53 53 raise VCSError(
54 'More than one [{}] scm found at given path {}'.format(found, path))
54 f'More than one [{found}] scm found at given path {path}')
55 55
56 56 if len(found_scms) == 0:
57 57 raise VCSError('No scm found at given path %s' % path)
58 58
59 59 return found_scms[0]
60 60
61 61
62 62 def get_scm_backend(backend_type):
63 63 from rhodecode.lib.vcs.backends import get_backend
64 64 return get_backend(backend_type)
65 65
66 66
67 67 def get_scms_for_path(path):
68 68 """
69 69 Returns all scm's found at the given path. If no scm is recognized
70 70 - empty list is returned.
71 71
72 72 :param path: path to directory which should be checked. May be callable.
73 73
74 74 :raises VCSError: if given ``path`` is not a directory
75 75 """
76 76 from rhodecode.lib.vcs.backends import get_backend
77 77 if hasattr(path, '__call__'):
78 78 path = path()
79 79 if not os.path.isdir(path):
80 80 raise VCSError("Given path %r is not a directory" % path)
81 81
82 82 result = []
83 83 for key in settings.available_aliases():
84 84 try:
85 85 backend = get_backend(key)
86 86 except VCSBackendNotSupportedError:
87 87 log.warning('VCSBackendNotSupportedError: %s not supported', key)
88 88 continue
89 89 if backend.is_valid_repository(path):
90 90 result.append(key)
91 91 return result
92 92
93 93
94 94 def parse_datetime(text):
95 95 """
96 96 Parses given text and returns ``datetime.datetime`` instance or raises
97 97 ``ValueError``.
98 98
99 99 :param text: string of desired date/datetime or something more verbose,
100 100 like *yesterday*, *2weeks 3days*, etc.
101 101 """
102 102 if not text:
103 103 raise ValueError('Wrong date: "%s"' % text)
104 104
105 105 if isinstance(text, datetime.datetime):
106 106 return text
107 107
108 108 # we limit a format to no include microseconds e.g 2017-10-17t17:48:23.XXXX
109 109 text = text.strip().lower()[:19]
110 110
111 111 input_formats = (
112 112 '%Y-%m-%d %H:%M:%S',
113 113 '%Y-%m-%dt%H:%M:%S',
114 114 '%Y-%m-%d %H:%M',
115 115 '%Y-%m-%dt%H:%M',
116 116 '%Y-%m-%d',
117 117 '%m/%d/%Y %H:%M:%S',
118 118 '%m/%d/%Yt%H:%M:%S',
119 119 '%m/%d/%Y %H:%M',
120 120 '%m/%d/%Yt%H:%M',
121 121 '%m/%d/%Y',
122 122 '%m/%d/%y %H:%M:%S',
123 123 '%m/%d/%yt%H:%M:%S',
124 124 '%m/%d/%y %H:%M',
125 125 '%m/%d/%yt%H:%M',
126 126 '%m/%d/%y',
127 127 )
128 128 for format_def in input_formats:
129 129 try:
130 130 return datetime.datetime(*time.strptime(text, format_def)[:6])
131 131 except ValueError:
132 132 pass
133 133
134 134 # Try descriptive texts
135 135 if text == 'tomorrow':
136 136 future = datetime.datetime.now() + datetime.timedelta(days=1)
137 137 args = future.timetuple()[:3] + (23, 59, 59)
138 138 return datetime.datetime(*args)
139 139 elif text == 'today':
140 140 return datetime.datetime(*datetime.datetime.today().timetuple()[:3])
141 141 elif text == 'now':
142 142 return datetime.datetime.now()
143 143 elif text == 'yesterday':
144 144 past = datetime.datetime.now() - datetime.timedelta(days=1)
145 145 return datetime.datetime(*past.timetuple()[:3])
146 146 else:
147 147 days = 0
148 148 matched = re.match(
149 149 r'^((?P<weeks>\d+) ?w(eeks?)?)? ?((?P<days>\d+) ?d(ays?)?)?$', text)
150 150 if matched:
151 151 groupdict = matched.groupdict()
152 152 if groupdict['days']:
153 153 days += int(matched.groupdict()['days'])
154 154 if groupdict['weeks']:
155 155 days += int(matched.groupdict()['weeks']) * 7
156 156 past = datetime.datetime.now() - datetime.timedelta(days=days)
157 157 return datetime.datetime(*past.timetuple()[:3])
158 158
159 159 raise ValueError('Wrong date: "%s"' % text)
@@ -1,88 +1,87 b''
1
2 1 # Copyright (C) 2010-2023 RhodeCode GmbH
3 2 #
4 3 # This program is free software: you can redistribute it and/or modify
5 4 # it under the terms of the GNU Affero General Public License, version 3
6 5 # (only), as published by the Free Software Foundation.
7 6 #
8 7 # This program is distributed in the hope that it will be useful,
9 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 10 # GNU General Public License for more details.
12 11 #
13 12 # You should have received a copy of the GNU Affero General Public License
14 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 14 #
16 15 # This program is dual-licensed. If you wish to learn more about the
17 16 # RhodeCode Enterprise Edition, including its added features, Support services,
18 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 18
20 19 """
21 20 Utilities to be shared by multiple controllers.
22 21
23 22 Should only contain utilities to be shared in the controller layer.
24 23 """
25 24
26 25 from rhodecode.lib import helpers as h
27 26 from rhodecode.lib.vcs.exceptions import RepositoryError
28 27
29 28
30 29 def parse_path_ref(ref, default_path=None):
31 30 """
32 31 Parse out a path and reference combination and return both parts of it.
33 32
34 33 This is used to allow support of path based comparisons for Subversion
35 34 as an iterim solution in parameter handling.
36 35 """
37 36 if '@' in ref:
38 37 return ref.rsplit('@', 1)
39 38 else:
40 39 return default_path, ref
41 40
42 41
43 42 def get_format_ref_id(repo):
44 43 """Returns a `repo` specific reference formatter function"""
45 44 if h.is_svn(repo):
46 45 return _format_ref_id_svn
47 46 else:
48 47 return _format_ref_id
49 48
50 49
51 50 def _format_ref_id(name, raw_id):
52 51 """Default formatting of a given reference `name`"""
53 52 return name
54 53
55 54
56 55 def _format_ref_id_svn(name, raw_id):
57 56 """Special way of formatting a reference for Subversion including path"""
58 return '%s@%s' % (name, raw_id)
57 return '{}@{}'.format(name, raw_id)
59 58
60 59
61 60 def get_commit_from_ref_name(repo, ref_name, ref_type=None):
62 61 """
63 62 Gets the commit for a `ref_name` taking into account `ref_type`.
64 63 Needed in case a bookmark / tag share the same name.
65 64
66 65 :param repo: the repo instance
67 66 :param ref_name: the name of the ref to get
68 67 :param ref_type: optional, used to disambiguate colliding refs
69 68 """
70 69 repo_scm = repo.scm_instance()
71 70 ref_type_mapping = {
72 71 'book': repo_scm.bookmarks,
73 72 'bookmark': repo_scm.bookmarks,
74 73 'tag': repo_scm.tags,
75 74 'branch': repo_scm.branches,
76 75 }
77 76
78 77 commit_id = ref_name
79 78 if repo_scm.alias != 'svn': # pass svn refs straight to backend until
80 79 # the branch issue with svn is fixed
81 80 if ref_type and ref_type in ref_type_mapping:
82 81 try:
83 82 commit_id = ref_type_mapping[ref_type][ref_name]
84 83 except KeyError:
85 84 raise RepositoryError(
86 '%s "%s" does not exist' % (ref_type, ref_name))
85 '{} "{}" does not exist'.format(ref_type, ref_name))
87 86
88 87 return repo_scm.get_commit(commit_id)
@@ -1,141 +1,140 b''
1
2 1 # Copyright (C) 2010-2023 RhodeCode GmbH
3 2 #
4 3 # This program is free software: you can redistribute it and/or modify
5 4 # it under the terms of the GNU Affero General Public License, version 3
6 5 # (only), as published by the Free Software Foundation.
7 6 #
8 7 # This program is distributed in the hope that it will be useful,
9 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 10 # GNU General Public License for more details.
12 11 #
13 12 # You should have received a copy of the GNU Affero General Public License
14 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 14 #
16 15 # This program is dual-licensed. If you wish to learn more about the
17 16 # RhodeCode Enterprise Edition, including its added features, Support services,
18 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 18
20 19
21 20 import logging
22 21
23 22 import rhodecode
24 23 from rhodecode.model import meta, db
25 24 from rhodecode.lib.utils2 import obfuscate_url_pw, get_encryption_key
26 25
27 26 log = logging.getLogger(__name__)
28 27
29 28
30 29 def init_model(engine, encryption_key: bytes = b''):
31 30 """
32 31 Initializes db session, bind the engine with the metadata,
33 32 Call this before using any of the tables or classes in the model,
34 33 preferably once in application start
35 34
36 35 :param engine: engine to bind to
37 36 :param encryption_key: key used for encryption
38 37 """
39 38
40 39 engine_str = obfuscate_url_pw(str(engine.url))
41 40 log.info("RhodeCode %s initializing db for %s", rhodecode.__version__, engine_str)
42 41
43 42 meta.bind_engine_to_session(engine)
44 43 init_model_encryption(db, enc_key=encryption_key)
45 44
46 45
47 46 def init_model_encryption(*db_models, enc_key: bytes = b'', config=None):
48 47 if not enc_key:
49 48 from pyramid.threadlocal import get_current_registry
50 49 config = config or get_current_registry().settings
51 50 enc_key = get_encryption_key(config)
52 51
53 52 for db_model in db_models:
54 53 log.debug('setting encryption key for model %s', db_model)
55 54 db_model.ENCRYPTION_KEY = enc_key
56 55
57 56
58 57 class BaseModel(object):
59 58 """
60 59 Base Model for all RhodeCode models, it adds sql alchemy session
61 60 into instance of model
62 61
63 62 :param sa: If passed it reuses this session instead of creating a new one
64 63 """
65 64
66 65 cls = None # override in child class
67 66
68 67 def __init__(self, sa=None):
69 68 if sa is not None:
70 69 self.sa = sa
71 70 else:
72 71 self.sa = meta.Session()
73 72
74 73 def _get_instance(self, cls, instance, callback=None):
75 74 """
76 75 Gets instance of given cls using some simple lookup mechanism.
77 76
78 77 :param cls: classes to fetch
79 78 :param instance: int or Instance
80 79 :param callback: callback to call if all lookups failed
81 80 """
82 81
83 82 if isinstance(instance, cls):
84 83 return instance
85 84 elif isinstance(instance, int):
86 85 if isinstance(cls, tuple):
87 86 # if we pass multi instances we pick first to .get()
88 87 cls = cls[0]
89 88 return cls.get(instance)
90 89 else:
91 90 if instance:
92 91 if callback is None:
93 92 raise Exception(
94 93 'given object must be int or Instance of %s '
95 94 'got %s, no callback provided' % (cls, type(instance))
96 95 )
97 96 else:
98 97 return callback(instance)
99 98
100 99 def _get_user(self, user):
101 100 """
102 101 Helper method to get user by ID, or username fallback
103 102
104 103 :param user: UserID, username, or User instance
105 104 """
106 105 return self._get_instance(
107 106 db.User, user, callback=db.User.get_by_username)
108 107
109 108 def _get_user_group(self, user_group):
110 109 """
111 110 Helper method to get user by ID, or username fallback
112 111
113 112 :param user_group: UserGroupID, user_group_name, or UserGroup instance
114 113 """
115 114 return self._get_instance(
116 115 db.UserGroup, user_group, callback=db.UserGroup.get_by_group_name)
117 116
118 117 def _get_repo(self, repository):
119 118 """
120 119 Helper method to get repository by ID, or repository name
121 120
122 121 :param repository: RepoID, repository name or Repository Instance
123 122 """
124 123 return self._get_instance(
125 124 db.Repository, repository, callback=db.Repository.get_by_repo_name)
126 125
127 126 def _get_perm(self, permission):
128 127 """
129 128 Helper method to get permission by ID, or permission name
130 129
131 130 :param permission: PermissionID, permission_name or Permission instance
132 131 """
133 132 return self._get_instance(
134 133 db.Permission, permission, callback=db.Permission.get_by_key)
135 134
136 135 @classmethod
137 136 def get_all(cls):
138 137 """
139 138 Returns all instances of what is defined in `cls` class variable
140 139 """
141 140 return cls.cls.getAll()
@@ -1,124 +1,122 b''
1
2
3 1 # Copyright (C) 2013-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 """
22 20 authentication tokens model for RhodeCode
23 21 """
24 22
25 23 import time
26 24 import logging
27 25 import traceback
28 26 from sqlalchemy import or_
29 27
30 28 from rhodecode.model import BaseModel
31 29 from rhodecode.model.db import UserApiKeys
32 30 from rhodecode.model.meta import Session
33 31
34 32 log = logging.getLogger(__name__)
35 33
36 34
37 35 class AuthTokenModel(BaseModel):
38 36 cls = UserApiKeys
39 37
40 38 @classmethod
41 39 def get_lifetime_values(cls, translator):
42 40 from rhodecode.lib import helpers as h
43 41 _ = translator
44 42
45 43 def date_after_min(mins):
46 44 after = time.time() + (60 * mins)
47 45 return h.format_date(h.time_to_datetime(after))
48 46
49 47 return [
50 48 (str(-1),
51 49 _('forever')),
52 50 (str(5),
53 51 _('5 minutes {end_date}').format(end_date=date_after_min(5))),
54 52 (str(60),
55 53 _('1 hour {end_date}').format(end_date=date_after_min(60))),
56 54 (str(60 * 24),
57 55 _('1 day {end_date}').format(end_date=date_after_min(60 * 24))),
58 56 (str(60 * 24 * 30),
59 57 _('1 month {end_date}').format(end_date=date_after_min(60 * 24 * 30))),
60 58 ]
61 59
62 60 def create(self, user, description, lifetime=-1, role=UserApiKeys.ROLE_ALL):
63 61 """
64 62 :param user: user or user_id
65 63 :param description: description of ApiKey
66 64 :param lifetime: expiration time in minutes
67 65 :param role: role for the apikey
68 66 """
69 67 from rhodecode.lib.auth import generate_auth_token
70 68
71 69 user = self._get_user(user)
72 70
73 71 new_auth_token = UserApiKeys()
74 72 new_auth_token.api_key = generate_auth_token(user.username)
75 73 new_auth_token.user_id = user.user_id
76 74 new_auth_token.description = description
77 75 new_auth_token.role = role
78 76 new_auth_token.expires = time.time() + (lifetime * 60) \
79 77 if lifetime != -1 else -1
80 78 Session().add(new_auth_token)
81 79
82 80 return new_auth_token
83 81
84 82 def delete(self, auth_token_id, user=None):
85 83 """
86 84 Deletes given api_key, if user is set it also filters the object for
87 85 deletion by given user.
88 86 """
89 87 auth_token = UserApiKeys.query().filter(
90 88 UserApiKeys.user_api_key_id == auth_token_id)
91 89
92 90 if user:
93 91 user = self._get_user(user)
94 92 auth_token = auth_token.filter(UserApiKeys.user_id == user.user_id)
95 93 auth_token = auth_token.scalar()
96 94
97 95 if auth_token:
98 96 try:
99 97 Session().delete(auth_token)
100 98 except Exception:
101 99 log.error(traceback.format_exc())
102 100 raise
103 101
104 102 def get_auth_tokens(self, user, show_expired=True):
105 103 user = self._get_user(user)
106 104 user_auth_tokens = UserApiKeys.query()\
107 105 .filter(UserApiKeys.user_id == user.user_id)
108 106 if not show_expired:
109 107 user_auth_tokens = user_auth_tokens\
110 108 .filter(or_(UserApiKeys.expires == -1,
111 109 UserApiKeys.expires >= time.time()))
112 110 user_auth_tokens = user_auth_tokens.order_by(
113 111 UserApiKeys.user_api_key_id)
114 112 return user_auth_tokens
115 113
116 114 def get_auth_token(self, auth_token):
117 115 auth_token = UserApiKeys.query().filter(
118 116 UserApiKeys.api_key == auth_token)
119 117 auth_token = auth_token \
120 118 .filter(or_(UserApiKeys.expires == -1,
121 119 UserApiKeys.expires >= time.time()))\
122 120 .first()
123 121
124 122 return auth_token
@@ -1,403 +1,402 b''
1
2 1 # Copyright (C) 2010-2023 RhodeCode GmbH
3 2 #
4 3 # This program is free software: you can redistribute it and/or modify
5 4 # it under the terms of the GNU Affero General Public License, version 3
6 5 # (only), as published by the Free Software Foundation.
7 6 #
8 7 # This program is distributed in the hope that it will be useful,
9 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 10 # GNU General Public License for more details.
12 11 #
13 12 # You should have received a copy of the GNU Affero General Public License
14 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 14 #
16 15 # This program is dual-licensed. If you wish to learn more about the
17 16 # RhodeCode Enterprise Edition, including its added features, Support services,
18 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 18
20 19
21 20 import itertools
22 21 import logging
23 22 import collections
24 23
25 24 from rhodecode.model import BaseModel
26 25 from rhodecode.model.db import (
27 26 ChangesetStatus, ChangesetComment, PullRequest, PullRequestReviewers, Session)
28 27 from rhodecode.lib.exceptions import StatusChangeOnClosedPullRequestError
29 28 from rhodecode.lib.markup_renderer import (
30 29 DEFAULT_COMMENTS_RENDERER, RstTemplateRenderer)
31 30
32 31 log = logging.getLogger(__name__)
33 32
34 33
35 34 class ChangesetStatusModel(BaseModel):
36 35
37 36 cls = ChangesetStatus
38 37
39 38 def __get_changeset_status(self, changeset_status):
40 39 return self._get_instance(ChangesetStatus, changeset_status)
41 40
42 41 def __get_pull_request(self, pull_request):
43 42 return self._get_instance(PullRequest, pull_request)
44 43
45 44 def _get_status_query(self, repo, revision, pull_request,
46 45 with_revisions=False):
47 46 repo = self._get_repo(repo)
48 47
49 48 q = ChangesetStatus.query()\
50 49 .filter(ChangesetStatus.repo == repo)
51 50 if not with_revisions:
52 51 q = q.filter(ChangesetStatus.version == 0)
53 52
54 53 if revision:
55 54 q = q.filter(ChangesetStatus.revision == revision)
56 55 elif pull_request:
57 56 pull_request = self.__get_pull_request(pull_request)
58 57 # TODO: johbo: Think about the impact of this join, there must
59 58 # be a reason why ChangesetStatus and ChanagesetComment is linked
60 59 # to the pull request. Might be that we want to do the same for
61 60 # the pull_request_version_id.
62 61 q = q.join(ChangesetComment).filter(
63 62 ChangesetStatus.pull_request == pull_request,
64 63 ChangesetComment.pull_request_version_id == None)
65 64 else:
66 65 raise Exception('Please specify revision or pull_request')
67 66 q = q.order_by(ChangesetStatus.version.asc())
68 67 return q
69 68
70 69 def calculate_group_vote(self, group_id, group_statuses_by_reviewers,
71 70 trim_votes=True):
72 71 """
73 72 Calculate status based on given group members, and voting rule
74 73
75 74
76 75 group1 - 4 members, 3 required for approval
77 76 user1 - approved
78 77 user2 - reject
79 78 user3 - approved
80 79 user4 - rejected
81 80
82 81 final_state: rejected, reasons not at least 3 votes
83 82
84 83
85 84 group1 - 4 members, 2 required for approval
86 85 user1 - approved
87 86 user2 - reject
88 87 user3 - approved
89 88 user4 - rejected
90 89
91 90 final_state: approved, reasons got at least 2 approvals
92 91
93 92 group1 - 4 members, ALL required for approval
94 93 user1 - approved
95 94 user2 - reject
96 95 user3 - approved
97 96 user4 - rejected
98 97
99 98 final_state: rejected, reasons not all approvals
100 99
101 100
102 101 group1 - 4 members, ALL required for approval
103 102 user1 - approved
104 103 user2 - approved
105 104 user3 - approved
106 105 user4 - approved
107 106
108 107 final_state: approved, reason all approvals received
109 108
110 109 group1 - 4 members, 5 required for approval
111 110 (approval should be shorted to number of actual members)
112 111
113 112 user1 - approved
114 113 user2 - approved
115 114 user3 - approved
116 115 user4 - approved
117 116
118 117 final_state: approved, reason all approvals received
119 118
120 119 """
121 120 group_vote_data = {}
122 121 got_rule = False
123 122 members = collections.OrderedDict()
124 123 for review_obj, user, reasons, mandatory, statuses \
125 124 in group_statuses_by_reviewers:
126 125
127 126 if not got_rule:
128 127 group_vote_data = review_obj.rule_user_group_data()
129 128 got_rule = bool(group_vote_data)
130 129
131 130 members[user.user_id] = statuses
132 131
133 132 if not group_vote_data:
134 133 return []
135 134
136 135 required_votes = group_vote_data['vote_rule']
137 136 if required_votes == -1:
138 137 # -1 means all required, so we replace it with how many people
139 138 # are in the members
140 139 required_votes = len(members)
141 140
142 141 if trim_votes and required_votes > len(members):
143 142 # we require more votes than we have members in the group
144 143 # in this case we trim the required votes to the number of members
145 144 required_votes = len(members)
146 145
147 146 approvals = sum([
148 147 1 for statuses in members.values()
149 148 if statuses and
150 149 statuses[0][1].status == ChangesetStatus.STATUS_APPROVED])
151 150
152 151 calculated_votes = []
153 152 # we have all votes from users, now check if we have enough votes
154 153 # to fill other
155 154 fill_in = ChangesetStatus.STATUS_UNDER_REVIEW
156 155 if approvals >= required_votes:
157 156 fill_in = ChangesetStatus.STATUS_APPROVED
158 157
159 158 for member, statuses in members.items():
160 159 if statuses:
161 160 ver, latest = statuses[0]
162 161 if fill_in == ChangesetStatus.STATUS_APPROVED:
163 162 calculated_votes.append(fill_in)
164 163 else:
165 164 calculated_votes.append(latest.status)
166 165 else:
167 166 calculated_votes.append(fill_in)
168 167
169 168 return calculated_votes
170 169
171 170 def calculate_status(self, statuses_by_reviewers):
172 171 """
173 172 Given the approval statuses from reviewers, calculates final approval
174 173 status. There can only be 3 results, all approved, all rejected. If
175 174 there is no consensus the PR is under review.
176 175
177 176 :param statuses_by_reviewers:
178 177 """
179 178
180 179 def group_rule(element):
181 180 _review_obj = element[0]
182 181 rule_data = _review_obj.rule_user_group_data()
183 182 if rule_data and rule_data['id']:
184 183 return rule_data['id']
185 184 # don't return None, as we cant compare this
186 185 return 0
187 186
188 187 voting_groups = itertools.groupby(sorted(statuses_by_reviewers, key=group_rule), group_rule)
189 188
190 189 voting_by_groups = [(x, list(y)) for x, y in voting_groups]
191 190
192 191 reviewers_number = len(statuses_by_reviewers)
193 192 votes = collections.defaultdict(int)
194 193 for group, group_statuses_by_reviewers in voting_by_groups:
195 194 if group:
196 195 # calculate how the "group" voted
197 196 for vote_status in self.calculate_group_vote(
198 197 group, group_statuses_by_reviewers):
199 198 votes[vote_status] += 1
200 199 else:
201 200
202 201 for review_obj, user, reasons, mandatory, statuses \
203 202 in group_statuses_by_reviewers:
204 203 # individual vote
205 204 if statuses:
206 205 ver, latest = statuses[0]
207 206 votes[latest.status] += 1
208 207
209 208 approved_votes_count = votes[ChangesetStatus.STATUS_APPROVED]
210 209 rejected_votes_count = votes[ChangesetStatus.STATUS_REJECTED]
211 210
212 211 # TODO(marcink): with group voting, how does rejected work,
213 212 # do we ever get rejected state ?
214 213
215 214 if approved_votes_count and (approved_votes_count == reviewers_number):
216 215 return ChangesetStatus.STATUS_APPROVED
217 216
218 217 if rejected_votes_count and (rejected_votes_count == reviewers_number):
219 218 return ChangesetStatus.STATUS_REJECTED
220 219
221 220 return ChangesetStatus.STATUS_UNDER_REVIEW
222 221
223 222 def get_statuses(self, repo, revision=None, pull_request=None,
224 223 with_revisions=False):
225 224 q = self._get_status_query(repo, revision, pull_request,
226 225 with_revisions)
227 226 return q.all()
228 227
229 228 def get_status(self, repo, revision=None, pull_request=None, as_str=True):
230 229 """
231 230 Returns latest status of changeset for given revision or for given
232 231 pull request. Statuses are versioned inside a table itself and
233 232 version == 0 is always the current one
234 233
235 234 :param repo:
236 235 :param revision: 40char hash or None
237 236 :param pull_request: pull_request reference
238 237 :param as_str: return status as string not object
239 238 """
240 239 q = self._get_status_query(repo, revision, pull_request)
241 240
242 241 # need to use first here since there can be multiple statuses
243 242 # returned from pull_request
244 243 status = q.first()
245 244 if as_str:
246 245 status = status.status if status else status
247 246 st = status or ChangesetStatus.DEFAULT
248 247 return str(st)
249 248 return status
250 249
251 250 def _render_auto_status_message(
252 251 self, status, commit_id=None, pull_request=None):
253 252 """
254 253 render the message using DEFAULT_COMMENTS_RENDERER (RST renderer),
255 254 so it's always looking the same disregarding on which default
256 255 renderer system is using.
257 256
258 257 :param status: status text to change into
259 258 :param commit_id: the commit_id we change the status for
260 259 :param pull_request: the pull request we change the status for
261 260 """
262 261
263 262 new_status = ChangesetStatus.get_status_lbl(status)
264 263
265 264 params = {
266 265 'new_status_label': new_status,
267 266 'pull_request': pull_request,
268 267 'commit_id': commit_id,
269 268 }
270 269 renderer = RstTemplateRenderer()
271 270 return renderer.render('auto_status_change.mako', **params)
272 271
273 272 def set_status(self, repo, status, user, comment=None, revision=None,
274 273 pull_request=None, dont_allow_on_closed_pull_request=False):
275 274 """
276 275 Creates new status for changeset or updates the old ones bumping their
277 276 version, leaving the current status at
278 277
279 278 :param repo:
280 279 :param revision:
281 280 :param status:
282 281 :param user:
283 282 :param comment:
284 283 :param dont_allow_on_closed_pull_request: don't allow a status change
285 284 if last status was for pull request and it's closed. We shouldn't
286 285 mess around this manually
287 286 """
288 287 repo = self._get_repo(repo)
289 288
290 289 q = ChangesetStatus.query()
291 290
292 291 if revision:
293 292 q = q.filter(ChangesetStatus.repo == repo)
294 293 q = q.filter(ChangesetStatus.revision == revision)
295 294 elif pull_request:
296 295 pull_request = self.__get_pull_request(pull_request)
297 296 q = q.filter(ChangesetStatus.repo == pull_request.source_repo)
298 297 q = q.filter(ChangesetStatus.revision.in_(pull_request.revisions))
299 298 cur_statuses = q.all()
300 299
301 300 # if statuses exists and last is associated with a closed pull request
302 301 # we need to check if we can allow this status change
303 302 if (dont_allow_on_closed_pull_request and cur_statuses
304 303 and getattr(cur_statuses[0].pull_request, 'status', '')
305 304 == PullRequest.STATUS_CLOSED):
306 305 raise StatusChangeOnClosedPullRequestError(
307 306 'Changing status on closed pull request is not allowed'
308 307 )
309 308
310 309 # update all current statuses with older version
311 310 if cur_statuses:
312 311 for st in cur_statuses:
313 312 st.version += 1
314 313 Session().add(st)
315 314 Session().flush()
316 315
317 316 def _create_status(user, repo, status, comment, revision, pull_request):
318 317 new_status = ChangesetStatus()
319 318 new_status.author = self._get_user(user)
320 319 new_status.repo = self._get_repo(repo)
321 320 new_status.status = status
322 321 new_status.comment = comment
323 322 new_status.revision = revision
324 323 new_status.pull_request = pull_request
325 324 return new_status
326 325
327 326 if not comment:
328 327 from rhodecode.model.comment import CommentsModel
329 328 comment = CommentsModel().create(
330 329 text=self._render_auto_status_message(
331 330 status, commit_id=revision, pull_request=pull_request),
332 331 repo=repo,
333 332 user=user,
334 333 pull_request=pull_request,
335 334 send_email=False, renderer=DEFAULT_COMMENTS_RENDERER
336 335 )
337 336
338 337 if revision:
339 338 new_status = _create_status(
340 339 user=user, repo=repo, status=status, comment=comment,
341 340 revision=revision, pull_request=pull_request)
342 341 Session().add(new_status)
343 342 return new_status
344 343 elif pull_request:
345 344 # pull request can have more than one revision associated to it
346 345 # we need to create new version for each one
347 346 new_statuses = []
348 347 repo = pull_request.source_repo
349 348 for rev in pull_request.revisions:
350 349 new_status = _create_status(
351 350 user=user, repo=repo, status=status, comment=comment,
352 351 revision=rev, pull_request=pull_request)
353 352 new_statuses.append(new_status)
354 353 Session().add(new_status)
355 354 return new_statuses
356 355
357 356 def aggregate_votes_by_user(self, commit_statuses, reviewers_data, user=None):
358 357
359 358 commit_statuses_map = collections.defaultdict(list)
360 359 for st in commit_statuses:
361 360 commit_statuses_map[st.author.username] += [st]
362 361
363 362 reviewers = []
364 363
365 364 def version(commit_status):
366 365 return commit_status.version
367 366
368 367 for obj in reviewers_data:
369 368 if not obj.user:
370 369 continue
371 370 if user and obj.user.username != user.username:
372 371 # single user filter
373 372 continue
374 373
375 374 statuses = commit_statuses_map.get(obj.user.username, None)
376 375 if statuses:
377 376 status_groups = itertools.groupby(
378 377 sorted(statuses, key=version), version)
379 378 statuses = [(x, list(y)[0]) for x, y in status_groups]
380 379
381 380 reviewers.append((obj, obj.user, obj.reasons, obj.mandatory, statuses))
382 381
383 382 if user:
384 383 return reviewers[0] if reviewers else reviewers
385 384 else:
386 385 return reviewers
387 386
388 387 def reviewers_statuses(self, pull_request, user=None):
389 388 _commit_statuses = self.get_statuses(
390 389 pull_request.source_repo,
391 390 pull_request=pull_request,
392 391 with_revisions=True)
393 392 reviewers = pull_request.get_pull_request_reviewers(
394 393 role=PullRequestReviewers.ROLE_REVIEWER)
395 394 return self.aggregate_votes_by_user(_commit_statuses, reviewers, user=user)
396 395
397 396 def calculated_review_status(self, pull_request):
398 397 """
399 398 calculate pull request status based on reviewers, it should be a list
400 399 of two element lists.
401 400 """
402 401 reviewers = self.reviewers_statuses(pull_request)
403 402 return self.calculate_status(reviewers)
@@ -1,855 +1,852 b''
1
2
3 1 # Copyright (C) 2011-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 """
22 20 comments model for RhodeCode
23 21 """
24 22 import datetime
25 23
26 24 import logging
27 25 import traceback
28 26 import collections
29 27
30 28 from pyramid.threadlocal import get_current_registry, get_current_request
31 29 from sqlalchemy.sql.expression import null
32 30 from sqlalchemy.sql.functions import coalesce
33 31
34 32 from rhodecode.lib import helpers as h, diffs, channelstream, hooks_utils
35 33 from rhodecode.lib import audit_logger
36 34 from rhodecode.lib.exceptions import CommentVersionMismatch
37 35 from rhodecode.lib.utils2 import extract_mentioned_users, safe_str, safe_int
38 36 from rhodecode.model import BaseModel
39 37 from rhodecode.model.db import (
40 38 false, true,
41 39 ChangesetComment,
42 40 User,
43 41 Notification,
44 42 PullRequest,
45 43 AttributeDict,
46 44 ChangesetCommentHistory,
47 45 )
48 46 from rhodecode.model.notification import NotificationModel
49 47 from rhodecode.model.meta import Session
50 48 from rhodecode.model.settings import VcsSettingsModel
51 49 from rhodecode.model.notification import EmailNotificationModel
52 50 from rhodecode.model.validation_schema.schemas import comment_schema
53 51
54 52
55 53 log = logging.getLogger(__name__)
56 54
57 55
58 56 class CommentsModel(BaseModel):
59 57
60 58 cls = ChangesetComment
61 59
62 60 DIFF_CONTEXT_BEFORE = 3
63 61 DIFF_CONTEXT_AFTER = 3
64 62
65 63 def __get_commit_comment(self, changeset_comment):
66 64 return self._get_instance(ChangesetComment, changeset_comment)
67 65
68 66 def __get_pull_request(self, pull_request):
69 67 return self._get_instance(PullRequest, pull_request)
70 68
71 69 def _extract_mentions(self, s):
72 70 user_objects = []
73 71 for username in extract_mentioned_users(s):
74 72 user_obj = User.get_by_username(username, case_insensitive=True)
75 73 if user_obj:
76 74 user_objects.append(user_obj)
77 75 return user_objects
78 76
79 77 def _get_renderer(self, global_renderer='rst', request=None):
80 78 request = request or get_current_request()
81 79
82 80 try:
83 81 global_renderer = request.call_context.visual.default_renderer
84 82 except AttributeError:
85 83 log.debug("Renderer not set, falling back "
86 84 "to default renderer '%s'", global_renderer)
87 85 except Exception:
88 86 log.error(traceback.format_exc())
89 87 return global_renderer
90 88
91 89 def aggregate_comments(self, comments, versions, show_version, inline=False):
92 90 # group by versions, and count until, and display objects
93 91
94 92 comment_groups = collections.defaultdict(list)
95 93 [comment_groups[_co.pull_request_version_id].append(_co) for _co in comments]
96 94
97 95 def yield_comments(pos):
98 for co in comment_groups[pos]:
99 yield co
96 yield from comment_groups[pos]
100 97
101 98 comment_versions = collections.defaultdict(
102 99 lambda: collections.defaultdict(list))
103 100 prev_prvid = -1
104 101 # fake last entry with None, to aggregate on "latest" version which
105 102 # doesn't have an pull_request_version_id
106 103 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
107 104 prvid = ver.pull_request_version_id
108 105 if prev_prvid == -1:
109 106 prev_prvid = prvid
110 107
111 108 for co in yield_comments(prvid):
112 109 comment_versions[prvid]['at'].append(co)
113 110
114 111 # save until
115 112 current = comment_versions[prvid]['at']
116 113 prev_until = comment_versions[prev_prvid]['until']
117 114 cur_until = prev_until + current
118 115 comment_versions[prvid]['until'].extend(cur_until)
119 116
120 117 # save outdated
121 118 if inline:
122 119 outdated = [x for x in cur_until
123 120 if x.outdated_at_version(show_version)]
124 121 else:
125 122 outdated = [x for x in cur_until
126 123 if x.older_than_version(show_version)]
127 124 display = [x for x in cur_until if x not in outdated]
128 125
129 126 comment_versions[prvid]['outdated'] = outdated
130 127 comment_versions[prvid]['display'] = display
131 128
132 129 prev_prvid = prvid
133 130
134 131 return comment_versions
135 132
136 133 def get_repository_comments(self, repo, comment_type=None, user=None, commit_id=None):
137 134 qry = Session().query(ChangesetComment) \
138 135 .filter(ChangesetComment.repo == repo)
139 136
140 137 if comment_type and comment_type in ChangesetComment.COMMENT_TYPES:
141 138 qry = qry.filter(ChangesetComment.comment_type == comment_type)
142 139
143 140 if user:
144 141 user = self._get_user(user)
145 142 if user:
146 143 qry = qry.filter(ChangesetComment.user_id == user.user_id)
147 144
148 145 if commit_id:
149 146 qry = qry.filter(ChangesetComment.revision == commit_id)
150 147
151 148 qry = qry.order_by(ChangesetComment.created_on)
152 149 return qry.all()
153 150
154 151 def get_repository_unresolved_todos(self, repo):
155 152 todos = Session().query(ChangesetComment) \
156 153 .filter(ChangesetComment.repo == repo) \
157 154 .filter(ChangesetComment.resolved_by == None) \
158 155 .filter(ChangesetComment.comment_type
159 156 == ChangesetComment.COMMENT_TYPE_TODO)
160 157 todos = todos.all()
161 158
162 159 return todos
163 160
164 161 def get_pull_request_unresolved_todos(self, pull_request, show_outdated=True, include_drafts=True):
165 162
166 163 todos = Session().query(ChangesetComment) \
167 164 .filter(ChangesetComment.pull_request == pull_request) \
168 165 .filter(ChangesetComment.resolved_by == None) \
169 166 .filter(ChangesetComment.comment_type
170 167 == ChangesetComment.COMMENT_TYPE_TODO)
171 168
172 169 if not include_drafts:
173 170 todos = todos.filter(ChangesetComment.draft == false())
174 171
175 172 if not show_outdated:
176 173 todos = todos.filter(
177 174 coalesce(ChangesetComment.display_state, '') !=
178 175 ChangesetComment.COMMENT_OUTDATED)
179 176
180 177 todos = todos.all()
181 178
182 179 return todos
183 180
184 181 def get_pull_request_resolved_todos(self, pull_request, show_outdated=True, include_drafts=True):
185 182
186 183 todos = Session().query(ChangesetComment) \
187 184 .filter(ChangesetComment.pull_request == pull_request) \
188 185 .filter(ChangesetComment.resolved_by != None) \
189 186 .filter(ChangesetComment.comment_type
190 187 == ChangesetComment.COMMENT_TYPE_TODO)
191 188
192 189 if not include_drafts:
193 190 todos = todos.filter(ChangesetComment.draft == false())
194 191
195 192 if not show_outdated:
196 193 todos = todos.filter(
197 194 coalesce(ChangesetComment.display_state, '') !=
198 195 ChangesetComment.COMMENT_OUTDATED)
199 196
200 197 todos = todos.all()
201 198
202 199 return todos
203 200
204 201 def get_pull_request_drafts(self, user_id, pull_request):
205 202 drafts = Session().query(ChangesetComment) \
206 203 .filter(ChangesetComment.pull_request == pull_request) \
207 204 .filter(ChangesetComment.user_id == user_id) \
208 205 .filter(ChangesetComment.draft == true())
209 206 return drafts.all()
210 207
211 208 def get_commit_unresolved_todos(self, commit_id, show_outdated=True, include_drafts=True):
212 209
213 210 todos = Session().query(ChangesetComment) \
214 211 .filter(ChangesetComment.revision == commit_id) \
215 212 .filter(ChangesetComment.resolved_by == None) \
216 213 .filter(ChangesetComment.comment_type
217 214 == ChangesetComment.COMMENT_TYPE_TODO)
218 215
219 216 if not include_drafts:
220 217 todos = todos.filter(ChangesetComment.draft == false())
221 218
222 219 if not show_outdated:
223 220 todos = todos.filter(
224 221 coalesce(ChangesetComment.display_state, '') !=
225 222 ChangesetComment.COMMENT_OUTDATED)
226 223
227 224 todos = todos.all()
228 225
229 226 return todos
230 227
231 228 def get_commit_resolved_todos(self, commit_id, show_outdated=True, include_drafts=True):
232 229
233 230 todos = Session().query(ChangesetComment) \
234 231 .filter(ChangesetComment.revision == commit_id) \
235 232 .filter(ChangesetComment.resolved_by != None) \
236 233 .filter(ChangesetComment.comment_type
237 234 == ChangesetComment.COMMENT_TYPE_TODO)
238 235
239 236 if not include_drafts:
240 237 todos = todos.filter(ChangesetComment.draft == false())
241 238
242 239 if not show_outdated:
243 240 todos = todos.filter(
244 241 coalesce(ChangesetComment.display_state, '') !=
245 242 ChangesetComment.COMMENT_OUTDATED)
246 243
247 244 todos = todos.all()
248 245
249 246 return todos
250 247
251 248 def get_commit_inline_comments(self, commit_id, include_drafts=True):
252 249 inline_comments = Session().query(ChangesetComment) \
253 250 .filter(ChangesetComment.line_no != None) \
254 251 .filter(ChangesetComment.f_path != None) \
255 252 .filter(ChangesetComment.revision == commit_id)
256 253
257 254 if not include_drafts:
258 255 inline_comments = inline_comments.filter(ChangesetComment.draft == false())
259 256
260 257 inline_comments = inline_comments.all()
261 258 return inline_comments
262 259
263 260 def _log_audit_action(self, action, action_data, auth_user, comment):
264 261 audit_logger.store(
265 262 action=action,
266 263 action_data=action_data,
267 264 user=auth_user,
268 265 repo=comment.repo)
269 266
270 267 def create(self, text, repo, user, commit_id=None, pull_request=None,
271 268 f_path=None, line_no=None, status_change=None,
272 269 status_change_type=None, comment_type=None, is_draft=False,
273 270 resolves_comment_id=None, closing_pr=False, send_email=True,
274 271 renderer=None, auth_user=None, extra_recipients=None):
275 272 """
276 273 Creates new comment for commit or pull request.
277 274 IF status_change is not none this comment is associated with a
278 275 status change of commit or commit associated with pull request
279 276
280 277 :param text:
281 278 :param repo:
282 279 :param user:
283 280 :param commit_id:
284 281 :param pull_request:
285 282 :param f_path:
286 283 :param line_no:
287 284 :param status_change: Label for status change
288 285 :param comment_type: Type of comment
289 286 :param is_draft: is comment a draft only
290 287 :param resolves_comment_id: id of comment which this one will resolve
291 288 :param status_change_type: type of status change
292 289 :param closing_pr:
293 290 :param send_email:
294 291 :param renderer: pick renderer for this comment
295 292 :param auth_user: current authenticated user calling this method
296 293 :param extra_recipients: list of extra users to be added to recipients
297 294 """
298 295
299 296 request = get_current_request()
300 297 _ = request.translate
301 298
302 299 if not renderer:
303 300 renderer = self._get_renderer(request=request)
304 301
305 302 repo = self._get_repo(repo)
306 303 user = self._get_user(user)
307 304 auth_user = auth_user or user
308 305
309 306 schema = comment_schema.CommentSchema()
310 307 validated_kwargs = schema.deserialize(dict(
311 308 comment_body=text,
312 309 comment_type=comment_type,
313 310 is_draft=is_draft,
314 311 comment_file=f_path,
315 312 comment_line=line_no,
316 313 renderer_type=renderer,
317 314 status_change=status_change_type,
318 315 resolves_comment_id=resolves_comment_id,
319 316 repo=repo.repo_id,
320 317 user=user.user_id,
321 318 ))
322 319
323 320 is_draft = validated_kwargs['is_draft']
324 321
325 322 comment = ChangesetComment()
326 323 comment.renderer = validated_kwargs['renderer_type']
327 324 comment.text = validated_kwargs['comment_body']
328 325 comment.f_path = validated_kwargs['comment_file']
329 326 comment.line_no = validated_kwargs['comment_line']
330 327 comment.comment_type = validated_kwargs['comment_type']
331 328 comment.draft = is_draft
332 329
333 330 comment.repo = repo
334 331 comment.author = user
335 332 resolved_comment = self.__get_commit_comment(
336 333 validated_kwargs['resolves_comment_id'])
337 334
338 335 # check if the comment actually belongs to this PR
339 336 if resolved_comment and resolved_comment.pull_request and \
340 337 resolved_comment.pull_request != pull_request:
341 338 log.warning('Comment tried to resolved unrelated todo comment: %s',
342 339 resolved_comment)
343 340 # comment not bound to this pull request, forbid
344 341 resolved_comment = None
345 342
346 343 elif resolved_comment and resolved_comment.repo and \
347 344 resolved_comment.repo != repo:
348 345 log.warning('Comment tried to resolved unrelated todo comment: %s',
349 346 resolved_comment)
350 347 # comment not bound to this repo, forbid
351 348 resolved_comment = None
352 349
353 350 if resolved_comment and resolved_comment.resolved_by:
354 351 # if this comment is already resolved, don't mark it again!
355 352 resolved_comment = None
356 353
357 354 comment.resolved_comment = resolved_comment
358 355
359 356 pull_request_id = pull_request
360 357
361 358 commit_obj = None
362 359 pull_request_obj = None
363 360
364 361 if commit_id:
365 362 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
366 363 # do a lookup, so we don't pass something bad here
367 364 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
368 365 comment.revision = commit_obj.raw_id
369 366
370 367 elif pull_request_id:
371 368 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
372 369 pull_request_obj = self.__get_pull_request(pull_request_id)
373 370 comment.pull_request = pull_request_obj
374 371 else:
375 372 raise Exception('Please specify commit or pull_request_id')
376 373
377 374 Session().add(comment)
378 375 Session().flush()
379 376 kwargs = {
380 377 'user': user,
381 378 'renderer_type': renderer,
382 379 'repo_name': repo.repo_name,
383 380 'status_change': status_change,
384 381 'status_change_type': status_change_type,
385 382 'comment_body': text,
386 383 'comment_file': f_path,
387 384 'comment_line': line_no,
388 385 'comment_type': comment_type or 'note',
389 386 'comment_id': comment.comment_id
390 387 }
391 388
392 389 if commit_obj:
393 390 recipients = ChangesetComment.get_users(
394 391 revision=commit_obj.raw_id)
395 392 # add commit author if it's in RhodeCode system
396 393 cs_author = User.get_from_cs_author(commit_obj.author)
397 394 if not cs_author:
398 395 # use repo owner if we cannot extract the author correctly
399 396 cs_author = repo.user
400 397 recipients += [cs_author]
401 398
402 399 commit_comment_url = self.get_url(comment, request=request)
403 400 commit_comment_reply_url = self.get_url(
404 401 comment, request=request,
405 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
402 anchor=f'comment-{comment.comment_id}/?/ReplyToComment')
406 403
407 404 target_repo_url = h.link_to(
408 405 repo.repo_name,
409 406 h.route_url('repo_summary', repo_name=repo.repo_name))
410 407
411 408 commit_url = h.route_url('repo_commit', repo_name=repo.repo_name,
412 409 commit_id=commit_id)
413 410
414 411 # commit specifics
415 412 kwargs.update({
416 413 'commit': commit_obj,
417 414 'commit_message': commit_obj.message,
418 415 'commit_target_repo_url': target_repo_url,
419 416 'commit_comment_url': commit_comment_url,
420 417 'commit_comment_reply_url': commit_comment_reply_url,
421 418 'commit_url': commit_url,
422 419 'thread_ids': [commit_url, commit_comment_url],
423 420 })
424 421
425 422 elif pull_request_obj:
426 423 # get the current participants of this pull request
427 424 recipients = ChangesetComment.get_users(
428 425 pull_request_id=pull_request_obj.pull_request_id)
429 426 # add pull request author
430 427 recipients += [pull_request_obj.author]
431 428
432 429 # add the reviewers to notification
433 430 recipients += [x.user for x in pull_request_obj.get_pull_request_reviewers()]
434 431
435 432 pr_target_repo = pull_request_obj.target_repo
436 433 pr_source_repo = pull_request_obj.source_repo
437 434
438 435 pr_comment_url = self.get_url(comment, request=request)
439 436 pr_comment_reply_url = self.get_url(
440 437 comment, request=request,
441 anchor='comment-{}/?/ReplyToComment'.format(comment.comment_id))
438 anchor=f'comment-{comment.comment_id}/?/ReplyToComment')
442 439
443 440 pr_url = h.route_url(
444 441 'pullrequest_show',
445 442 repo_name=pr_target_repo.repo_name,
446 443 pull_request_id=pull_request_obj.pull_request_id, )
447 444
448 445 # set some variables for email notification
449 446 pr_target_repo_url = h.route_url(
450 447 'repo_summary', repo_name=pr_target_repo.repo_name)
451 448
452 449 pr_source_repo_url = h.route_url(
453 450 'repo_summary', repo_name=pr_source_repo.repo_name)
454 451
455 452 # pull request specifics
456 453 kwargs.update({
457 454 'pull_request': pull_request_obj,
458 455 'pr_id': pull_request_obj.pull_request_id,
459 456 'pull_request_url': pr_url,
460 457 'pull_request_target_repo': pr_target_repo,
461 458 'pull_request_target_repo_url': pr_target_repo_url,
462 459 'pull_request_source_repo': pr_source_repo,
463 460 'pull_request_source_repo_url': pr_source_repo_url,
464 461 'pr_comment_url': pr_comment_url,
465 462 'pr_comment_reply_url': pr_comment_reply_url,
466 463 'pr_closing': closing_pr,
467 464 'thread_ids': [pr_url, pr_comment_url],
468 465 })
469 466
470 467 if send_email:
471 468 recipients += [self._get_user(u) for u in (extra_recipients or [])]
472 469
473 470 mention_recipients = set(
474 471 self._extract_mentions(text)).difference(recipients)
475 472
476 473 # create notification objects, and emails
477 474 NotificationModel().create(
478 475 created_by=user,
479 476 notification_subject='', # Filled in based on the notification_type
480 477 notification_body='', # Filled in based on the notification_type
481 478 notification_type=notification_type,
482 479 recipients=recipients,
483 480 mention_recipients=mention_recipients,
484 481 email_kwargs=kwargs,
485 482 )
486 483
487 484 Session().flush()
488 485 if comment.pull_request:
489 486 action = 'repo.pull_request.comment.create'
490 487 else:
491 488 action = 'repo.commit.comment.create'
492 489
493 490 if not is_draft:
494 491 comment_data = comment.get_api_data()
495 492
496 493 self._log_audit_action(
497 494 action, {'data': comment_data}, auth_user, comment)
498 495
499 496 return comment
500 497
501 498 def edit(self, comment_id, text, auth_user, version):
502 499 """
503 500 Change existing comment for commit or pull request.
504 501
505 502 :param comment_id:
506 503 :param text:
507 504 :param auth_user: current authenticated user calling this method
508 505 :param version: last comment version
509 506 """
510 507 if not text:
511 508 log.warning('Missing text for comment, skipping...')
512 509 return
513 510
514 511 comment = ChangesetComment.get(comment_id)
515 512 old_comment_text = comment.text
516 513 comment.text = text
517 514 comment.modified_at = datetime.datetime.now()
518 515 version = safe_int(version)
519 516
520 517 # NOTE(marcink): this returns initial comment + edits, so v2 from ui
521 518 # would return 3 here
522 519 comment_version = ChangesetCommentHistory.get_version(comment_id)
523 520
524 521 if isinstance(version, int) and (comment_version - version) != 1:
525 522 log.warning(
526 523 'Version mismatch comment_version {} submitted {}, skipping'.format(
527 524 comment_version-1, # -1 since note above
528 525 version
529 526 )
530 527 )
531 528 raise CommentVersionMismatch()
532 529
533 530 comment_history = ChangesetCommentHistory()
534 531 comment_history.comment_id = comment_id
535 532 comment_history.version = comment_version
536 533 comment_history.created_by_user_id = auth_user.user_id
537 534 comment_history.text = old_comment_text
538 535 # TODO add email notification
539 536 Session().add(comment_history)
540 537 Session().add(comment)
541 538 Session().flush()
542 539
543 540 if comment.pull_request:
544 541 action = 'repo.pull_request.comment.edit'
545 542 else:
546 543 action = 'repo.commit.comment.edit'
547 544
548 545 comment_data = comment.get_api_data()
549 546 comment_data['old_comment_text'] = old_comment_text
550 547 self._log_audit_action(
551 548 action, {'data': comment_data}, auth_user, comment)
552 549
553 550 return comment_history
554 551
555 552 def delete(self, comment, auth_user):
556 553 """
557 554 Deletes given comment
558 555 """
559 556 comment = self.__get_commit_comment(comment)
560 557 old_data = comment.get_api_data()
561 558 Session().delete(comment)
562 559
563 560 if comment.pull_request:
564 561 action = 'repo.pull_request.comment.delete'
565 562 else:
566 563 action = 'repo.commit.comment.delete'
567 564
568 565 self._log_audit_action(
569 566 action, {'old_data': old_data}, auth_user, comment)
570 567
571 568 return comment
572 569
573 570 def get_all_comments(self, repo_id, revision=None, pull_request=None,
574 571 include_drafts=True, count_only=False):
575 572 q = ChangesetComment.query()\
576 573 .filter(ChangesetComment.repo_id == repo_id)
577 574 if revision:
578 575 q = q.filter(ChangesetComment.revision == revision)
579 576 elif pull_request:
580 577 pull_request = self.__get_pull_request(pull_request)
581 578 q = q.filter(ChangesetComment.pull_request_id == pull_request.pull_request_id)
582 579 else:
583 580 raise Exception('Please specify commit or pull_request')
584 581 if not include_drafts:
585 582 q = q.filter(ChangesetComment.draft == false())
586 583 q = q.order_by(ChangesetComment.created_on)
587 584 if count_only:
588 585 return q.count()
589 586
590 587 return q.all()
591 588
592 589 def get_url(self, comment, request=None, permalink=False, anchor=None):
593 590 if not request:
594 591 request = get_current_request()
595 592
596 593 comment = self.__get_commit_comment(comment)
597 594 if anchor is None:
598 anchor = 'comment-{}'.format(comment.comment_id)
595 anchor = f'comment-{comment.comment_id}'
599 596
600 597 if comment.pull_request:
601 598 pull_request = comment.pull_request
602 599 if permalink:
603 600 return request.route_url(
604 601 'pull_requests_global',
605 602 pull_request_id=pull_request.pull_request_id,
606 603 _anchor=anchor)
607 604 else:
608 605 return request.route_url(
609 606 'pullrequest_show',
610 607 repo_name=safe_str(pull_request.target_repo.repo_name),
611 608 pull_request_id=pull_request.pull_request_id,
612 609 _anchor=anchor)
613 610
614 611 else:
615 612 repo = comment.repo
616 613 commit_id = comment.revision
617 614
618 615 if permalink:
619 616 return request.route_url(
620 617 'repo_commit', repo_name=safe_str(repo.repo_id),
621 618 commit_id=commit_id,
622 619 _anchor=anchor)
623 620
624 621 else:
625 622 return request.route_url(
626 623 'repo_commit', repo_name=safe_str(repo.repo_name),
627 624 commit_id=commit_id,
628 625 _anchor=anchor)
629 626
630 627 def get_comments(self, repo_id, revision=None, pull_request=None):
631 628 """
632 629 Gets main comments based on revision or pull_request_id
633 630
634 631 :param repo_id:
635 632 :param revision:
636 633 :param pull_request:
637 634 """
638 635
639 636 q = ChangesetComment.query()\
640 637 .filter(ChangesetComment.repo_id == repo_id)\
641 638 .filter(ChangesetComment.line_no == None)\
642 639 .filter(ChangesetComment.f_path == None)
643 640 if revision:
644 641 q = q.filter(ChangesetComment.revision == revision)
645 642 elif pull_request:
646 643 pull_request = self.__get_pull_request(pull_request)
647 644 q = q.filter(ChangesetComment.pull_request == pull_request)
648 645 else:
649 646 raise Exception('Please specify commit or pull_request')
650 647 q = q.order_by(ChangesetComment.created_on)
651 648 return q.all()
652 649
653 650 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
654 651 q = self._get_inline_comments_query(repo_id, revision, pull_request)
655 652 return self._group_comments_by_path_and_line_number(q)
656 653
657 654 def get_inline_comments_as_list(self, inline_comments, skip_outdated=True,
658 655 version=None):
659 656 inline_comms = []
660 657 for fname, per_line_comments in inline_comments.items():
661 658 for lno, comments in per_line_comments.items():
662 659 for comm in comments:
663 660 if not comm.outdated_at_version(version) and skip_outdated:
664 661 inline_comms.append(comm)
665 662
666 663 return inline_comms
667 664
668 665 def get_outdated_comments(self, repo_id, pull_request):
669 666 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
670 667 # of a pull request.
671 668 q = self._all_inline_comments_of_pull_request(pull_request)
672 669 q = q.filter(
673 670 ChangesetComment.display_state ==
674 671 ChangesetComment.COMMENT_OUTDATED
675 672 ).order_by(ChangesetComment.comment_id.asc())
676 673
677 674 return self._group_comments_by_path_and_line_number(q)
678 675
679 676 def _get_inline_comments_query(self, repo_id, revision, pull_request):
680 677 # TODO: johbo: Split this into two methods: One for PR and one for
681 678 # commit.
682 679 if revision:
683 680 q = Session().query(ChangesetComment).filter(
684 681 ChangesetComment.repo_id == repo_id,
685 682 ChangesetComment.line_no != null(),
686 683 ChangesetComment.f_path != null(),
687 684 ChangesetComment.revision == revision)
688 685
689 686 elif pull_request:
690 687 pull_request = self.__get_pull_request(pull_request)
691 688 if not CommentsModel.use_outdated_comments(pull_request):
692 689 q = self._visible_inline_comments_of_pull_request(pull_request)
693 690 else:
694 691 q = self._all_inline_comments_of_pull_request(pull_request)
695 692
696 693 else:
697 694 raise Exception('Please specify commit or pull_request_id')
698 695 q = q.order_by(ChangesetComment.comment_id.asc())
699 696 return q
700 697
701 698 def _group_comments_by_path_and_line_number(self, q):
702 699 comments = q.all()
703 700 paths = collections.defaultdict(lambda: collections.defaultdict(list))
704 701 for co in comments:
705 702 paths[co.f_path][co.line_no].append(co)
706 703 return paths
707 704
708 705 @classmethod
709 706 def needed_extra_diff_context(cls):
710 707 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
711 708
712 709 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
713 710 if not CommentsModel.use_outdated_comments(pull_request):
714 711 return
715 712
716 713 comments = self._visible_inline_comments_of_pull_request(pull_request)
717 714 comments_to_outdate = comments.all()
718 715
719 716 for comment in comments_to_outdate:
720 717 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
721 718
722 719 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
723 720 diff_line = _parse_comment_line_number(comment.line_no)
724 721
725 722 try:
726 723 old_context = old_diff_proc.get_context_of_line(
727 724 path=comment.f_path, diff_line=diff_line)
728 725 new_context = new_diff_proc.get_context_of_line(
729 726 path=comment.f_path, diff_line=diff_line)
730 727 except (diffs.LineNotInDiffException,
731 728 diffs.FileNotInDiffException):
732 729 if not comment.draft:
733 730 comment.display_state = ChangesetComment.COMMENT_OUTDATED
734 731 return
735 732
736 733 if old_context == new_context:
737 734 return
738 735
739 736 if self._should_relocate_diff_line(diff_line):
740 737 new_diff_lines = new_diff_proc.find_context(
741 738 path=comment.f_path, context=old_context,
742 739 offset=self.DIFF_CONTEXT_BEFORE)
743 740 if not new_diff_lines and not comment.draft:
744 741 comment.display_state = ChangesetComment.COMMENT_OUTDATED
745 742 else:
746 743 new_diff_line = self._choose_closest_diff_line(
747 744 diff_line, new_diff_lines)
748 745 comment.line_no = _diff_to_comment_line_number(new_diff_line)
749 746 else:
750 747 if not comment.draft:
751 748 comment.display_state = ChangesetComment.COMMENT_OUTDATED
752 749
753 750 def _should_relocate_diff_line(self, diff_line):
754 751 """
755 752 Checks if relocation shall be tried for the given `diff_line`.
756 753
757 754 If a comment points into the first lines, then we can have a situation
758 755 that after an update another line has been added on top. In this case
759 756 we would find the context still and move the comment around. This
760 757 would be wrong.
761 758 """
762 759 should_relocate = (
763 760 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
764 761 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
765 762 return should_relocate
766 763
767 764 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
768 765 candidate = new_diff_lines[0]
769 766 best_delta = _diff_line_delta(diff_line, candidate)
770 767 for new_diff_line in new_diff_lines[1:]:
771 768 delta = _diff_line_delta(diff_line, new_diff_line)
772 769 if delta < best_delta:
773 770 candidate = new_diff_line
774 771 best_delta = delta
775 772 return candidate
776 773
777 774 def _visible_inline_comments_of_pull_request(self, pull_request):
778 775 comments = self._all_inline_comments_of_pull_request(pull_request)
779 776 comments = comments.filter(
780 777 coalesce(ChangesetComment.display_state, '') !=
781 778 ChangesetComment.COMMENT_OUTDATED)
782 779 return comments
783 780
784 781 def _all_inline_comments_of_pull_request(self, pull_request):
785 782 comments = Session().query(ChangesetComment)\
786 783 .filter(ChangesetComment.line_no != None)\
787 784 .filter(ChangesetComment.f_path != None)\
788 785 .filter(ChangesetComment.pull_request == pull_request)
789 786 return comments
790 787
791 788 def _all_general_comments_of_pull_request(self, pull_request):
792 789 comments = Session().query(ChangesetComment)\
793 790 .filter(ChangesetComment.line_no == None)\
794 791 .filter(ChangesetComment.f_path == None)\
795 792 .filter(ChangesetComment.pull_request == pull_request)
796 793
797 794 return comments
798 795
799 796 @staticmethod
800 797 def use_outdated_comments(pull_request):
801 798 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
802 799 settings = settings_model.get_general_settings()
803 800 return settings.get('rhodecode_use_outdated_comments', False)
804 801
805 802 def trigger_commit_comment_hook(self, repo, user, action, data=None):
806 803 repo = self._get_repo(repo)
807 804 target_scm = repo.scm_instance()
808 805 if action == 'create':
809 806 trigger_hook = hooks_utils.trigger_comment_commit_hooks
810 807 elif action == 'edit':
811 808 trigger_hook = hooks_utils.trigger_comment_commit_edit_hooks
812 809 else:
813 810 return
814 811
815 812 log.debug('Handling repo %s trigger_commit_comment_hook with action %s: %s',
816 813 repo, action, trigger_hook)
817 814 trigger_hook(
818 815 username=user.username,
819 816 repo_name=repo.repo_name,
820 817 repo_type=target_scm.alias,
821 818 repo=repo,
822 819 data=data)
823 820
824 821
825 822 def _parse_comment_line_number(line_no):
826 """
823 r"""
827 824 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
828 825 """
829 826 old_line = None
830 827 new_line = None
831 828 if line_no.startswith('o'):
832 829 old_line = int(line_no[1:])
833 830 elif line_no.startswith('n'):
834 831 new_line = int(line_no[1:])
835 832 else:
836 833 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
837 834 return diffs.DiffLineNumber(old_line, new_line)
838 835
839 836
840 837 def _diff_to_comment_line_number(diff_line):
841 838 if diff_line.new is not None:
842 return u'n{}'.format(diff_line.new)
839 return f'n{diff_line.new}'
843 840 elif diff_line.old is not None:
844 return u'o{}'.format(diff_line.old)
845 return u''
841 return f'o{diff_line.old}'
842 return ''
846 843
847 844
848 845 def _diff_line_delta(a, b):
849 846 if None not in (a.new, b.new):
850 847 return abs(a.new - b.new)
851 848 elif None not in (a.old, b.old):
852 849 return abs(a.old - b.old)
853 850 else:
854 851 raise ValueError(
855 "Cannot compute delta between {} and {}".format(a, b))
852 f"Cannot compute delta between {a} and {b}")
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
General Comments 0
You need to be logged in to leave comments. Login now