##// END OF EJS Templates
py3: add safe_str where we really need it to get a str - probably from bytes
Mads Kiilerich -
r8079:1112e440 default
parent child Browse files
Show More
@@ -1,213 +1,213 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 # This program is free software: you can redistribute it and/or modify
2 # This program is free software: you can redistribute it and/or modify
3 # it under the terms of the GNU General Public License as published by
3 # it under the terms of the GNU General Public License as published by
4 # the Free Software Foundation, either version 3 of the License, or
4 # the Free Software Foundation, either version 3 of the License, or
5 # (at your option) any later version.
5 # (at your option) any later version.
6 #
6 #
7 # This program is distributed in the hope that it will be useful,
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
10 # GNU General Public License for more details.
11 #
11 #
12 # You should have received a copy of the GNU General Public License
12 # You should have received a copy of the GNU General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 """
14 """
15 Global configuration file for TurboGears2 specific settings in Kallithea.
15 Global configuration file for TurboGears2 specific settings in Kallithea.
16
16
17 This file complements the .ini file.
17 This file complements the .ini file.
18 """
18 """
19
19
20 import logging
20 import logging
21 import os
21 import os
22 import platform
22 import platform
23 import sys
23 import sys
24
24
25 import alembic.config
25 import alembic.config
26 import mercurial
26 import mercurial
27 import tg
27 import tg
28 from alembic.migration import MigrationContext
28 from alembic.migration import MigrationContext
29 from alembic.script.base import ScriptDirectory
29 from alembic.script.base import ScriptDirectory
30 from sqlalchemy import create_engine
30 from sqlalchemy import create_engine
31 from tg.configuration import AppConfig
31 from tg.configuration import AppConfig
32 from tg.support.converters import asbool
32 from tg.support.converters import asbool
33
33
34 import kallithea.lib.locale
34 import kallithea.lib.locale
35 import kallithea.model.base
35 import kallithea.model.base
36 import kallithea.model.meta
36 import kallithea.model.meta
37 from kallithea.lib.middleware.https_fixup import HttpsFixup
37 from kallithea.lib.middleware.https_fixup import HttpsFixup
38 from kallithea.lib.middleware.permanent_repo_url import PermanentRepoUrl
38 from kallithea.lib.middleware.permanent_repo_url import PermanentRepoUrl
39 from kallithea.lib.middleware.simplegit import SimpleGit
39 from kallithea.lib.middleware.simplegit import SimpleGit
40 from kallithea.lib.middleware.simplehg import SimpleHg
40 from kallithea.lib.middleware.simplehg import SimpleHg
41 from kallithea.lib.middleware.wrapper import RequestWrapper
41 from kallithea.lib.middleware.wrapper import RequestWrapper
42 from kallithea.lib.utils import check_git_version, load_rcextensions, make_ui, set_app_settings, set_indexer_config, set_vcs_config
42 from kallithea.lib.utils import check_git_version, load_rcextensions, make_ui, set_app_settings, set_indexer_config, set_vcs_config
43 from kallithea.lib.utils2 import str2bool
43 from kallithea.lib.utils2 import safe_str, str2bool
44
44
45
45
46 log = logging.getLogger(__name__)
46 log = logging.getLogger(__name__)
47
47
48
48
49 class KallitheaAppConfig(AppConfig):
49 class KallitheaAppConfig(AppConfig):
50 # Note: AppConfig has a misleading name, as it's not the application
50 # Note: AppConfig has a misleading name, as it's not the application
51 # configuration, but the application configurator. The AppConfig values are
51 # configuration, but the application configurator. The AppConfig values are
52 # used as a template to create the actual configuration, which might
52 # used as a template to create the actual configuration, which might
53 # overwrite or extend the one provided by the configurator template.
53 # overwrite or extend the one provided by the configurator template.
54
54
55 # To make it clear, AppConfig creates the config and sets into it the same
55 # To make it clear, AppConfig creates the config and sets into it the same
56 # values that AppConfig itself has. Then the values from the config file and
56 # values that AppConfig itself has. Then the values from the config file and
57 # gearbox options are loaded and merged into the configuration. Then an
57 # gearbox options are loaded and merged into the configuration. Then an
58 # after_init_config(conf) method of AppConfig is called for any change that
58 # after_init_config(conf) method of AppConfig is called for any change that
59 # might depend on options provided by configuration files.
59 # might depend on options provided by configuration files.
60
60
61 def __init__(self):
61 def __init__(self):
62 super(KallitheaAppConfig, self).__init__()
62 super(KallitheaAppConfig, self).__init__()
63
63
64 self['package'] = kallithea
64 self['package'] = kallithea
65
65
66 self['prefer_toscawidgets2'] = False
66 self['prefer_toscawidgets2'] = False
67 self['use_toscawidgets'] = False
67 self['use_toscawidgets'] = False
68
68
69 self['renderers'] = []
69 self['renderers'] = []
70
70
71 # Enable json in expose
71 # Enable json in expose
72 self['renderers'].append('json')
72 self['renderers'].append('json')
73
73
74 # Configure template rendering
74 # Configure template rendering
75 self['renderers'].append('mako')
75 self['renderers'].append('mako')
76 self['default_renderer'] = 'mako'
76 self['default_renderer'] = 'mako'
77 self['use_dotted_templatenames'] = False
77 self['use_dotted_templatenames'] = False
78
78
79 # Configure Sessions, store data as JSON to avoid pickle security issues
79 # Configure Sessions, store data as JSON to avoid pickle security issues
80 self['session.enabled'] = True
80 self['session.enabled'] = True
81 self['session.data_serializer'] = 'json'
81 self['session.data_serializer'] = 'json'
82
82
83 # Configure the base SQLALchemy Setup
83 # Configure the base SQLALchemy Setup
84 self['use_sqlalchemy'] = True
84 self['use_sqlalchemy'] = True
85 self['model'] = kallithea.model.base
85 self['model'] = kallithea.model.base
86 self['DBSession'] = kallithea.model.meta.Session
86 self['DBSession'] = kallithea.model.meta.Session
87
87
88 # Configure App without an authentication backend.
88 # Configure App without an authentication backend.
89 self['auth_backend'] = None
89 self['auth_backend'] = None
90
90
91 # Use custom error page for these errors. By default, Turbogears2 does not add
91 # Use custom error page for these errors. By default, Turbogears2 does not add
92 # 400 in this list.
92 # 400 in this list.
93 # Explicitly listing all is considered more robust than appending to defaults,
93 # Explicitly listing all is considered more robust than appending to defaults,
94 # in light of possible future framework changes.
94 # in light of possible future framework changes.
95 self['errorpage.status_codes'] = [400, 401, 403, 404]
95 self['errorpage.status_codes'] = [400, 401, 403, 404]
96
96
97 # Disable transaction manager -- currently Kallithea takes care of transactions itself
97 # Disable transaction manager -- currently Kallithea takes care of transactions itself
98 self['tm.enabled'] = False
98 self['tm.enabled'] = False
99
99
100 # Set the i18n source language so TG doesn't search beyond 'en' in Accept-Language.
100 # Set the i18n source language so TG doesn't search beyond 'en' in Accept-Language.
101 # Don't force the default here if configuration force something else.
101 # Don't force the default here if configuration force something else.
102 if not self.get('i18n.lang'):
102 if not self.get('i18n.lang'):
103 self['i18n.lang'] = 'en'
103 self['i18n.lang'] = 'en'
104
104
105
105
106 base_config = KallitheaAppConfig()
106 base_config = KallitheaAppConfig()
107
107
108 # DebugBar, a debug toolbar for TurboGears2.
108 # DebugBar, a debug toolbar for TurboGears2.
109 # (https://github.com/TurboGears/tgext.debugbar)
109 # (https://github.com/TurboGears/tgext.debugbar)
110 # To enable it, install 'tgext.debugbar' and 'kajiki', and run Kallithea with
110 # To enable it, install 'tgext.debugbar' and 'kajiki', and run Kallithea with
111 # 'debug = true' (not in production!)
111 # 'debug = true' (not in production!)
112 # See the Kallithea documentation for more information.
112 # See the Kallithea documentation for more information.
113 try:
113 try:
114 from tgext.debugbar import enable_debugbar
114 from tgext.debugbar import enable_debugbar
115 import kajiki # only to check its existence
115 import kajiki # only to check its existence
116 except ImportError:
116 except ImportError:
117 pass
117 pass
118 else:
118 else:
119 base_config['renderers'].append('kajiki')
119 base_config['renderers'].append('kajiki')
120 enable_debugbar(base_config)
120 enable_debugbar(base_config)
121
121
122
122
123 def setup_configuration(app):
123 def setup_configuration(app):
124 config = app.config
124 config = app.config
125
125
126 if not kallithea.lib.locale.current_locale_is_valid():
126 if not kallithea.lib.locale.current_locale_is_valid():
127 log.error("Terminating ...")
127 log.error("Terminating ...")
128 sys.exit(1)
128 sys.exit(1)
129
129
130 # Mercurial sets encoding at module import time, so we have to monkey patch it
130 # Mercurial sets encoding at module import time, so we have to monkey patch it
131 hgencoding = config.get('hgencoding')
131 hgencoding = config.get('hgencoding')
132 if hgencoding:
132 if hgencoding:
133 mercurial.encoding.encoding = hgencoding
133 mercurial.encoding.encoding = hgencoding
134
134
135 if config.get('ignore_alembic_revision', False):
135 if config.get('ignore_alembic_revision', False):
136 log.warn('database alembic revision checking is disabled')
136 log.warn('database alembic revision checking is disabled')
137 else:
137 else:
138 dbconf = config['sqlalchemy.url']
138 dbconf = config['sqlalchemy.url']
139 alembic_cfg = alembic.config.Config()
139 alembic_cfg = alembic.config.Config()
140 alembic_cfg.set_main_option('script_location', 'kallithea:alembic')
140 alembic_cfg.set_main_option('script_location', 'kallithea:alembic')
141 alembic_cfg.set_main_option('sqlalchemy.url', dbconf)
141 alembic_cfg.set_main_option('sqlalchemy.url', dbconf)
142 script_dir = ScriptDirectory.from_config(alembic_cfg)
142 script_dir = ScriptDirectory.from_config(alembic_cfg)
143 available_heads = sorted(script_dir.get_heads())
143 available_heads = sorted(script_dir.get_heads())
144
144
145 engine = create_engine(dbconf)
145 engine = create_engine(dbconf)
146 with engine.connect() as conn:
146 with engine.connect() as conn:
147 context = MigrationContext.configure(conn)
147 context = MigrationContext.configure(conn)
148 current_heads = sorted(str(s) for s in context.get_current_heads())
148 current_heads = sorted(str(s) for s in context.get_current_heads())
149 if current_heads != available_heads:
149 if current_heads != available_heads:
150 log.error('Failed to run Kallithea:\n\n'
150 log.error('Failed to run Kallithea:\n\n'
151 'The database version does not match the Kallithea version.\n'
151 'The database version does not match the Kallithea version.\n'
152 'Please read the documentation on how to upgrade or downgrade the database.\n'
152 'Please read the documentation on how to upgrade or downgrade the database.\n'
153 'Current database version id(s): %s\n'
153 'Current database version id(s): %s\n'
154 'Expected database version id(s): %s\n'
154 'Expected database version id(s): %s\n'
155 'If you are a developer and you know what you are doing, you can add `ignore_alembic_revision = True` '
155 'If you are a developer and you know what you are doing, you can add `ignore_alembic_revision = True` '
156 'to your .ini file to skip the check.\n' % (' '.join(current_heads), ' '.join(available_heads)))
156 'to your .ini file to skip the check.\n' % (' '.join(current_heads), ' '.join(available_heads)))
157 sys.exit(1)
157 sys.exit(1)
158
158
159 # store some globals into kallithea
159 # store some globals into kallithea
160 kallithea.CELERY_ON = str2bool(config.get('use_celery'))
160 kallithea.CELERY_ON = str2bool(config.get('use_celery'))
161 kallithea.CELERY_EAGER = str2bool(config.get('celery.always.eager'))
161 kallithea.CELERY_EAGER = str2bool(config.get('celery.always.eager'))
162 kallithea.CONFIG = config
162 kallithea.CONFIG = config
163
163
164 load_rcextensions(root_path=config['here'])
164 load_rcextensions(root_path=config['here'])
165
165
166 repos_path = make_ui().configitems(b'paths')[0][1]
166 repos_path = safe_str(make_ui().configitems(b'paths')[0][1])
167 config['base_path'] = repos_path
167 config['base_path'] = repos_path
168 set_app_settings(config)
168 set_app_settings(config)
169
169
170 instance_id = kallithea.CONFIG.get('instance_id', '*')
170 instance_id = kallithea.CONFIG.get('instance_id', '*')
171 if instance_id == '*':
171 if instance_id == '*':
172 instance_id = '%s-%s' % (platform.uname()[1], os.getpid())
172 instance_id = '%s-%s' % (platform.uname()[1], os.getpid())
173 kallithea.CONFIG['instance_id'] = instance_id
173 kallithea.CONFIG['instance_id'] = instance_id
174
174
175 # update kallithea.CONFIG with the meanwhile changed 'config'
175 # update kallithea.CONFIG with the meanwhile changed 'config'
176 kallithea.CONFIG.update(config)
176 kallithea.CONFIG.update(config)
177
177
178 # configure vcs and indexer libraries (they are supposed to be independent
178 # configure vcs and indexer libraries (they are supposed to be independent
179 # as much as possible and thus avoid importing tg.config or
179 # as much as possible and thus avoid importing tg.config or
180 # kallithea.CONFIG).
180 # kallithea.CONFIG).
181 set_vcs_config(kallithea.CONFIG)
181 set_vcs_config(kallithea.CONFIG)
182 set_indexer_config(kallithea.CONFIG)
182 set_indexer_config(kallithea.CONFIG)
183
183
184 check_git_version()
184 check_git_version()
185
185
186 kallithea.model.meta.Session.remove()
186 kallithea.model.meta.Session.remove()
187
187
188
188
189 tg.hooks.register('configure_new_app', setup_configuration)
189 tg.hooks.register('configure_new_app', setup_configuration)
190
190
191
191
192 def setup_application(app):
192 def setup_application(app):
193 config = app.config
193 config = app.config
194
194
195 # we want our low level middleware to get to the request ASAP. We don't
195 # we want our low level middleware to get to the request ASAP. We don't
196 # need any stack middleware in them - especially no StatusCodeRedirect buffering
196 # need any stack middleware in them - especially no StatusCodeRedirect buffering
197 app = SimpleHg(app, config)
197 app = SimpleHg(app, config)
198 app = SimpleGit(app, config)
198 app = SimpleGit(app, config)
199
199
200 # Enable https redirects based on HTTP_X_URL_SCHEME set by proxy
200 # Enable https redirects based on HTTP_X_URL_SCHEME set by proxy
201 if any(asbool(config.get(x)) for x in ['https_fixup', 'force_https', 'use_htsts']):
201 if any(asbool(config.get(x)) for x in ['https_fixup', 'force_https', 'use_htsts']):
202 app = HttpsFixup(app, config)
202 app = HttpsFixup(app, config)
203
203
204 app = PermanentRepoUrl(app, config)
204 app = PermanentRepoUrl(app, config)
205
205
206 # Optional and undocumented wrapper - gives more verbose request/response logging, but has a slight overhead
206 # Optional and undocumented wrapper - gives more verbose request/response logging, but has a slight overhead
207 if str2bool(config.get('use_wsgi_wrapper')):
207 if str2bool(config.get('use_wsgi_wrapper')):
208 app = RequestWrapper(app, config)
208 app = RequestWrapper(app, config)
209
209
210 return app
210 return app
211
211
212
212
213 tg.hooks.register('before_config', setup_application)
213 tg.hooks.register('before_config', setup_application)
@@ -1,209 +1,210 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 # This program is free software: you can redistribute it and/or modify
2 # This program is free software: you can redistribute it and/or modify
3 # it under the terms of the GNU General Public License as published by
3 # it under the terms of the GNU General Public License as published by
4 # the Free Software Foundation, either version 3 of the License, or
4 # the Free Software Foundation, either version 3 of the License, or
5 # (at your option) any later version.
5 # (at your option) any later version.
6 #
6 #
7 # This program is distributed in the hope that it will be useful,
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
10 # GNU General Public License for more details.
11 #
11 #
12 # You should have received a copy of the GNU General Public License
12 # You should have received a copy of the GNU General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 """
14 """
15 kallithea.controllers.home
15 kallithea.controllers.home
16 ~~~~~~~~~~~~~~~~~~~~~~~~~~
16 ~~~~~~~~~~~~~~~~~~~~~~~~~~
17
17
18 Home controller for Kallithea
18 Home controller for Kallithea
19
19
20 This file was forked by the Kallithea project in July 2014.
20 This file was forked by the Kallithea project in July 2014.
21 Original author and date, and relevant copyright and licensing information is below:
21 Original author and date, and relevant copyright and licensing information is below:
22 :created_on: Feb 18, 2010
22 :created_on: Feb 18, 2010
23 :author: marcink
23 :author: marcink
24 :copyright: (c) 2013 RhodeCode GmbH, and others.
24 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 :license: GPLv3, see LICENSE.md for more details.
25 :license: GPLv3, see LICENSE.md for more details.
26
26
27 """
27 """
28
28
29 import logging
29 import logging
30
30
31 from sqlalchemy import or_
31 from sqlalchemy import or_
32 from tg import request
32 from tg import request
33 from tg import tmpl_context as c
33 from tg import tmpl_context as c
34 from tg.i18n import ugettext as _
34 from tg.i18n import ugettext as _
35 from webob.exc import HTTPBadRequest
35 from webob.exc import HTTPBadRequest
36
36
37 from kallithea.lib import helpers as h
37 from kallithea.lib import helpers as h
38 from kallithea.lib.auth import HasRepoPermissionLevelDecorator, LoginRequired
38 from kallithea.lib.auth import HasRepoPermissionLevelDecorator, LoginRequired
39 from kallithea.lib.base import BaseController, jsonify, render
39 from kallithea.lib.base import BaseController, jsonify, render
40 from kallithea.lib.utils2 import safe_str
40 from kallithea.model.db import RepoGroup, Repository, User, UserGroup
41 from kallithea.model.db import RepoGroup, Repository, User, UserGroup
41 from kallithea.model.repo import RepoModel
42 from kallithea.model.repo import RepoModel
42 from kallithea.model.scm import UserGroupList
43 from kallithea.model.scm import UserGroupList
43
44
44
45
45 log = logging.getLogger(__name__)
46 log = logging.getLogger(__name__)
46
47
47
48
48 class HomeController(BaseController):
49 class HomeController(BaseController):
49
50
50 def about(self):
51 def about(self):
51 return render('/about.html')
52 return render('/about.html')
52
53
53 @LoginRequired(allow_default_user=True)
54 @LoginRequired(allow_default_user=True)
54 def index(self):
55 def index(self):
55 c.group = None
56 c.group = None
56
57
57 repo_groups_list = self.scm_model.get_repo_groups()
58 repo_groups_list = self.scm_model.get_repo_groups()
58 repos_list = Repository.query(sorted=True).filter_by(group=None).all()
59 repos_list = Repository.query(sorted=True).filter_by(group=None).all()
59
60
60 c.data = RepoModel().get_repos_as_dict(repos_list,
61 c.data = RepoModel().get_repos_as_dict(repos_list,
61 repo_groups_list=repo_groups_list,
62 repo_groups_list=repo_groups_list,
62 short_name=True)
63 short_name=True)
63
64
64 return render('/index.html')
65 return render('/index.html')
65
66
66 @LoginRequired(allow_default_user=True)
67 @LoginRequired(allow_default_user=True)
67 @jsonify
68 @jsonify
68 def repo_switcher_data(self):
69 def repo_switcher_data(self):
69 if request.is_xhr:
70 if request.is_xhr:
70 all_repos = Repository.query(sorted=True).all()
71 all_repos = Repository.query(sorted=True).all()
71 repo_iter = self.scm_model.get_repos(all_repos)
72 repo_iter = self.scm_model.get_repos(all_repos)
72 all_groups = RepoGroup.query(sorted=True).all()
73 all_groups = RepoGroup.query(sorted=True).all()
73 repo_groups_iter = self.scm_model.get_repo_groups(all_groups)
74 repo_groups_iter = self.scm_model.get_repo_groups(all_groups)
74
75
75 res = [{
76 res = [{
76 'text': _('Groups'),
77 'text': _('Groups'),
77 'children': [
78 'children': [
78 {'id': obj.group_name,
79 {'id': obj.group_name,
79 'text': obj.group_name,
80 'text': obj.group_name,
80 'type': 'group',
81 'type': 'group',
81 'obj': {}}
82 'obj': {}}
82 for obj in repo_groups_iter
83 for obj in repo_groups_iter
83 ],
84 ],
84 },
85 },
85 {
86 {
86 'text': _('Repositories'),
87 'text': _('Repositories'),
87 'children': [
88 'children': [
88 {'id': obj.repo_name,
89 {'id': obj.repo_name,
89 'text': obj.repo_name,
90 'text': obj.repo_name,
90 'type': 'repo',
91 'type': 'repo',
91 'obj': obj.get_dict()}
92 'obj': obj.get_dict()}
92 for obj in repo_iter
93 for obj in repo_iter
93 ],
94 ],
94 }]
95 }]
95
96
96 for res_dict in res:
97 for res_dict in res:
97 for child in (res_dict['children']):
98 for child in (res_dict['children']):
98 child['obj'].pop('_changeset_cache', None) # bytes cannot be encoded in json ... but this value isn't relevant on client side at all ...
99 child['obj'].pop('_changeset_cache', None) # bytes cannot be encoded in json ... but this value isn't relevant on client side at all ...
99
100
100 data = {
101 data = {
101 'more': False,
102 'more': False,
102 'results': res,
103 'results': res,
103 }
104 }
104 return data
105 return data
105
106
106 else:
107 else:
107 raise HTTPBadRequest()
108 raise HTTPBadRequest()
108
109
109 @LoginRequired(allow_default_user=True)
110 @LoginRequired(allow_default_user=True)
110 @HasRepoPermissionLevelDecorator('read')
111 @HasRepoPermissionLevelDecorator('read')
111 @jsonify
112 @jsonify
112 def repo_refs_data(self, repo_name):
113 def repo_refs_data(self, repo_name):
113 repo = Repository.get_by_repo_name(repo_name).scm_instance
114 repo = Repository.get_by_repo_name(repo_name).scm_instance
114 res = []
115 res = []
115 _branches = repo.branches.items()
116 _branches = repo.branches.items()
116 if _branches:
117 if _branches:
117 res.append({
118 res.append({
118 'text': _('Branch'),
119 'text': _('Branch'),
119 'children': [{'id': rev, 'text': name, 'type': 'branch'} for name, rev in _branches]
120 'children': [{'id': safe_str(rev), 'text': safe_str(name), 'type': 'branch'} for name, rev in _branches]
120 })
121 })
121 _closed_branches = repo.closed_branches.items()
122 _closed_branches = repo.closed_branches.items()
122 if _closed_branches:
123 if _closed_branches:
123 res.append({
124 res.append({
124 'text': _('Closed Branches'),
125 'text': _('Closed Branches'),
125 'children': [{'id': rev, 'text': name, 'type': 'closed-branch'} for name, rev in _closed_branches]
126 'children': [{'id': safe_str(rev), 'text': safe_str(name), 'type': 'closed-branch'} for name, rev in _closed_branches]
126 })
127 })
127 _tags = repo.tags.items()
128 _tags = repo.tags.items()
128 if _tags:
129 if _tags:
129 res.append({
130 res.append({
130 'text': _('Tag'),
131 'text': _('Tag'),
131 'children': [{'id': rev, 'text': name, 'type': 'tag'} for name, rev in _tags]
132 'children': [{'id': safe_str(rev), 'text': safe_str(name), 'type': 'tag'} for name, rev in _tags]
132 })
133 })
133 _bookmarks = repo.bookmarks.items()
134 _bookmarks = repo.bookmarks.items()
134 if _bookmarks:
135 if _bookmarks:
135 res.append({
136 res.append({
136 'text': _('Bookmark'),
137 'text': _('Bookmark'),
137 'children': [{'id': rev, 'text': name, 'type': 'book'} for name, rev in _bookmarks]
138 'children': [{'id': safe_str(rev), 'text': safe_str(name), 'type': 'book'} for name, rev in _bookmarks]
138 })
139 })
139 data = {
140 data = {
140 'more': False,
141 'more': False,
141 'results': res
142 'results': res
142 }
143 }
143 return data
144 return data
144
145
145 @LoginRequired()
146 @LoginRequired()
146 @jsonify
147 @jsonify
147 def users_and_groups_data(self):
148 def users_and_groups_data(self):
148 """
149 """
149 Returns 'results' with a list of users and user groups.
150 Returns 'results' with a list of users and user groups.
150
151
151 You can either use the 'key' GET parameter to get a user by providing
152 You can either use the 'key' GET parameter to get a user by providing
152 the exact user key or you can use the 'query' parameter to
153 the exact user key or you can use the 'query' parameter to
153 search for users by user key, first name and last name.
154 search for users by user key, first name and last name.
154 'types' defaults to just 'users' but can be set to 'users,groups' to
155 'types' defaults to just 'users' but can be set to 'users,groups' to
155 get both users and groups.
156 get both users and groups.
156 No more than 500 results (of each kind) will be returned.
157 No more than 500 results (of each kind) will be returned.
157 """
158 """
158 types = request.GET.get('types', 'users').split(',')
159 types = request.GET.get('types', 'users').split(',')
159 key = request.GET.get('key', '')
160 key = request.GET.get('key', '')
160 query = request.GET.get('query', '')
161 query = request.GET.get('query', '')
161 results = []
162 results = []
162 if 'users' in types:
163 if 'users' in types:
163 user_list = []
164 user_list = []
164 if key:
165 if key:
165 u = User.get_by_username(key)
166 u = User.get_by_username(key)
166 if u:
167 if u:
167 user_list = [u]
168 user_list = [u]
168 elif query:
169 elif query:
169 user_list = User.query() \
170 user_list = User.query() \
170 .filter(User.is_default_user == False) \
171 .filter(User.is_default_user == False) \
171 .filter(User.active == True) \
172 .filter(User.active == True) \
172 .filter(or_(
173 .filter(or_(
173 User.username.ilike("%%" + query + "%%"),
174 User.username.ilike("%%" + query + "%%"),
174 User.name.ilike("%%" + query + "%%"),
175 User.name.ilike("%%" + query + "%%"),
175 User.lastname.ilike("%%" + query + "%%"),
176 User.lastname.ilike("%%" + query + "%%"),
176 )) \
177 )) \
177 .order_by(User.username) \
178 .order_by(User.username) \
178 .limit(500) \
179 .limit(500) \
179 .all()
180 .all()
180 for u in user_list:
181 for u in user_list:
181 results.append({
182 results.append({
182 'type': 'user',
183 'type': 'user',
183 'id': u.user_id,
184 'id': u.user_id,
184 'nname': u.username,
185 'nname': u.username,
185 'fname': u.name,
186 'fname': u.name,
186 'lname': u.lastname,
187 'lname': u.lastname,
187 'gravatar_lnk': h.gravatar_url(u.email, size=28, default='default'),
188 'gravatar_lnk': h.gravatar_url(u.email, size=28, default='default'),
188 'gravatar_size': 14,
189 'gravatar_size': 14,
189 })
190 })
190 if 'groups' in types:
191 if 'groups' in types:
191 grp_list = []
192 grp_list = []
192 if key:
193 if key:
193 grp = UserGroup.get_by_group_name(key)
194 grp = UserGroup.get_by_group_name(key)
194 if grp:
195 if grp:
195 grp_list = [grp]
196 grp_list = [grp]
196 elif query:
197 elif query:
197 grp_list = UserGroup.query() \
198 grp_list = UserGroup.query() \
198 .filter(UserGroup.users_group_name.ilike("%%" + query + "%%")) \
199 .filter(UserGroup.users_group_name.ilike("%%" + query + "%%")) \
199 .filter(UserGroup.users_group_active == True) \
200 .filter(UserGroup.users_group_active == True) \
200 .order_by(UserGroup.users_group_name) \
201 .order_by(UserGroup.users_group_name) \
201 .limit(500) \
202 .limit(500) \
202 .all()
203 .all()
203 for g in UserGroupList(grp_list, perm_level='read'):
204 for g in UserGroupList(grp_list, perm_level='read'):
204 results.append({
205 results.append({
205 'type': 'group',
206 'type': 'group',
206 'id': g.users_group_id,
207 'id': g.users_group_id,
207 'grname': g.users_group_name,
208 'grname': g.users_group_name,
208 })
209 })
209 return dict(results=results)
210 return dict(results=results)
@@ -1,644 +1,644 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 # This program is free software: you can redistribute it and/or modify
2 # This program is free software: you can redistribute it and/or modify
3 # it under the terms of the GNU General Public License as published by
3 # it under the terms of the GNU General Public License as published by
4 # the Free Software Foundation, either version 3 of the License, or
4 # the Free Software Foundation, either version 3 of the License, or
5 # (at your option) any later version.
5 # (at your option) any later version.
6 #
6 #
7 # This program is distributed in the hope that it will be useful,
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
10 # GNU General Public License for more details.
11 #
11 #
12 # You should have received a copy of the GNU General Public License
12 # You should have received a copy of the GNU General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14
14
15 """
15 """
16 kallithea.lib.base
16 kallithea.lib.base
17 ~~~~~~~~~~~~~~~~~~
17 ~~~~~~~~~~~~~~~~~~
18
18
19 The base Controller API
19 The base Controller API
20 Provides the BaseController class for subclassing. And usage in different
20 Provides the BaseController class for subclassing. And usage in different
21 controllers
21 controllers
22
22
23 This file was forked by the Kallithea project in July 2014.
23 This file was forked by the Kallithea project in July 2014.
24 Original author and date, and relevant copyright and licensing information is below:
24 Original author and date, and relevant copyright and licensing information is below:
25 :created_on: Oct 06, 2010
25 :created_on: Oct 06, 2010
26 :author: marcink
26 :author: marcink
27 :copyright: (c) 2013 RhodeCode GmbH, and others.
27 :copyright: (c) 2013 RhodeCode GmbH, and others.
28 :license: GPLv3, see LICENSE.md for more details.
28 :license: GPLv3, see LICENSE.md for more details.
29 """
29 """
30
30
31 import base64
31 import base64
32 import datetime
32 import datetime
33 import logging
33 import logging
34 import traceback
34 import traceback
35 import warnings
35 import warnings
36
36
37 import decorator
37 import decorator
38 import paste.auth.basic
38 import paste.auth.basic
39 import paste.httpexceptions
39 import paste.httpexceptions
40 import paste.httpheaders
40 import paste.httpheaders
41 import webob.exc
41 import webob.exc
42 from tg import TGController, config, render_template, request, response, session
42 from tg import TGController, config, render_template, request, response, session
43 from tg import tmpl_context as c
43 from tg import tmpl_context as c
44 from tg.i18n import ugettext as _
44 from tg.i18n import ugettext as _
45
45
46 from kallithea import BACKENDS, __version__
46 from kallithea import BACKENDS, __version__
47 from kallithea.config.routing import url
47 from kallithea.config.routing import url
48 from kallithea.lib import auth_modules, ext_json
48 from kallithea.lib import auth_modules, ext_json
49 from kallithea.lib.auth import AuthUser, HasPermissionAnyMiddleware
49 from kallithea.lib.auth import AuthUser, HasPermissionAnyMiddleware
50 from kallithea.lib.exceptions import UserCreationError
50 from kallithea.lib.exceptions import UserCreationError
51 from kallithea.lib.utils import get_repo_slug, is_valid_repo
51 from kallithea.lib.utils import get_repo_slug, is_valid_repo
52 from kallithea.lib.utils2 import AttributeDict, ascii_bytes, safe_int, safe_str, set_hook_environment, str2bool
52 from kallithea.lib.utils2 import AttributeDict, ascii_bytes, safe_int, safe_str, set_hook_environment, str2bool
53 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError
53 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError
54 from kallithea.model import meta
54 from kallithea.model import meta
55 from kallithea.model.db import PullRequest, Repository, Setting, User
55 from kallithea.model.db import PullRequest, Repository, Setting, User
56 from kallithea.model.scm import ScmModel
56 from kallithea.model.scm import ScmModel
57
57
58
58
59 log = logging.getLogger(__name__)
59 log = logging.getLogger(__name__)
60
60
61
61
62 def render(template_path):
62 def render(template_path):
63 return render_template({'url': url}, 'mako', template_path)
63 return render_template({'url': url}, 'mako', template_path)
64
64
65
65
66 def _filter_proxy(ip):
66 def _filter_proxy(ip):
67 """
67 """
68 HEADERS can have multiple ips inside the left-most being the original
68 HEADERS can have multiple ips inside the left-most being the original
69 client, and each successive proxy that passed the request adding the IP
69 client, and each successive proxy that passed the request adding the IP
70 address where it received the request from.
70 address where it received the request from.
71
71
72 :param ip:
72 :param ip:
73 """
73 """
74 if ',' in ip:
74 if ',' in ip:
75 _ips = ip.split(',')
75 _ips = ip.split(',')
76 _first_ip = _ips[0].strip()
76 _first_ip = _ips[0].strip()
77 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
77 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
78 return _first_ip
78 return _first_ip
79 return ip
79 return ip
80
80
81
81
82 def _get_ip_addr(environ):
82 def _get_ip_addr(environ):
83 proxy_key = 'HTTP_X_REAL_IP'
83 proxy_key = 'HTTP_X_REAL_IP'
84 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
84 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
85 def_key = 'REMOTE_ADDR'
85 def_key = 'REMOTE_ADDR'
86
86
87 ip = environ.get(proxy_key)
87 ip = environ.get(proxy_key)
88 if ip:
88 if ip:
89 return _filter_proxy(ip)
89 return _filter_proxy(ip)
90
90
91 ip = environ.get(proxy_key2)
91 ip = environ.get(proxy_key2)
92 if ip:
92 if ip:
93 return _filter_proxy(ip)
93 return _filter_proxy(ip)
94
94
95 ip = environ.get(def_key, '0.0.0.0')
95 ip = environ.get(def_key, '0.0.0.0')
96 return _filter_proxy(ip)
96 return _filter_proxy(ip)
97
97
98
98
99 def get_path_info(environ):
99 def get_path_info(environ):
100 """Return unicode PATH_INFO from environ ... using tg.original_request if available.
100 """Return unicode PATH_INFO from environ ... using tg.original_request if available.
101 """
101 """
102 org_req = environ.get('tg.original_request')
102 org_req = environ.get('tg.original_request')
103 if org_req is not None:
103 if org_req is not None:
104 environ = org_req.environ
104 environ = org_req.environ
105 return safe_str(environ['PATH_INFO'])
105 return safe_str(environ['PATH_INFO'])
106
106
107
107
108 def log_in_user(user, remember, is_external_auth, ip_addr):
108 def log_in_user(user, remember, is_external_auth, ip_addr):
109 """
109 """
110 Log a `User` in and update session and cookies. If `remember` is True,
110 Log a `User` in and update session and cookies. If `remember` is True,
111 the session cookie is set to expire in a year; otherwise, it expires at
111 the session cookie is set to expire in a year; otherwise, it expires at
112 the end of the browser session.
112 the end of the browser session.
113
113
114 Returns populated `AuthUser` object.
114 Returns populated `AuthUser` object.
115 """
115 """
116 # It should not be possible to explicitly log in as the default user.
116 # It should not be possible to explicitly log in as the default user.
117 assert not user.is_default_user, user
117 assert not user.is_default_user, user
118
118
119 auth_user = AuthUser.make(dbuser=user, is_external_auth=is_external_auth, ip_addr=ip_addr)
119 auth_user = AuthUser.make(dbuser=user, is_external_auth=is_external_auth, ip_addr=ip_addr)
120 if auth_user is None:
120 if auth_user is None:
121 return None
121 return None
122
122
123 user.update_lastlogin()
123 user.update_lastlogin()
124 meta.Session().commit()
124 meta.Session().commit()
125
125
126 # Start new session to prevent session fixation attacks.
126 # Start new session to prevent session fixation attacks.
127 session.invalidate()
127 session.invalidate()
128 session['authuser'] = cookie = auth_user.to_cookie()
128 session['authuser'] = cookie = auth_user.to_cookie()
129
129
130 # If they want to be remembered, update the cookie.
130 # If they want to be remembered, update the cookie.
131 # NOTE: Assumes that beaker defaults to browser session cookie.
131 # NOTE: Assumes that beaker defaults to browser session cookie.
132 if remember:
132 if remember:
133 t = datetime.datetime.now() + datetime.timedelta(days=365)
133 t = datetime.datetime.now() + datetime.timedelta(days=365)
134 session._set_cookie_expires(t)
134 session._set_cookie_expires(t)
135
135
136 session.save()
136 session.save()
137
137
138 log.info('user %s is now authenticated and stored in '
138 log.info('user %s is now authenticated and stored in '
139 'session, session attrs %s', user.username, cookie)
139 'session, session attrs %s', user.username, cookie)
140
140
141 # dumps session attrs back to cookie
141 # dumps session attrs back to cookie
142 session._update_cookie_out()
142 session._update_cookie_out()
143
143
144 return auth_user
144 return auth_user
145
145
146
146
147 class BasicAuth(paste.auth.basic.AuthBasicAuthenticator):
147 class BasicAuth(paste.auth.basic.AuthBasicAuthenticator):
148
148
149 def __init__(self, realm, authfunc, auth_http_code=None):
149 def __init__(self, realm, authfunc, auth_http_code=None):
150 self.realm = realm
150 self.realm = realm
151 self.authfunc = authfunc
151 self.authfunc = authfunc
152 self._rc_auth_http_code = auth_http_code
152 self._rc_auth_http_code = auth_http_code
153
153
154 def build_authentication(self, environ):
154 def build_authentication(self, environ):
155 head = paste.httpheaders.WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
155 head = paste.httpheaders.WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
156 # Consume the whole body before sending a response
156 # Consume the whole body before sending a response
157 try:
157 try:
158 request_body_size = int(environ.get('CONTENT_LENGTH', 0))
158 request_body_size = int(environ.get('CONTENT_LENGTH', 0))
159 except (ValueError):
159 except (ValueError):
160 request_body_size = 0
160 request_body_size = 0
161 environ['wsgi.input'].read(request_body_size)
161 environ['wsgi.input'].read(request_body_size)
162 if self._rc_auth_http_code and self._rc_auth_http_code == '403':
162 if self._rc_auth_http_code and self._rc_auth_http_code == '403':
163 # return 403 if alternative http return code is specified in
163 # return 403 if alternative http return code is specified in
164 # Kallithea config
164 # Kallithea config
165 return paste.httpexceptions.HTTPForbidden(headers=head)
165 return paste.httpexceptions.HTTPForbidden(headers=head)
166 return paste.httpexceptions.HTTPUnauthorized(headers=head)
166 return paste.httpexceptions.HTTPUnauthorized(headers=head)
167
167
168 def authenticate(self, environ):
168 def authenticate(self, environ):
169 authorization = paste.httpheaders.AUTHORIZATION(environ)
169 authorization = paste.httpheaders.AUTHORIZATION(environ)
170 if not authorization:
170 if not authorization:
171 return self.build_authentication(environ)
171 return self.build_authentication(environ)
172 (authmeth, auth) = authorization.split(' ', 1)
172 (authmeth, auth) = authorization.split(' ', 1)
173 if 'basic' != authmeth.lower():
173 if 'basic' != authmeth.lower():
174 return self.build_authentication(environ)
174 return self.build_authentication(environ)
175 auth = base64.b64decode(auth.strip())
175 auth = safe_str(base64.b64decode(auth.strip()))
176 _parts = auth.split(':', 1)
176 _parts = auth.split(':', 1)
177 if len(_parts) == 2:
177 if len(_parts) == 2:
178 username, password = _parts
178 username, password = _parts
179 if self.authfunc(username, password, environ) is not None:
179 if self.authfunc(username, password, environ) is not None:
180 return username
180 return username
181 return self.build_authentication(environ)
181 return self.build_authentication(environ)
182
182
183 __call__ = authenticate
183 __call__ = authenticate
184
184
185
185
186 class BaseVCSController(object):
186 class BaseVCSController(object):
187 """Base controller for handling Mercurial/Git protocol requests
187 """Base controller for handling Mercurial/Git protocol requests
188 (coming from a VCS client, and not a browser).
188 (coming from a VCS client, and not a browser).
189 """
189 """
190
190
191 scm_alias = None # 'hg' / 'git'
191 scm_alias = None # 'hg' / 'git'
192
192
193 def __init__(self, application, config):
193 def __init__(self, application, config):
194 self.application = application
194 self.application = application
195 self.config = config
195 self.config = config
196 # base path of repo locations
196 # base path of repo locations
197 self.basepath = self.config['base_path']
197 self.basepath = self.config['base_path']
198 # authenticate this VCS request using the authentication modules
198 # authenticate this VCS request using the authentication modules
199 self.authenticate = BasicAuth('', auth_modules.authenticate,
199 self.authenticate = BasicAuth('', auth_modules.authenticate,
200 config.get('auth_ret_code'))
200 config.get('auth_ret_code'))
201
201
202 @classmethod
202 @classmethod
203 def parse_request(cls, environ):
203 def parse_request(cls, environ):
204 """If request is parsed as a request for this VCS, return a namespace with the parsed request.
204 """If request is parsed as a request for this VCS, return a namespace with the parsed request.
205 If the request is unknown, return None.
205 If the request is unknown, return None.
206 """
206 """
207 raise NotImplementedError()
207 raise NotImplementedError()
208
208
209 def _authorize(self, environ, action, repo_name, ip_addr):
209 def _authorize(self, environ, action, repo_name, ip_addr):
210 """Authenticate and authorize user.
210 """Authenticate and authorize user.
211
211
212 Since we're dealing with a VCS client and not a browser, we only
212 Since we're dealing with a VCS client and not a browser, we only
213 support HTTP basic authentication, either directly via raw header
213 support HTTP basic authentication, either directly via raw header
214 inspection, or by using container authentication to delegate the
214 inspection, or by using container authentication to delegate the
215 authentication to the web server.
215 authentication to the web server.
216
216
217 Returns (user, None) on successful authentication and authorization.
217 Returns (user, None) on successful authentication and authorization.
218 Returns (None, wsgi_app) to send the wsgi_app response to the client.
218 Returns (None, wsgi_app) to send the wsgi_app response to the client.
219 """
219 """
220 # Use anonymous access if allowed for action on repo.
220 # Use anonymous access if allowed for action on repo.
221 default_user = User.get_default_user(cache=True)
221 default_user = User.get_default_user(cache=True)
222 default_authuser = AuthUser.make(dbuser=default_user, ip_addr=ip_addr)
222 default_authuser = AuthUser.make(dbuser=default_user, ip_addr=ip_addr)
223 if default_authuser is None:
223 if default_authuser is None:
224 log.debug('No anonymous access at all') # move on to proper user auth
224 log.debug('No anonymous access at all') # move on to proper user auth
225 else:
225 else:
226 if self._check_permission(action, default_authuser, repo_name):
226 if self._check_permission(action, default_authuser, repo_name):
227 return default_authuser, None
227 return default_authuser, None
228 log.debug('Not authorized to access this repository as anonymous user')
228 log.debug('Not authorized to access this repository as anonymous user')
229
229
230 username = None
230 username = None
231 #==============================================================
231 #==============================================================
232 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
232 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
233 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
233 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
234 #==============================================================
234 #==============================================================
235
235
236 # try to auth based on environ, container auth methods
236 # try to auth based on environ, container auth methods
237 log.debug('Running PRE-AUTH for container based authentication')
237 log.debug('Running PRE-AUTH for container based authentication')
238 pre_auth = auth_modules.authenticate('', '', environ)
238 pre_auth = auth_modules.authenticate('', '', environ)
239 if pre_auth is not None and pre_auth.get('username'):
239 if pre_auth is not None and pre_auth.get('username'):
240 username = pre_auth['username']
240 username = pre_auth['username']
241 log.debug('PRE-AUTH got %s as username', username)
241 log.debug('PRE-AUTH got %s as username', username)
242
242
243 # If not authenticated by the container, running basic auth
243 # If not authenticated by the container, running basic auth
244 if not username:
244 if not username:
245 self.authenticate.realm = self.config['realm']
245 self.authenticate.realm = self.config['realm']
246 result = self.authenticate(environ)
246 result = self.authenticate(environ)
247 if isinstance(result, str):
247 if isinstance(result, str):
248 paste.httpheaders.AUTH_TYPE.update(environ, 'basic')
248 paste.httpheaders.AUTH_TYPE.update(environ, 'basic')
249 paste.httpheaders.REMOTE_USER.update(environ, result)
249 paste.httpheaders.REMOTE_USER.update(environ, result)
250 username = result
250 username = result
251 else:
251 else:
252 return None, result.wsgi_application
252 return None, result.wsgi_application
253
253
254 #==============================================================
254 #==============================================================
255 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
255 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
256 #==============================================================
256 #==============================================================
257 try:
257 try:
258 user = User.get_by_username_or_email(username)
258 user = User.get_by_username_or_email(username)
259 except Exception:
259 except Exception:
260 log.error(traceback.format_exc())
260 log.error(traceback.format_exc())
261 return None, webob.exc.HTTPInternalServerError()
261 return None, webob.exc.HTTPInternalServerError()
262
262
263 authuser = AuthUser.make(dbuser=user, ip_addr=ip_addr)
263 authuser = AuthUser.make(dbuser=user, ip_addr=ip_addr)
264 if authuser is None:
264 if authuser is None:
265 return None, webob.exc.HTTPForbidden()
265 return None, webob.exc.HTTPForbidden()
266 if not self._check_permission(action, authuser, repo_name):
266 if not self._check_permission(action, authuser, repo_name):
267 return None, webob.exc.HTTPForbidden()
267 return None, webob.exc.HTTPForbidden()
268
268
269 return user, None
269 return user, None
270
270
271 def _handle_request(self, environ, start_response):
271 def _handle_request(self, environ, start_response):
272 raise NotImplementedError()
272 raise NotImplementedError()
273
273
274 def _check_permission(self, action, authuser, repo_name):
274 def _check_permission(self, action, authuser, repo_name):
275 """
275 """
276 Checks permissions using action (push/pull) user and repository
276 Checks permissions using action (push/pull) user and repository
277 name
277 name
278
278
279 :param action: 'push' or 'pull' action
279 :param action: 'push' or 'pull' action
280 :param user: `User` instance
280 :param user: `User` instance
281 :param repo_name: repository name
281 :param repo_name: repository name
282 """
282 """
283 if action == 'push':
283 if action == 'push':
284 if not HasPermissionAnyMiddleware('repository.write',
284 if not HasPermissionAnyMiddleware('repository.write',
285 'repository.admin')(authuser,
285 'repository.admin')(authuser,
286 repo_name):
286 repo_name):
287 return False
287 return False
288
288
289 else:
289 else:
290 #any other action need at least read permission
290 #any other action need at least read permission
291 if not HasPermissionAnyMiddleware('repository.read',
291 if not HasPermissionAnyMiddleware('repository.read',
292 'repository.write',
292 'repository.write',
293 'repository.admin')(authuser,
293 'repository.admin')(authuser,
294 repo_name):
294 repo_name):
295 return False
295 return False
296
296
297 return True
297 return True
298
298
299 def _get_ip_addr(self, environ):
299 def _get_ip_addr(self, environ):
300 return _get_ip_addr(environ)
300 return _get_ip_addr(environ)
301
301
302 def __call__(self, environ, start_response):
302 def __call__(self, environ, start_response):
303 try:
303 try:
304 # try parsing a request for this VCS - if it fails, call the wrapped app
304 # try parsing a request for this VCS - if it fails, call the wrapped app
305 parsed_request = self.parse_request(environ)
305 parsed_request = self.parse_request(environ)
306 if parsed_request is None:
306 if parsed_request is None:
307 return self.application(environ, start_response)
307 return self.application(environ, start_response)
308
308
309 # skip passing error to error controller
309 # skip passing error to error controller
310 environ['pylons.status_code_redirect'] = True
310 environ['pylons.status_code_redirect'] = True
311
311
312 # quick check if repo exists...
312 # quick check if repo exists...
313 if not is_valid_repo(parsed_request.repo_name, self.basepath, self.scm_alias):
313 if not is_valid_repo(parsed_request.repo_name, self.basepath, self.scm_alias):
314 raise webob.exc.HTTPNotFound()
314 raise webob.exc.HTTPNotFound()
315
315
316 if parsed_request.action is None:
316 if parsed_request.action is None:
317 # Note: the client doesn't get the helpful error message
317 # Note: the client doesn't get the helpful error message
318 raise webob.exc.HTTPBadRequest('Unable to detect pull/push action for %r! Are you using a nonstandard command or client?' % parsed_request.repo_name)
318 raise webob.exc.HTTPBadRequest('Unable to detect pull/push action for %r! Are you using a nonstandard command or client?' % parsed_request.repo_name)
319
319
320 #======================================================================
320 #======================================================================
321 # CHECK PERMISSIONS
321 # CHECK PERMISSIONS
322 #======================================================================
322 #======================================================================
323 ip_addr = self._get_ip_addr(environ)
323 ip_addr = self._get_ip_addr(environ)
324 user, response_app = self._authorize(environ, parsed_request.action, parsed_request.repo_name, ip_addr)
324 user, response_app = self._authorize(environ, parsed_request.action, parsed_request.repo_name, ip_addr)
325 if response_app is not None:
325 if response_app is not None:
326 return response_app(environ, start_response)
326 return response_app(environ, start_response)
327
327
328 #======================================================================
328 #======================================================================
329 # REQUEST HANDLING
329 # REQUEST HANDLING
330 #======================================================================
330 #======================================================================
331 set_hook_environment(user.username, ip_addr,
331 set_hook_environment(user.username, ip_addr,
332 parsed_request.repo_name, self.scm_alias, parsed_request.action)
332 parsed_request.repo_name, self.scm_alias, parsed_request.action)
333
333
334 try:
334 try:
335 log.info('%s action on %s repo "%s" by "%s" from %s',
335 log.info('%s action on %s repo "%s" by "%s" from %s',
336 parsed_request.action, self.scm_alias, parsed_request.repo_name, user.username, ip_addr)
336 parsed_request.action, self.scm_alias, parsed_request.repo_name, user.username, ip_addr)
337 app = self._make_app(parsed_request)
337 app = self._make_app(parsed_request)
338 return app(environ, start_response)
338 return app(environ, start_response)
339 except Exception:
339 except Exception:
340 log.error(traceback.format_exc())
340 log.error(traceback.format_exc())
341 raise webob.exc.HTTPInternalServerError()
341 raise webob.exc.HTTPInternalServerError()
342
342
343 except webob.exc.HTTPException as e:
343 except webob.exc.HTTPException as e:
344 return e(environ, start_response)
344 return e(environ, start_response)
345
345
346
346
347 class BaseController(TGController):
347 class BaseController(TGController):
348
348
349 def _before(self, *args, **kwargs):
349 def _before(self, *args, **kwargs):
350 """
350 """
351 _before is called before controller methods and after __call__
351 _before is called before controller methods and after __call__
352 """
352 """
353 if request.needs_csrf_check:
353 if request.needs_csrf_check:
354 # CSRF protection: Whenever a request has ambient authority (whether
354 # CSRF protection: Whenever a request has ambient authority (whether
355 # through a session cookie or its origin IP address), it must include
355 # through a session cookie or its origin IP address), it must include
356 # the correct token, unless the HTTP method is GET or HEAD (and thus
356 # the correct token, unless the HTTP method is GET or HEAD (and thus
357 # guaranteed to be side effect free. In practice, the only situation
357 # guaranteed to be side effect free. In practice, the only situation
358 # where we allow side effects without ambient authority is when the
358 # where we allow side effects without ambient authority is when the
359 # authority comes from an API key; and that is handled above.
359 # authority comes from an API key; and that is handled above.
360 from kallithea.lib import helpers as h
360 from kallithea.lib import helpers as h
361 token = request.POST.get(h.session_csrf_secret_name)
361 token = request.POST.get(h.session_csrf_secret_name)
362 if not token or token != h.session_csrf_secret_token():
362 if not token or token != h.session_csrf_secret_token():
363 log.error('CSRF check failed')
363 log.error('CSRF check failed')
364 raise webob.exc.HTTPForbidden()
364 raise webob.exc.HTTPForbidden()
365
365
366 c.kallithea_version = __version__
366 c.kallithea_version = __version__
367 rc_config = Setting.get_app_settings()
367 rc_config = Setting.get_app_settings()
368
368
369 # Visual options
369 # Visual options
370 c.visual = AttributeDict({})
370 c.visual = AttributeDict({})
371
371
372 ## DB stored
372 ## DB stored
373 c.visual.show_public_icon = str2bool(rc_config.get('show_public_icon'))
373 c.visual.show_public_icon = str2bool(rc_config.get('show_public_icon'))
374 c.visual.show_private_icon = str2bool(rc_config.get('show_private_icon'))
374 c.visual.show_private_icon = str2bool(rc_config.get('show_private_icon'))
375 c.visual.stylify_metalabels = str2bool(rc_config.get('stylify_metalabels'))
375 c.visual.stylify_metalabels = str2bool(rc_config.get('stylify_metalabels'))
376 c.visual.page_size = safe_int(rc_config.get('dashboard_items', 100))
376 c.visual.page_size = safe_int(rc_config.get('dashboard_items', 100))
377 c.visual.admin_grid_items = safe_int(rc_config.get('admin_grid_items', 100))
377 c.visual.admin_grid_items = safe_int(rc_config.get('admin_grid_items', 100))
378 c.visual.repository_fields = str2bool(rc_config.get('repository_fields'))
378 c.visual.repository_fields = str2bool(rc_config.get('repository_fields'))
379 c.visual.show_version = str2bool(rc_config.get('show_version'))
379 c.visual.show_version = str2bool(rc_config.get('show_version'))
380 c.visual.use_gravatar = str2bool(rc_config.get('use_gravatar'))
380 c.visual.use_gravatar = str2bool(rc_config.get('use_gravatar'))
381 c.visual.gravatar_url = rc_config.get('gravatar_url')
381 c.visual.gravatar_url = rc_config.get('gravatar_url')
382
382
383 c.ga_code = rc_config.get('ga_code')
383 c.ga_code = rc_config.get('ga_code')
384 # TODO: replace undocumented backwards compatibility hack with db upgrade and rename ga_code
384 # TODO: replace undocumented backwards compatibility hack with db upgrade and rename ga_code
385 if c.ga_code and '<' not in c.ga_code:
385 if c.ga_code and '<' not in c.ga_code:
386 c.ga_code = '''<script type="text/javascript">
386 c.ga_code = '''<script type="text/javascript">
387 var _gaq = _gaq || [];
387 var _gaq = _gaq || [];
388 _gaq.push(['_setAccount', '%s']);
388 _gaq.push(['_setAccount', '%s']);
389 _gaq.push(['_trackPageview']);
389 _gaq.push(['_trackPageview']);
390
390
391 (function() {
391 (function() {
392 var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
392 var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
393 ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
393 ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
394 var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
394 var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
395 })();
395 })();
396 </script>''' % c.ga_code
396 </script>''' % c.ga_code
397 c.site_name = rc_config.get('title')
397 c.site_name = rc_config.get('title')
398 c.clone_uri_tmpl = rc_config.get('clone_uri_tmpl') or Repository.DEFAULT_CLONE_URI
398 c.clone_uri_tmpl = rc_config.get('clone_uri_tmpl') or Repository.DEFAULT_CLONE_URI
399 c.clone_ssh_tmpl = rc_config.get('clone_ssh_tmpl') or Repository.DEFAULT_CLONE_SSH
399 c.clone_ssh_tmpl = rc_config.get('clone_ssh_tmpl') or Repository.DEFAULT_CLONE_SSH
400
400
401 ## INI stored
401 ## INI stored
402 c.visual.allow_repo_location_change = str2bool(config.get('allow_repo_location_change', True))
402 c.visual.allow_repo_location_change = str2bool(config.get('allow_repo_location_change', True))
403 c.visual.allow_custom_hooks_settings = str2bool(config.get('allow_custom_hooks_settings', True))
403 c.visual.allow_custom_hooks_settings = str2bool(config.get('allow_custom_hooks_settings', True))
404 c.ssh_enabled = str2bool(config.get('ssh_enabled', False))
404 c.ssh_enabled = str2bool(config.get('ssh_enabled', False))
405
405
406 c.instance_id = config.get('instance_id')
406 c.instance_id = config.get('instance_id')
407 c.issues_url = config.get('bugtracker', url('issues_url'))
407 c.issues_url = config.get('bugtracker', url('issues_url'))
408 # END CONFIG VARS
408 # END CONFIG VARS
409
409
410 c.repo_name = get_repo_slug(request) # can be empty
410 c.repo_name = get_repo_slug(request) # can be empty
411 c.backends = list(BACKENDS)
411 c.backends = list(BACKENDS)
412
412
413 self.cut_off_limit = safe_int(config.get('cut_off_limit'))
413 self.cut_off_limit = safe_int(config.get('cut_off_limit'))
414
414
415 c.my_pr_count = PullRequest.query(reviewer_id=request.authuser.user_id, include_closed=False).count()
415 c.my_pr_count = PullRequest.query(reviewer_id=request.authuser.user_id, include_closed=False).count()
416
416
417 self.scm_model = ScmModel()
417 self.scm_model = ScmModel()
418
418
419 @staticmethod
419 @staticmethod
420 def _determine_auth_user(session_authuser, ip_addr):
420 def _determine_auth_user(session_authuser, ip_addr):
421 """
421 """
422 Create an `AuthUser` object given the API key/bearer token
422 Create an `AuthUser` object given the API key/bearer token
423 (if any) and the value of the authuser session cookie.
423 (if any) and the value of the authuser session cookie.
424 Returns None if no valid user is found (like not active or no access for IP).
424 Returns None if no valid user is found (like not active or no access for IP).
425 """
425 """
426
426
427 # Authenticate by session cookie
427 # Authenticate by session cookie
428 # In ancient login sessions, 'authuser' may not be a dict.
428 # In ancient login sessions, 'authuser' may not be a dict.
429 # In that case, the user will have to log in again.
429 # In that case, the user will have to log in again.
430 # v0.3 and earlier included an 'is_authenticated' key; if present,
430 # v0.3 and earlier included an 'is_authenticated' key; if present,
431 # this must be True.
431 # this must be True.
432 if isinstance(session_authuser, dict) and session_authuser.get('is_authenticated', True):
432 if isinstance(session_authuser, dict) and session_authuser.get('is_authenticated', True):
433 return AuthUser.from_cookie(session_authuser, ip_addr=ip_addr)
433 return AuthUser.from_cookie(session_authuser, ip_addr=ip_addr)
434
434
435 # Authenticate by auth_container plugin (if enabled)
435 # Authenticate by auth_container plugin (if enabled)
436 if any(
436 if any(
437 plugin.is_container_auth
437 plugin.is_container_auth
438 for plugin in auth_modules.get_auth_plugins()
438 for plugin in auth_modules.get_auth_plugins()
439 ):
439 ):
440 try:
440 try:
441 user_info = auth_modules.authenticate('', '', request.environ)
441 user_info = auth_modules.authenticate('', '', request.environ)
442 except UserCreationError as e:
442 except UserCreationError as e:
443 from kallithea.lib import helpers as h
443 from kallithea.lib import helpers as h
444 h.flash(e, 'error', logf=log.error)
444 h.flash(e, 'error', logf=log.error)
445 else:
445 else:
446 if user_info is not None:
446 if user_info is not None:
447 username = user_info['username']
447 username = user_info['username']
448 user = User.get_by_username(username, case_insensitive=True)
448 user = User.get_by_username(username, case_insensitive=True)
449 return log_in_user(user, remember=False, is_external_auth=True, ip_addr=ip_addr)
449 return log_in_user(user, remember=False, is_external_auth=True, ip_addr=ip_addr)
450
450
451 # User is default user (if active) or anonymous
451 # User is default user (if active) or anonymous
452 default_user = User.get_default_user(cache=True)
452 default_user = User.get_default_user(cache=True)
453 authuser = AuthUser.make(dbuser=default_user, ip_addr=ip_addr)
453 authuser = AuthUser.make(dbuser=default_user, ip_addr=ip_addr)
454 if authuser is None: # fall back to anonymous
454 if authuser is None: # fall back to anonymous
455 authuser = AuthUser(dbuser=default_user) # TODO: somehow use .make?
455 authuser = AuthUser(dbuser=default_user) # TODO: somehow use .make?
456 return authuser
456 return authuser
457
457
458 @staticmethod
458 @staticmethod
459 def _basic_security_checks():
459 def _basic_security_checks():
460 """Perform basic security/sanity checks before processing the request."""
460 """Perform basic security/sanity checks before processing the request."""
461
461
462 # Only allow the following HTTP request methods.
462 # Only allow the following HTTP request methods.
463 if request.method not in ['GET', 'HEAD', 'POST']:
463 if request.method not in ['GET', 'HEAD', 'POST']:
464 raise webob.exc.HTTPMethodNotAllowed()
464 raise webob.exc.HTTPMethodNotAllowed()
465
465
466 # Also verify the _method override - no longer allowed.
466 # Also verify the _method override - no longer allowed.
467 if request.params.get('_method') is None:
467 if request.params.get('_method') is None:
468 pass # no override, no problem
468 pass # no override, no problem
469 else:
469 else:
470 raise webob.exc.HTTPMethodNotAllowed()
470 raise webob.exc.HTTPMethodNotAllowed()
471
471
472 # Make sure CSRF token never appears in the URL. If so, invalidate it.
472 # Make sure CSRF token never appears in the URL. If so, invalidate it.
473 from kallithea.lib import helpers as h
473 from kallithea.lib import helpers as h
474 if h.session_csrf_secret_name in request.GET:
474 if h.session_csrf_secret_name in request.GET:
475 log.error('CSRF key leak detected')
475 log.error('CSRF key leak detected')
476 session.pop(h.session_csrf_secret_name, None)
476 session.pop(h.session_csrf_secret_name, None)
477 session.save()
477 session.save()
478 h.flash(_('CSRF token leak has been detected - all form tokens have been expired'),
478 h.flash(_('CSRF token leak has been detected - all form tokens have been expired'),
479 category='error')
479 category='error')
480
480
481 # WebOb already ignores request payload parameters for anything other
481 # WebOb already ignores request payload parameters for anything other
482 # than POST/PUT, but double-check since other Kallithea code relies on
482 # than POST/PUT, but double-check since other Kallithea code relies on
483 # this assumption.
483 # this assumption.
484 if request.method not in ['POST', 'PUT'] and request.POST:
484 if request.method not in ['POST', 'PUT'] and request.POST:
485 log.error('%r request with payload parameters; WebOb should have stopped this', request.method)
485 log.error('%r request with payload parameters; WebOb should have stopped this', request.method)
486 raise webob.exc.HTTPBadRequest()
486 raise webob.exc.HTTPBadRequest()
487
487
488 def __call__(self, environ, context):
488 def __call__(self, environ, context):
489 try:
489 try:
490 ip_addr = _get_ip_addr(environ)
490 ip_addr = _get_ip_addr(environ)
491 self._basic_security_checks()
491 self._basic_security_checks()
492
492
493 api_key = request.GET.get('api_key')
493 api_key = request.GET.get('api_key')
494 try:
494 try:
495 # Request.authorization may raise ValueError on invalid input
495 # Request.authorization may raise ValueError on invalid input
496 type, params = request.authorization
496 type, params = request.authorization
497 except (ValueError, TypeError):
497 except (ValueError, TypeError):
498 pass
498 pass
499 else:
499 else:
500 if type.lower() == 'bearer':
500 if type.lower() == 'bearer':
501 api_key = params # bearer token is an api key too
501 api_key = params # bearer token is an api key too
502
502
503 if api_key is None:
503 if api_key is None:
504 authuser = self._determine_auth_user(
504 authuser = self._determine_auth_user(
505 session.get('authuser'),
505 session.get('authuser'),
506 ip_addr=ip_addr,
506 ip_addr=ip_addr,
507 )
507 )
508 needs_csrf_check = request.method not in ['GET', 'HEAD']
508 needs_csrf_check = request.method not in ['GET', 'HEAD']
509
509
510 else:
510 else:
511 dbuser = User.get_by_api_key(api_key)
511 dbuser = User.get_by_api_key(api_key)
512 if dbuser is None:
512 if dbuser is None:
513 log.info('No db user found for authentication with API key ****%s from %s',
513 log.info('No db user found for authentication with API key ****%s from %s',
514 api_key[-4:], ip_addr)
514 api_key[-4:], ip_addr)
515 authuser = AuthUser.make(dbuser=dbuser, is_external_auth=True, ip_addr=ip_addr)
515 authuser = AuthUser.make(dbuser=dbuser, is_external_auth=True, ip_addr=ip_addr)
516 needs_csrf_check = False # API key provides CSRF protection
516 needs_csrf_check = False # API key provides CSRF protection
517
517
518 if authuser is None:
518 if authuser is None:
519 log.info('No valid user found')
519 log.info('No valid user found')
520 raise webob.exc.HTTPForbidden()
520 raise webob.exc.HTTPForbidden()
521
521
522 # set globals for auth user
522 # set globals for auth user
523 request.authuser = authuser
523 request.authuser = authuser
524 request.ip_addr = ip_addr
524 request.ip_addr = ip_addr
525 request.needs_csrf_check = needs_csrf_check
525 request.needs_csrf_check = needs_csrf_check
526
526
527 log.info('IP: %s User: %s accessed %s',
527 log.info('IP: %s User: %s accessed %s',
528 request.ip_addr, request.authuser,
528 request.ip_addr, request.authuser,
529 get_path_info(environ),
529 get_path_info(environ),
530 )
530 )
531 return super(BaseController, self).__call__(environ, context)
531 return super(BaseController, self).__call__(environ, context)
532 except webob.exc.HTTPException as e:
532 except webob.exc.HTTPException as e:
533 return e
533 return e
534
534
535
535
536 class BaseRepoController(BaseController):
536 class BaseRepoController(BaseController):
537 """
537 """
538 Base class for controllers responsible for loading all needed data for
538 Base class for controllers responsible for loading all needed data for
539 repository loaded items are
539 repository loaded items are
540
540
541 c.db_repo_scm_instance: instance of scm repository
541 c.db_repo_scm_instance: instance of scm repository
542 c.db_repo: instance of db
542 c.db_repo: instance of db
543 c.repository_followers: number of followers
543 c.repository_followers: number of followers
544 c.repository_forks: number of forks
544 c.repository_forks: number of forks
545 c.repository_following: weather the current user is following the current repo
545 c.repository_following: weather the current user is following the current repo
546 """
546 """
547
547
548 def _before(self, *args, **kwargs):
548 def _before(self, *args, **kwargs):
549 super(BaseRepoController, self)._before(*args, **kwargs)
549 super(BaseRepoController, self)._before(*args, **kwargs)
550 if c.repo_name: # extracted from routes
550 if c.repo_name: # extracted from routes
551 _dbr = Repository.get_by_repo_name(c.repo_name)
551 _dbr = Repository.get_by_repo_name(c.repo_name)
552 if not _dbr:
552 if not _dbr:
553 return
553 return
554
554
555 log.debug('Found repository in database %s with state `%s`',
555 log.debug('Found repository in database %s with state `%s`',
556 _dbr, _dbr.repo_state)
556 _dbr, _dbr.repo_state)
557 route = getattr(request.environ.get('routes.route'), 'name', '')
557 route = getattr(request.environ.get('routes.route'), 'name', '')
558
558
559 # allow to delete repos that are somehow damages in filesystem
559 # allow to delete repos that are somehow damages in filesystem
560 if route in ['delete_repo']:
560 if route in ['delete_repo']:
561 return
561 return
562
562
563 if _dbr.repo_state in [Repository.STATE_PENDING]:
563 if _dbr.repo_state in [Repository.STATE_PENDING]:
564 if route in ['repo_creating_home']:
564 if route in ['repo_creating_home']:
565 return
565 return
566 check_url = url('repo_creating_home', repo_name=c.repo_name)
566 check_url = url('repo_creating_home', repo_name=c.repo_name)
567 raise webob.exc.HTTPFound(location=check_url)
567 raise webob.exc.HTTPFound(location=check_url)
568
568
569 dbr = c.db_repo = _dbr
569 dbr = c.db_repo = _dbr
570 c.db_repo_scm_instance = c.db_repo.scm_instance
570 c.db_repo_scm_instance = c.db_repo.scm_instance
571 if c.db_repo_scm_instance is None:
571 if c.db_repo_scm_instance is None:
572 log.error('%s this repository is present in database but it '
572 log.error('%s this repository is present in database but it '
573 'cannot be created as an scm instance', c.repo_name)
573 'cannot be created as an scm instance', c.repo_name)
574 from kallithea.lib import helpers as h
574 from kallithea.lib import helpers as h
575 h.flash(_('Repository not found in the filesystem'),
575 h.flash(_('Repository not found in the filesystem'),
576 category='error')
576 category='error')
577 raise webob.exc.HTTPNotFound()
577 raise webob.exc.HTTPNotFound()
578
578
579 # some globals counter for menu
579 # some globals counter for menu
580 c.repository_followers = self.scm_model.get_followers(dbr)
580 c.repository_followers = self.scm_model.get_followers(dbr)
581 c.repository_forks = self.scm_model.get_forks(dbr)
581 c.repository_forks = self.scm_model.get_forks(dbr)
582 c.repository_pull_requests = self.scm_model.get_pull_requests(dbr)
582 c.repository_pull_requests = self.scm_model.get_pull_requests(dbr)
583 c.repository_following = self.scm_model.is_following_repo(
583 c.repository_following = self.scm_model.is_following_repo(
584 c.repo_name, request.authuser.user_id)
584 c.repo_name, request.authuser.user_id)
585
585
586 @staticmethod
586 @staticmethod
587 def _get_ref_rev(repo, ref_type, ref_name, returnempty=False):
587 def _get_ref_rev(repo, ref_type, ref_name, returnempty=False):
588 """
588 """
589 Safe way to get changeset. If error occurs show error.
589 Safe way to get changeset. If error occurs show error.
590 """
590 """
591 from kallithea.lib import helpers as h
591 from kallithea.lib import helpers as h
592 try:
592 try:
593 return repo.scm_instance.get_ref_revision(ref_type, ref_name)
593 return repo.scm_instance.get_ref_revision(ref_type, ref_name)
594 except EmptyRepositoryError as e:
594 except EmptyRepositoryError as e:
595 if returnempty:
595 if returnempty:
596 return repo.scm_instance.EMPTY_CHANGESET
596 return repo.scm_instance.EMPTY_CHANGESET
597 h.flash(_('There are no changesets yet'), category='error')
597 h.flash(_('There are no changesets yet'), category='error')
598 raise webob.exc.HTTPNotFound()
598 raise webob.exc.HTTPNotFound()
599 except ChangesetDoesNotExistError as e:
599 except ChangesetDoesNotExistError as e:
600 h.flash(_('Changeset for %s %s not found in %s') %
600 h.flash(_('Changeset for %s %s not found in %s') %
601 (ref_type, ref_name, repo.repo_name),
601 (ref_type, ref_name, repo.repo_name),
602 category='error')
602 category='error')
603 raise webob.exc.HTTPNotFound()
603 raise webob.exc.HTTPNotFound()
604 except RepositoryError as e:
604 except RepositoryError as e:
605 log.error(traceback.format_exc())
605 log.error(traceback.format_exc())
606 h.flash(e, category='error')
606 h.flash(e, category='error')
607 raise webob.exc.HTTPBadRequest()
607 raise webob.exc.HTTPBadRequest()
608
608
609
609
610 @decorator.decorator
610 @decorator.decorator
611 def jsonify(func, *args, **kwargs):
611 def jsonify(func, *args, **kwargs):
612 """Action decorator that formats output for JSON
612 """Action decorator that formats output for JSON
613
613
614 Given a function that will return content, this decorator will turn
614 Given a function that will return content, this decorator will turn
615 the result into JSON, with a content-type of 'application/json' and
615 the result into JSON, with a content-type of 'application/json' and
616 output it.
616 output it.
617 """
617 """
618 response.headers['Content-Type'] = 'application/json; charset=utf-8'
618 response.headers['Content-Type'] = 'application/json; charset=utf-8'
619 data = func(*args, **kwargs)
619 data = func(*args, **kwargs)
620 if isinstance(data, (list, tuple)):
620 if isinstance(data, (list, tuple)):
621 # A JSON list response is syntactically valid JavaScript and can be
621 # A JSON list response is syntactically valid JavaScript and can be
622 # loaded and executed as JavaScript by a malicious third-party site
622 # loaded and executed as JavaScript by a malicious third-party site
623 # using <script>, which can lead to cross-site data leaks.
623 # using <script>, which can lead to cross-site data leaks.
624 # JSON responses should therefore be scalars or objects (i.e. Python
624 # JSON responses should therefore be scalars or objects (i.e. Python
625 # dicts), because a JSON object is a syntax error if intepreted as JS.
625 # dicts), because a JSON object is a syntax error if intepreted as JS.
626 msg = "JSON responses with Array envelopes are susceptible to " \
626 msg = "JSON responses with Array envelopes are susceptible to " \
627 "cross-site data leak attacks, see " \
627 "cross-site data leak attacks, see " \
628 "https://web.archive.org/web/20120519231904/http://wiki.pylonshq.com/display/pylonsfaq/Warnings"
628 "https://web.archive.org/web/20120519231904/http://wiki.pylonshq.com/display/pylonsfaq/Warnings"
629 warnings.warn(msg, Warning, 2)
629 warnings.warn(msg, Warning, 2)
630 log.warning(msg)
630 log.warning(msg)
631 log.debug("Returning JSON wrapped action output")
631 log.debug("Returning JSON wrapped action output")
632 return ascii_bytes(ext_json.dumps(data))
632 return ascii_bytes(ext_json.dumps(data))
633
633
634 @decorator.decorator
634 @decorator.decorator
635 def IfSshEnabled(func, *args, **kwargs):
635 def IfSshEnabled(func, *args, **kwargs):
636 """Decorator for functions that can only be called if SSH access is enabled.
636 """Decorator for functions that can only be called if SSH access is enabled.
637
637
638 If SSH access is disabled in the configuration file, HTTPNotFound is raised.
638 If SSH access is disabled in the configuration file, HTTPNotFound is raised.
639 """
639 """
640 if not c.ssh_enabled:
640 if not c.ssh_enabled:
641 from kallithea.lib import helpers as h
641 from kallithea.lib import helpers as h
642 h.flash(_("SSH access is disabled."), category='warning')
642 h.flash(_("SSH access is disabled."), category='warning')
643 raise webob.exc.HTTPNotFound()
643 raise webob.exc.HTTPNotFound()
644 return func(*args, **kwargs)
644 return func(*args, **kwargs)
@@ -1,679 +1,679 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 # This program is free software: you can redistribute it and/or modify
2 # This program is free software: you can redistribute it and/or modify
3 # it under the terms of the GNU General Public License as published by
3 # it under the terms of the GNU General Public License as published by
4 # the Free Software Foundation, either version 3 of the License, or
4 # the Free Software Foundation, either version 3 of the License, or
5 # (at your option) any later version.
5 # (at your option) any later version.
6 #
6 #
7 # This program is distributed in the hope that it will be useful,
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
10 # GNU General Public License for more details.
11 #
11 #
12 # You should have received a copy of the GNU General Public License
12 # You should have received a copy of the GNU General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 """
14 """
15 kallithea.lib.diffs
15 kallithea.lib.diffs
16 ~~~~~~~~~~~~~~~~~~~
16 ~~~~~~~~~~~~~~~~~~~
17
17
18 Set of diffing helpers, previously part of vcs
18 Set of diffing helpers, previously part of vcs
19
19
20
20
21 This file was forked by the Kallithea project in July 2014.
21 This file was forked by the Kallithea project in July 2014.
22 Original author and date, and relevant copyright and licensing information is below:
22 Original author and date, and relevant copyright and licensing information is below:
23 :created_on: Dec 4, 2011
23 :created_on: Dec 4, 2011
24 :author: marcink
24 :author: marcink
25 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 :copyright: (c) 2013 RhodeCode GmbH, and others.
26 :license: GPLv3, see LICENSE.md for more details.
26 :license: GPLv3, see LICENSE.md for more details.
27 """
27 """
28 import difflib
28 import difflib
29 import logging
29 import logging
30 import re
30 import re
31
31
32 from tg.i18n import ugettext as _
32 from tg.i18n import ugettext as _
33
33
34 from kallithea.lib import helpers as h
34 from kallithea.lib import helpers as h
35 from kallithea.lib.utils2 import safe_str
35 from kallithea.lib.utils2 import safe_str
36 from kallithea.lib.vcs.backends.base import EmptyChangeset
36 from kallithea.lib.vcs.backends.base import EmptyChangeset
37 from kallithea.lib.vcs.exceptions import VCSError
37 from kallithea.lib.vcs.exceptions import VCSError
38 from kallithea.lib.vcs.nodes import FileNode, SubModuleNode
38 from kallithea.lib.vcs.nodes import FileNode, SubModuleNode
39
39
40
40
41 log = logging.getLogger(__name__)
41 log = logging.getLogger(__name__)
42
42
43
43
44 def _safe_id(idstring):
44 def _safe_id(idstring):
45 r"""Make a string safe for including in an id attribute.
45 r"""Make a string safe for including in an id attribute.
46
46
47 The HTML spec says that id attributes 'must begin with
47 The HTML spec says that id attributes 'must begin with
48 a letter ([A-Za-z]) and may be followed by any number
48 a letter ([A-Za-z]) and may be followed by any number
49 of letters, digits ([0-9]), hyphens ("-"), underscores
49 of letters, digits ([0-9]), hyphens ("-"), underscores
50 ("_"), colons (":"), and periods (".")'. These regexps
50 ("_"), colons (":"), and periods (".")'. These regexps
51 are slightly over-zealous, in that they remove colons
51 are slightly over-zealous, in that they remove colons
52 and periods unnecessarily.
52 and periods unnecessarily.
53
53
54 Whitespace is transformed into underscores, and then
54 Whitespace is transformed into underscores, and then
55 anything which is not a hyphen or a character that
55 anything which is not a hyphen or a character that
56 matches \w (alphanumerics and underscore) is removed.
56 matches \w (alphanumerics and underscore) is removed.
57
57
58 """
58 """
59 # Transform all whitespace to underscore
59 # Transform all whitespace to underscore
60 idstring = re.sub(r'\s', "_", idstring)
60 idstring = re.sub(r'\s', "_", idstring)
61 # Remove everything that is not a hyphen or a member of \w
61 # Remove everything that is not a hyphen or a member of \w
62 idstring = re.sub(r'(?!-)\W', "", idstring).lower()
62 idstring = re.sub(r'(?!-)\W', "", idstring).lower()
63 return idstring
63 return idstring
64
64
65
65
66 def as_html(table_class='code-difftable', line_class='line',
66 def as_html(table_class='code-difftable', line_class='line',
67 old_lineno_class='lineno old', new_lineno_class='lineno new',
67 old_lineno_class='lineno old', new_lineno_class='lineno new',
68 no_lineno_class='lineno',
68 no_lineno_class='lineno',
69 code_class='code', enable_comments=False, parsed_lines=None):
69 code_class='code', enable_comments=False, parsed_lines=None):
70 """
70 """
71 Return given diff as html table with customized css classes
71 Return given diff as html table with customized css classes
72 """
72 """
73 def _link_to_if(condition, label, url):
73 def _link_to_if(condition, label, url):
74 """
74 """
75 Generates a link if condition is meet or just the label if not.
75 Generates a link if condition is meet or just the label if not.
76 """
76 """
77
77
78 if condition:
78 if condition:
79 return '''<a href="%(url)s" data-pseudo-content="%(label)s"></a>''' % {
79 return '''<a href="%(url)s" data-pseudo-content="%(label)s"></a>''' % {
80 'url': url,
80 'url': url,
81 'label': label
81 'label': label
82 }
82 }
83 else:
83 else:
84 return label
84 return label
85
85
86 _html_empty = True
86 _html_empty = True
87 _html = []
87 _html = []
88 _html.append('''<table class="%(table_class)s">\n''' % {
88 _html.append('''<table class="%(table_class)s">\n''' % {
89 'table_class': table_class
89 'table_class': table_class
90 })
90 })
91
91
92 for diff in parsed_lines:
92 for diff in parsed_lines:
93 for line in diff['chunks']:
93 for line in diff['chunks']:
94 _html_empty = False
94 _html_empty = False
95 for change in line:
95 for change in line:
96 _html.append('''<tr class="%(lc)s %(action)s">\n''' % {
96 _html.append('''<tr class="%(lc)s %(action)s">\n''' % {
97 'lc': line_class,
97 'lc': line_class,
98 'action': change['action']
98 'action': change['action']
99 })
99 })
100 anchor_old_id = ''
100 anchor_old_id = ''
101 anchor_new_id = ''
101 anchor_new_id = ''
102 anchor_old = "%(filename)s_o%(oldline_no)s" % {
102 anchor_old = "%(filename)s_o%(oldline_no)s" % {
103 'filename': _safe_id(diff['filename']),
103 'filename': _safe_id(diff['filename']),
104 'oldline_no': change['old_lineno']
104 'oldline_no': change['old_lineno']
105 }
105 }
106 anchor_new = "%(filename)s_n%(oldline_no)s" % {
106 anchor_new = "%(filename)s_n%(oldline_no)s" % {
107 'filename': _safe_id(diff['filename']),
107 'filename': _safe_id(diff['filename']),
108 'oldline_no': change['new_lineno']
108 'oldline_no': change['new_lineno']
109 }
109 }
110 cond_old = (change['old_lineno'] != '...' and
110 cond_old = (change['old_lineno'] != '...' and
111 change['old_lineno'])
111 change['old_lineno'])
112 cond_new = (change['new_lineno'] != '...' and
112 cond_new = (change['new_lineno'] != '...' and
113 change['new_lineno'])
113 change['new_lineno'])
114 no_lineno = (change['old_lineno'] == '...' and
114 no_lineno = (change['old_lineno'] == '...' and
115 change['new_lineno'] == '...')
115 change['new_lineno'] == '...')
116 if cond_old:
116 if cond_old:
117 anchor_old_id = 'id="%s"' % anchor_old
117 anchor_old_id = 'id="%s"' % anchor_old
118 if cond_new:
118 if cond_new:
119 anchor_new_id = 'id="%s"' % anchor_new
119 anchor_new_id = 'id="%s"' % anchor_new
120 ###########################################################
120 ###########################################################
121 # OLD LINE NUMBER
121 # OLD LINE NUMBER
122 ###########################################################
122 ###########################################################
123 _html.append('''\t<td %(a_id)s class="%(olc)s" %(colspan)s>''' % {
123 _html.append('''\t<td %(a_id)s class="%(olc)s" %(colspan)s>''' % {
124 'a_id': anchor_old_id,
124 'a_id': anchor_old_id,
125 'olc': no_lineno_class if no_lineno else old_lineno_class,
125 'olc': no_lineno_class if no_lineno else old_lineno_class,
126 'colspan': 'colspan="2"' if no_lineno else ''
126 'colspan': 'colspan="2"' if no_lineno else ''
127 })
127 })
128
128
129 _html.append('''%(link)s''' % {
129 _html.append('''%(link)s''' % {
130 'link': _link_to_if(not no_lineno, change['old_lineno'],
130 'link': _link_to_if(not no_lineno, change['old_lineno'],
131 '#%s' % anchor_old)
131 '#%s' % anchor_old)
132 })
132 })
133 _html.append('''</td>\n''')
133 _html.append('''</td>\n''')
134 ###########################################################
134 ###########################################################
135 # NEW LINE NUMBER
135 # NEW LINE NUMBER
136 ###########################################################
136 ###########################################################
137
137
138 if not no_lineno:
138 if not no_lineno:
139 _html.append('''\t<td %(a_id)s class="%(nlc)s">''' % {
139 _html.append('''\t<td %(a_id)s class="%(nlc)s">''' % {
140 'a_id': anchor_new_id,
140 'a_id': anchor_new_id,
141 'nlc': new_lineno_class
141 'nlc': new_lineno_class
142 })
142 })
143
143
144 _html.append('''%(link)s''' % {
144 _html.append('''%(link)s''' % {
145 'link': _link_to_if(True, change['new_lineno'],
145 'link': _link_to_if(True, change['new_lineno'],
146 '#%s' % anchor_new)
146 '#%s' % anchor_new)
147 })
147 })
148 _html.append('''</td>\n''')
148 _html.append('''</td>\n''')
149 ###########################################################
149 ###########################################################
150 # CODE
150 # CODE
151 ###########################################################
151 ###########################################################
152 comments = '' if enable_comments else 'no-comment'
152 comments = '' if enable_comments else 'no-comment'
153 _html.append('''\t<td class="%(cc)s %(inc)s">''' % {
153 _html.append('''\t<td class="%(cc)s %(inc)s">''' % {
154 'cc': code_class,
154 'cc': code_class,
155 'inc': comments
155 'inc': comments
156 })
156 })
157 _html.append('''\n\t\t<div class="add-bubble"><div>&nbsp;</div></div><pre>%(code)s</pre>\n''' % {
157 _html.append('''\n\t\t<div class="add-bubble"><div>&nbsp;</div></div><pre>%(code)s</pre>\n''' % {
158 'code': change['line']
158 'code': change['line']
159 })
159 })
160
160
161 _html.append('''\t</td>''')
161 _html.append('''\t</td>''')
162 _html.append('''\n</tr>\n''')
162 _html.append('''\n</tr>\n''')
163 _html.append('''</table>''')
163 _html.append('''</table>''')
164 if _html_empty:
164 if _html_empty:
165 return None
165 return None
166 return ''.join(_html)
166 return ''.join(_html)
167
167
168
168
169 def wrap_to_table(html):
169 def wrap_to_table(html):
170 """Given a string with html, return it wrapped in a table, similar to what
170 """Given a string with html, return it wrapped in a table, similar to what
171 DiffProcessor returns."""
171 DiffProcessor returns."""
172 return '''\
172 return '''\
173 <table class="code-difftable">
173 <table class="code-difftable">
174 <tr class="line no-comment">
174 <tr class="line no-comment">
175 <td class="lineno new"></td>
175 <td class="lineno new"></td>
176 <td class="code no-comment"><pre>%s</pre></td>
176 <td class="code no-comment"><pre>%s</pre></td>
177 </tr>
177 </tr>
178 </table>''' % html
178 </table>''' % html
179
179
180
180
181 def wrapped_diff(filenode_old, filenode_new, diff_limit=None,
181 def wrapped_diff(filenode_old, filenode_new, diff_limit=None,
182 ignore_whitespace=True, line_context=3,
182 ignore_whitespace=True, line_context=3,
183 enable_comments=False):
183 enable_comments=False):
184 """
184 """
185 Returns a file diff wrapped into a table.
185 Returns a file diff wrapped into a table.
186 Checks for diff_limit and presents a message if the diff is too big.
186 Checks for diff_limit and presents a message if the diff is too big.
187 """
187 """
188 if filenode_old is None:
188 if filenode_old is None:
189 filenode_old = FileNode(filenode_new.path, '', EmptyChangeset())
189 filenode_old = FileNode(filenode_new.path, '', EmptyChangeset())
190
190
191 op = None
191 op = None
192 a_path = filenode_old.path # default, might be overriden by actual rename in diff
192 a_path = filenode_old.path # default, might be overriden by actual rename in diff
193 if filenode_old.is_binary or filenode_new.is_binary:
193 if filenode_old.is_binary or filenode_new.is_binary:
194 html_diff = wrap_to_table(_('Binary file'))
194 html_diff = wrap_to_table(_('Binary file'))
195 stats = (0, 0)
195 stats = (0, 0)
196
196
197 elif diff_limit != -1 and (
197 elif diff_limit != -1 and (
198 diff_limit is None or
198 diff_limit is None or
199 (filenode_old.size < diff_limit and filenode_new.size < diff_limit)):
199 (filenode_old.size < diff_limit and filenode_new.size < diff_limit)):
200
200
201 raw_diff = get_gitdiff(filenode_old, filenode_new,
201 raw_diff = get_gitdiff(filenode_old, filenode_new,
202 ignore_whitespace=ignore_whitespace,
202 ignore_whitespace=ignore_whitespace,
203 context=line_context)
203 context=line_context)
204 diff_processor = DiffProcessor(raw_diff)
204 diff_processor = DiffProcessor(raw_diff)
205 if diff_processor.parsed: # there should be exactly one element, for the specified file
205 if diff_processor.parsed: # there should be exactly one element, for the specified file
206 f = diff_processor.parsed[0]
206 f = diff_processor.parsed[0]
207 op = f['operation']
207 op = f['operation']
208 a_path = f['old_filename']
208 a_path = f['old_filename']
209
209
210 html_diff = as_html(parsed_lines=diff_processor.parsed, enable_comments=enable_comments)
210 html_diff = as_html(parsed_lines=diff_processor.parsed, enable_comments=enable_comments)
211 stats = diff_processor.stat()
211 stats = diff_processor.stat()
212
212
213 else:
213 else:
214 html_diff = wrap_to_table(_('Changeset was too big and was cut off, use '
214 html_diff = wrap_to_table(_('Changeset was too big and was cut off, use '
215 'diff menu to display this diff'))
215 'diff menu to display this diff'))
216 stats = (0, 0)
216 stats = (0, 0)
217
217
218 if not html_diff:
218 if not html_diff:
219 submodules = [o for o in [filenode_new, filenode_old] if isinstance(o, SubModuleNode)]
219 submodules = [o for o in [filenode_new, filenode_old] if isinstance(o, SubModuleNode)]
220 if submodules:
220 if submodules:
221 html_diff = wrap_to_table(h.escape('Submodule %r' % submodules[0]))
221 html_diff = wrap_to_table(h.escape('Submodule %r' % submodules[0]))
222 else:
222 else:
223 html_diff = wrap_to_table(_('No changes detected'))
223 html_diff = wrap_to_table(_('No changes detected'))
224
224
225 cs1 = filenode_old.changeset.raw_id
225 cs1 = filenode_old.changeset.raw_id
226 cs2 = filenode_new.changeset.raw_id
226 cs2 = filenode_new.changeset.raw_id
227
227
228 return cs1, cs2, a_path, html_diff, stats, op
228 return cs1, cs2, a_path, html_diff, stats, op
229
229
230
230
231 def get_gitdiff(filenode_old, filenode_new, ignore_whitespace=True, context=3):
231 def get_gitdiff(filenode_old, filenode_new, ignore_whitespace=True, context=3):
232 """
232 """
233 Returns git style diff between given ``filenode_old`` and ``filenode_new``.
233 Returns git style diff between given ``filenode_old`` and ``filenode_new``.
234 """
234 """
235 # make sure we pass in default context
235 # make sure we pass in default context
236 context = context or 3
236 context = context or 3
237 submodules = [o for o in [filenode_new, filenode_old] if isinstance(o, SubModuleNode)]
237 submodules = [o for o in [filenode_new, filenode_old] if isinstance(o, SubModuleNode)]
238 if submodules:
238 if submodules:
239 return b''
239 return b''
240
240
241 for filenode in (filenode_old, filenode_new):
241 for filenode in (filenode_old, filenode_new):
242 if not isinstance(filenode, FileNode):
242 if not isinstance(filenode, FileNode):
243 raise VCSError("Given object should be FileNode object, not %s"
243 raise VCSError("Given object should be FileNode object, not %s"
244 % filenode.__class__)
244 % filenode.__class__)
245
245
246 repo = filenode_new.changeset.repository
246 repo = filenode_new.changeset.repository
247 old_raw_id = getattr(filenode_old.changeset, 'raw_id', repo.EMPTY_CHANGESET)
247 old_raw_id = getattr(filenode_old.changeset, 'raw_id', repo.EMPTY_CHANGESET)
248 new_raw_id = getattr(filenode_new.changeset, 'raw_id', repo.EMPTY_CHANGESET)
248 new_raw_id = getattr(filenode_new.changeset, 'raw_id', repo.EMPTY_CHANGESET)
249
249
250 vcs_gitdiff = get_diff(repo, old_raw_id, new_raw_id, filenode_new.path,
250 vcs_gitdiff = get_diff(repo, old_raw_id, new_raw_id, filenode_new.path,
251 ignore_whitespace, context)
251 ignore_whitespace, context)
252 return vcs_gitdiff
252 return vcs_gitdiff
253
253
254
254
255 def get_diff(scm_instance, rev1, rev2, path=None, ignore_whitespace=False, context=3):
255 def get_diff(scm_instance, rev1, rev2, path=None, ignore_whitespace=False, context=3):
256 """
256 """
257 A thin wrapper around vcs lib get_diff.
257 A thin wrapper around vcs lib get_diff.
258 """
258 """
259 try:
259 try:
260 return scm_instance.get_diff(rev1, rev2, path=path,
260 return scm_instance.get_diff(rev1, rev2, path=path,
261 ignore_whitespace=ignore_whitespace, context=context)
261 ignore_whitespace=ignore_whitespace, context=context)
262 except MemoryError:
262 except MemoryError:
263 h.flash('MemoryError: Diff is too big', category='error')
263 h.flash('MemoryError: Diff is too big', category='error')
264 return b''
264 return b''
265
265
266
266
267 NEW_FILENODE = 1
267 NEW_FILENODE = 1
268 DEL_FILENODE = 2
268 DEL_FILENODE = 2
269 MOD_FILENODE = 3
269 MOD_FILENODE = 3
270 RENAMED_FILENODE = 4
270 RENAMED_FILENODE = 4
271 COPIED_FILENODE = 5
271 COPIED_FILENODE = 5
272 CHMOD_FILENODE = 6
272 CHMOD_FILENODE = 6
273 BIN_FILENODE = 7
273 BIN_FILENODE = 7
274
274
275
275
276 class DiffProcessor(object):
276 class DiffProcessor(object):
277 """
277 """
278 Give it a unified or git diff and it returns a list of the files that were
278 Give it a unified or git diff and it returns a list of the files that were
279 mentioned in the diff together with a dict of meta information that
279 mentioned in the diff together with a dict of meta information that
280 can be used to render it in a HTML template.
280 can be used to render it in a HTML template.
281 """
281 """
282 _diff_git_re = re.compile(b'^diff --git', re.MULTILINE)
282 _diff_git_re = re.compile(b'^diff --git', re.MULTILINE)
283
283
284 def __init__(self, diff, vcs='hg', diff_limit=None, inline_diff=True):
284 def __init__(self, diff, vcs='hg', diff_limit=None, inline_diff=True):
285 """
285 """
286 :param diff: a text in diff format
286 :param diff: a text in diff format
287 :param vcs: type of version control hg or git
287 :param vcs: type of version control hg or git
288 :param diff_limit: define the size of diff that is considered "big"
288 :param diff_limit: define the size of diff that is considered "big"
289 based on that parameter cut off will be triggered, set to None
289 based on that parameter cut off will be triggered, set to None
290 to show full diff
290 to show full diff
291 """
291 """
292 if not isinstance(diff, bytes):
292 if not isinstance(diff, bytes):
293 raise Exception('Diff must be bytes - got %s' % type(diff))
293 raise Exception('Diff must be bytes - got %s' % type(diff))
294
294
295 self._diff = memoryview(diff)
295 self._diff = memoryview(diff)
296 self.adds = 0
296 self.adds = 0
297 self.removes = 0
297 self.removes = 0
298 self.diff_limit = diff_limit
298 self.diff_limit = diff_limit
299 self.limited_diff = False
299 self.limited_diff = False
300 self.vcs = vcs
300 self.vcs = vcs
301 self.parsed = self._parse_gitdiff(inline_diff=inline_diff)
301 self.parsed = self._parse_gitdiff(inline_diff=inline_diff)
302
302
303 def _parse_gitdiff(self, inline_diff):
303 def _parse_gitdiff(self, inline_diff):
304 """Parse self._diff and return a list of dicts with meta info and chunks for each file.
304 """Parse self._diff and return a list of dicts with meta info and chunks for each file.
305 Might set limited_diff.
305 Might set limited_diff.
306 Optionally, do an extra pass and to extra markup of one-liner changes.
306 Optionally, do an extra pass and to extra markup of one-liner changes.
307 """
307 """
308 _files = [] # list of dicts with meta info and chunks
308 _files = [] # list of dicts with meta info and chunks
309
309
310 starts = [m.start() for m in self._diff_git_re.finditer(self._diff)]
310 starts = [m.start() for m in self._diff_git_re.finditer(self._diff)]
311 starts.append(len(self._diff))
311 starts.append(len(self._diff))
312
312
313 for start, end in zip(starts, starts[1:]):
313 for start, end in zip(starts, starts[1:]):
314 if self.diff_limit and end > self.diff_limit:
314 if self.diff_limit and end > self.diff_limit:
315 self.limited_diff = True
315 self.limited_diff = True
316 continue
316 continue
317
317
318 head, diff_lines = _get_header(self.vcs, self._diff[start:end])
318 head, diff_lines = _get_header(self.vcs, self._diff[start:end])
319
319
320 op = None
320 op = None
321 stats = {
321 stats = {
322 'added': 0,
322 'added': 0,
323 'deleted': 0,
323 'deleted': 0,
324 'binary': False,
324 'binary': False,
325 'ops': {},
325 'ops': {},
326 }
326 }
327
327
328 if head['deleted_file_mode']:
328 if head['deleted_file_mode']:
329 op = 'removed'
329 op = 'removed'
330 stats['binary'] = True
330 stats['binary'] = True
331 stats['ops'][DEL_FILENODE] = 'deleted file'
331 stats['ops'][DEL_FILENODE] = 'deleted file'
332
332
333 elif head['new_file_mode']:
333 elif head['new_file_mode']:
334 op = 'added'
334 op = 'added'
335 stats['binary'] = True
335 stats['binary'] = True
336 stats['ops'][NEW_FILENODE] = 'new file %s' % head['new_file_mode']
336 stats['ops'][NEW_FILENODE] = 'new file %s' % head['new_file_mode']
337 else: # modify operation, can be cp, rename, chmod
337 else: # modify operation, can be cp, rename, chmod
338 # CHMOD
338 # CHMOD
339 if head['new_mode'] and head['old_mode']:
339 if head['new_mode'] and head['old_mode']:
340 op = 'modified'
340 op = 'modified'
341 stats['binary'] = True
341 stats['binary'] = True
342 stats['ops'][CHMOD_FILENODE] = ('modified file chmod %s => %s'
342 stats['ops'][CHMOD_FILENODE] = ('modified file chmod %s => %s'
343 % (head['old_mode'], head['new_mode']))
343 % (head['old_mode'], head['new_mode']))
344 # RENAME
344 # RENAME
345 if (head['rename_from'] and head['rename_to']
345 if (head['rename_from'] and head['rename_to']
346 and head['rename_from'] != head['rename_to']):
346 and head['rename_from'] != head['rename_to']):
347 op = 'renamed'
347 op = 'renamed'
348 stats['binary'] = True
348 stats['binary'] = True
349 stats['ops'][RENAMED_FILENODE] = ('file renamed from %s to %s'
349 stats['ops'][RENAMED_FILENODE] = ('file renamed from %s to %s'
350 % (head['rename_from'], head['rename_to']))
350 % (head['rename_from'], head['rename_to']))
351 # COPY
351 # COPY
352 if head.get('copy_from') and head.get('copy_to'):
352 if head.get('copy_from') and head.get('copy_to'):
353 op = 'modified'
353 op = 'modified'
354 stats['binary'] = True
354 stats['binary'] = True
355 stats['ops'][COPIED_FILENODE] = ('file copied from %s to %s'
355 stats['ops'][COPIED_FILENODE] = ('file copied from %s to %s'
356 % (head['copy_from'], head['copy_to']))
356 % (head['copy_from'], head['copy_to']))
357 # FALL BACK: detect missed old style add or remove
357 # FALL BACK: detect missed old style add or remove
358 if op is None:
358 if op is None:
359 if not head['a_file'] and head['b_file']:
359 if not head['a_file'] and head['b_file']:
360 op = 'added'
360 op = 'added'
361 stats['binary'] = True
361 stats['binary'] = True
362 stats['ops'][NEW_FILENODE] = 'new file'
362 stats['ops'][NEW_FILENODE] = 'new file'
363
363
364 elif head['a_file'] and not head['b_file']:
364 elif head['a_file'] and not head['b_file']:
365 op = 'removed'
365 op = 'removed'
366 stats['binary'] = True
366 stats['binary'] = True
367 stats['ops'][DEL_FILENODE] = 'deleted file'
367 stats['ops'][DEL_FILENODE] = 'deleted file'
368
368
369 # it's not ADD not DELETE
369 # it's not ADD not DELETE
370 if op is None:
370 if op is None:
371 op = 'modified'
371 op = 'modified'
372 stats['binary'] = True
372 stats['binary'] = True
373 stats['ops'][MOD_FILENODE] = 'modified file'
373 stats['ops'][MOD_FILENODE] = 'modified file'
374
374
375 # a real non-binary diff
375 # a real non-binary diff
376 if head['a_file'] or head['b_file']:
376 if head['a_file'] or head['b_file']:
377 chunks, added, deleted = _parse_lines(diff_lines)
377 chunks, added, deleted = _parse_lines(diff_lines)
378 stats['binary'] = False
378 stats['binary'] = False
379 stats['added'] = added
379 stats['added'] = added
380 stats['deleted'] = deleted
380 stats['deleted'] = deleted
381 # explicit mark that it's a modified file
381 # explicit mark that it's a modified file
382 if op == 'modified':
382 if op == 'modified':
383 stats['ops'][MOD_FILENODE] = 'modified file'
383 stats['ops'][MOD_FILENODE] = 'modified file'
384 else: # Git binary patch (or empty diff)
384 else: # Git binary patch (or empty diff)
385 # Git binary patch
385 # Git binary patch
386 if head['bin_patch']:
386 if head['bin_patch']:
387 stats['ops'][BIN_FILENODE] = 'binary diff not shown'
387 stats['ops'][BIN_FILENODE] = 'binary diff not shown'
388 chunks = []
388 chunks = []
389
389
390 if op == 'removed' and chunks:
390 if op == 'removed' and chunks:
391 # a way of seeing deleted content could perhaps be nice - but
391 # a way of seeing deleted content could perhaps be nice - but
392 # not with the current UI
392 # not with the current UI
393 chunks = []
393 chunks = []
394
394
395 chunks.insert(0, [{
395 chunks.insert(0, [{
396 'old_lineno': '',
396 'old_lineno': '',
397 'new_lineno': '',
397 'new_lineno': '',
398 'action': 'context',
398 'action': 'context',
399 'line': msg,
399 'line': msg,
400 } for _op, msg in stats['ops'].items()
400 } for _op, msg in stats['ops'].items()
401 if _op not in [MOD_FILENODE]])
401 if _op not in [MOD_FILENODE]])
402
402
403 _files.append({
403 _files.append({
404 'old_filename': head['a_path'],
404 'old_filename': head['a_path'],
405 'filename': head['b_path'],
405 'filename': head['b_path'],
406 'old_revision': head['a_blob_id'],
406 'old_revision': head['a_blob_id'],
407 'new_revision': head['b_blob_id'],
407 'new_revision': head['b_blob_id'],
408 'chunks': chunks,
408 'chunks': chunks,
409 'operation': op,
409 'operation': op,
410 'stats': stats,
410 'stats': stats,
411 })
411 })
412
412
413 if not inline_diff:
413 if not inline_diff:
414 return _files
414 return _files
415
415
416 # highlight inline changes when one del is followed by one add
416 # highlight inline changes when one del is followed by one add
417 for diff_data in _files:
417 for diff_data in _files:
418 for chunk in diff_data['chunks']:
418 for chunk in diff_data['chunks']:
419 lineiter = iter(chunk)
419 lineiter = iter(chunk)
420 try:
420 try:
421 peekline = next(lineiter)
421 peekline = next(lineiter)
422 while True:
422 while True:
423 # find a first del line
423 # find a first del line
424 while peekline['action'] != 'del':
424 while peekline['action'] != 'del':
425 peekline = next(lineiter)
425 peekline = next(lineiter)
426 delline = peekline
426 delline = peekline
427 peekline = next(lineiter)
427 peekline = next(lineiter)
428 # if not followed by add, eat all following del lines
428 # if not followed by add, eat all following del lines
429 if peekline['action'] != 'add':
429 if peekline['action'] != 'add':
430 while peekline['action'] == 'del':
430 while peekline['action'] == 'del':
431 peekline = next(lineiter)
431 peekline = next(lineiter)
432 continue
432 continue
433 # found an add - make sure it is the only one
433 # found an add - make sure it is the only one
434 addline = peekline
434 addline = peekline
435 try:
435 try:
436 peekline = next(lineiter)
436 peekline = next(lineiter)
437 except StopIteration:
437 except StopIteration:
438 # add was last line - ok
438 # add was last line - ok
439 _highlight_inline_diff(delline, addline)
439 _highlight_inline_diff(delline, addline)
440 raise
440 raise
441 if peekline['action'] != 'add':
441 if peekline['action'] != 'add':
442 # there was only one add line - ok
442 # there was only one add line - ok
443 _highlight_inline_diff(delline, addline)
443 _highlight_inline_diff(delline, addline)
444 except StopIteration:
444 except StopIteration:
445 pass
445 pass
446
446
447 return _files
447 return _files
448
448
449 def stat(self):
449 def stat(self):
450 """
450 """
451 Returns tuple of added, and removed lines for this instance
451 Returns tuple of added, and removed lines for this instance
452 """
452 """
453 return self.adds, self.removes
453 return self.adds, self.removes
454
454
455
455
456 _escape_re = re.compile(r'(&)|(<)|(>)|(\t)|(\r)|(?<=.)( \n| $)')
456 _escape_re = re.compile(r'(&)|(<)|(>)|(\t)|(\r)|(?<=.)( \n| $)')
457
457
458
458
459 def _escaper(string):
459 def _escaper(string):
460 """
460 """
461 Do HTML escaping/markup
461 Do HTML escaping/markup
462 """
462 """
463
463
464 def substitute(m):
464 def substitute(m):
465 groups = m.groups()
465 groups = m.groups()
466 if groups[0]:
466 if groups[0]:
467 return '&amp;'
467 return '&amp;'
468 if groups[1]:
468 if groups[1]:
469 return '&lt;'
469 return '&lt;'
470 if groups[2]:
470 if groups[2]:
471 return '&gt;'
471 return '&gt;'
472 if groups[3]:
472 if groups[3]:
473 return '<u>\t</u>'
473 return '<u>\t</u>'
474 if groups[4]:
474 if groups[4]:
475 return '<u class="cr"></u>'
475 return '<u class="cr"></u>'
476 if groups[5]:
476 if groups[5]:
477 return ' <i></i>'
477 return ' <i></i>'
478 assert False
478 assert False
479
479
480 return _escape_re.sub(substitute, safe_str(string))
480 return _escape_re.sub(substitute, safe_str(string))
481
481
482
482
483 _git_header_re = re.compile(br"""
483 _git_header_re = re.compile(br"""
484 ^diff[ ]--git[ ]a/(?P<a_path>.+?)[ ]b/(?P<b_path>.+?)\n
484 ^diff[ ]--git[ ]a/(?P<a_path>.+?)[ ]b/(?P<b_path>.+?)\n
485 (?:^old[ ]mode[ ](?P<old_mode>\d+)\n
485 (?:^old[ ]mode[ ](?P<old_mode>\d+)\n
486 ^new[ ]mode[ ](?P<new_mode>\d+)(?:\n|$))?
486 ^new[ ]mode[ ](?P<new_mode>\d+)(?:\n|$))?
487 (?:^similarity[ ]index[ ](?P<similarity_index>\d+)%\n
487 (?:^similarity[ ]index[ ](?P<similarity_index>\d+)%\n
488 ^rename[ ]from[ ](?P<rename_from>.+)\n
488 ^rename[ ]from[ ](?P<rename_from>.+)\n
489 ^rename[ ]to[ ](?P<rename_to>.+)(?:\n|$))?
489 ^rename[ ]to[ ](?P<rename_to>.+)(?:\n|$))?
490 (?:^new[ ]file[ ]mode[ ](?P<new_file_mode>.+)(?:\n|$))?
490 (?:^new[ ]file[ ]mode[ ](?P<new_file_mode>.+)(?:\n|$))?
491 (?:^deleted[ ]file[ ]mode[ ](?P<deleted_file_mode>.+)(?:\n|$))?
491 (?:^deleted[ ]file[ ]mode[ ](?P<deleted_file_mode>.+)(?:\n|$))?
492 (?:^index[ ](?P<a_blob_id>[0-9A-Fa-f]+)
492 (?:^index[ ](?P<a_blob_id>[0-9A-Fa-f]+)
493 \.\.(?P<b_blob_id>[0-9A-Fa-f]+)[ ]?(?P<b_mode>.+)?(?:\n|$))?
493 \.\.(?P<b_blob_id>[0-9A-Fa-f]+)[ ]?(?P<b_mode>.+)?(?:\n|$))?
494 (?:^(?P<bin_patch>GIT[ ]binary[ ]patch)(?:\n|$))?
494 (?:^(?P<bin_patch>GIT[ ]binary[ ]patch)(?:\n|$))?
495 (?:^---[ ](a/(?P<a_file>.+?)|/dev/null)\t?(?:\n|$))?
495 (?:^---[ ](a/(?P<a_file>.+?)|/dev/null)\t?(?:\n|$))?
496 (?:^\+\+\+[ ](b/(?P<b_file>.+?)|/dev/null)\t?(?:\n|$))?
496 (?:^\+\+\+[ ](b/(?P<b_file>.+?)|/dev/null)\t?(?:\n|$))?
497 """, re.VERBOSE | re.MULTILINE)
497 """, re.VERBOSE | re.MULTILINE)
498
498
499
499
500 _hg_header_re = re.compile(br"""
500 _hg_header_re = re.compile(br"""
501 ^diff[ ]--git[ ]a/(?P<a_path>.+?)[ ]b/(?P<b_path>.+?)\n
501 ^diff[ ]--git[ ]a/(?P<a_path>.+?)[ ]b/(?P<b_path>.+?)\n
502 (?:^old[ ]mode[ ](?P<old_mode>\d+)\n
502 (?:^old[ ]mode[ ](?P<old_mode>\d+)\n
503 ^new[ ]mode[ ](?P<new_mode>\d+)(?:\n|$))?
503 ^new[ ]mode[ ](?P<new_mode>\d+)(?:\n|$))?
504 (?:^similarity[ ]index[ ](?P<similarity_index>\d+)%(?:\n|$))?
504 (?:^similarity[ ]index[ ](?P<similarity_index>\d+)%(?:\n|$))?
505 (?:^rename[ ]from[ ](?P<rename_from>.+)\n
505 (?:^rename[ ]from[ ](?P<rename_from>.+)\n
506 ^rename[ ]to[ ](?P<rename_to>.+)(?:\n|$))?
506 ^rename[ ]to[ ](?P<rename_to>.+)(?:\n|$))?
507 (?:^copy[ ]from[ ](?P<copy_from>.+)\n
507 (?:^copy[ ]from[ ](?P<copy_from>.+)\n
508 ^copy[ ]to[ ](?P<copy_to>.+)(?:\n|$))?
508 ^copy[ ]to[ ](?P<copy_to>.+)(?:\n|$))?
509 (?:^new[ ]file[ ]mode[ ](?P<new_file_mode>.+)(?:\n|$))?
509 (?:^new[ ]file[ ]mode[ ](?P<new_file_mode>.+)(?:\n|$))?
510 (?:^deleted[ ]file[ ]mode[ ](?P<deleted_file_mode>.+)(?:\n|$))?
510 (?:^deleted[ ]file[ ]mode[ ](?P<deleted_file_mode>.+)(?:\n|$))?
511 (?:^index[ ](?P<a_blob_id>[0-9A-Fa-f]+)
511 (?:^index[ ](?P<a_blob_id>[0-9A-Fa-f]+)
512 \.\.(?P<b_blob_id>[0-9A-Fa-f]+)[ ]?(?P<b_mode>.+)?(?:\n|$))?
512 \.\.(?P<b_blob_id>[0-9A-Fa-f]+)[ ]?(?P<b_mode>.+)?(?:\n|$))?
513 (?:^(?P<bin_patch>GIT[ ]binary[ ]patch)(?:\n|$))?
513 (?:^(?P<bin_patch>GIT[ ]binary[ ]patch)(?:\n|$))?
514 (?:^---[ ](a/(?P<a_file>.+?)|/dev/null)\t?(?:\n|$))?
514 (?:^---[ ](a/(?P<a_file>.+?)|/dev/null)\t?(?:\n|$))?
515 (?:^\+\+\+[ ](b/(?P<b_file>.+?)|/dev/null)\t?(?:\n|$))?
515 (?:^\+\+\+[ ](b/(?P<b_file>.+?)|/dev/null)\t?(?:\n|$))?
516 """, re.VERBOSE | re.MULTILINE)
516 """, re.VERBOSE | re.MULTILINE)
517
517
518
518
519 _header_next_check = re.compile(br'''(?!@)(?!literal )(?!delta )''')
519 _header_next_check = re.compile(br'''(?!@)(?!literal )(?!delta )''')
520
520
521
521
522 def _get_header(vcs, diff_chunk):
522 def _get_header(vcs, diff_chunk):
523 """
523 """
524 Parses a Git diff for a single file (header and chunks) and returns a tuple with:
524 Parses a Git diff for a single file (header and chunks) and returns a tuple with:
525
525
526 1. A dict with meta info:
526 1. A dict with meta info:
527
527
528 a_path, b_path, similarity_index, rename_from, rename_to,
528 a_path, b_path, similarity_index, rename_from, rename_to,
529 old_mode, new_mode, new_file_mode, deleted_file_mode,
529 old_mode, new_mode, new_file_mode, deleted_file_mode,
530 a_blob_id, b_blob_id, b_mode, a_file, b_file
530 a_blob_id, b_blob_id, b_mode, a_file, b_file
531
531
532 2. An iterator yielding lines with simple HTML markup.
532 2. An iterator yielding lines with simple HTML markup.
533 """
533 """
534 match = None
534 match = None
535 if vcs == 'git':
535 if vcs == 'git':
536 match = _git_header_re.match(diff_chunk)
536 match = _git_header_re.match(diff_chunk)
537 elif vcs == 'hg':
537 elif vcs == 'hg':
538 match = _hg_header_re.match(diff_chunk)
538 match = _hg_header_re.match(diff_chunk)
539 if match is None:
539 if match is None:
540 raise Exception('diff not recognized as valid %s diff' % vcs)
540 raise Exception('diff not recognized as valid %s diff' % vcs)
541 meta_info = match.groupdict()
541 meta_info = {k: None if v is None else safe_str(v) for k, v in match.groupdict().items()}
542 rest = diff_chunk[match.end():]
542 rest = diff_chunk[match.end():]
543 if rest and _header_next_check.match(rest):
543 if rest and _header_next_check.match(rest):
544 raise Exception('cannot parse %s diff header: %r followed by %r' % (vcs, diff_chunk[:match.end()], rest[:1000]))
544 raise Exception('cannot parse %s diff header: %r followed by %r' % (vcs, safe_str(bytes(diff_chunk[:match.end()])), safe_str(bytes(rest[:1000]))))
545 diff_lines = (_escaper(m.group(0)) for m in re.finditer(br'.*\n|.+$', rest)) # don't split on \r as str.splitlines do
545 diff_lines = (_escaper(m.group(0)) for m in re.finditer(br'.*\n|.+$', rest)) # don't split on \r as str.splitlines do
546 return meta_info, diff_lines
546 return meta_info, diff_lines
547
547
548
548
549 _chunk_re = re.compile(r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)')
549 _chunk_re = re.compile(r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)')
550 _newline_marker = re.compile(r'^\\ No newline at end of file')
550 _newline_marker = re.compile(r'^\\ No newline at end of file')
551
551
552
552
553 def _parse_lines(diff_lines):
553 def _parse_lines(diff_lines):
554 """
554 """
555 Given an iterator of diff body lines, parse them and return a dict per
555 Given an iterator of diff body lines, parse them and return a dict per
556 line and added/removed totals.
556 line and added/removed totals.
557 """
557 """
558 added = deleted = 0
558 added = deleted = 0
559 old_line = old_end = new_line = new_end = None
559 old_line = old_end = new_line = new_end = None
560
560
561 chunks = []
561 chunks = []
562 try:
562 try:
563 line = next(diff_lines)
563 line = next(diff_lines)
564
564
565 while True:
565 while True:
566 lines = []
566 lines = []
567 chunks.append(lines)
567 chunks.append(lines)
568
568
569 match = _chunk_re.match(line)
569 match = _chunk_re.match(line)
570
570
571 if not match:
571 if not match:
572 raise Exception('error parsing diff @@ line %r' % line)
572 raise Exception('error parsing diff @@ line %r' % line)
573
573
574 gr = match.groups()
574 gr = match.groups()
575 (old_line, old_end,
575 (old_line, old_end,
576 new_line, new_end) = [int(x or 1) for x in gr[:-1]]
576 new_line, new_end) = [int(x or 1) for x in gr[:-1]]
577 old_line -= 1
577 old_line -= 1
578 new_line -= 1
578 new_line -= 1
579
579
580 context = len(gr) == 5
580 context = len(gr) == 5
581 old_end += old_line
581 old_end += old_line
582 new_end += new_line
582 new_end += new_line
583
583
584 if context:
584 if context:
585 # skip context only if it's first line
585 # skip context only if it's first line
586 if int(gr[0]) > 1:
586 if int(gr[0]) > 1:
587 lines.append({
587 lines.append({
588 'old_lineno': '...',
588 'old_lineno': '...',
589 'new_lineno': '...',
589 'new_lineno': '...',
590 'action': 'context',
590 'action': 'context',
591 'line': line,
591 'line': line,
592 })
592 })
593
593
594 line = next(diff_lines)
594 line = next(diff_lines)
595
595
596 while old_line < old_end or new_line < new_end:
596 while old_line < old_end or new_line < new_end:
597 if not line:
597 if not line:
598 raise Exception('error parsing diff - empty line at -%s+%s' % (old_line, new_line))
598 raise Exception('error parsing diff - empty line at -%s+%s' % (old_line, new_line))
599
599
600 affects_old = affects_new = False
600 affects_old = affects_new = False
601
601
602 command = line[0]
602 command = line[0]
603 if command == '+':
603 if command == '+':
604 affects_new = True
604 affects_new = True
605 action = 'add'
605 action = 'add'
606 added += 1
606 added += 1
607 elif command == '-':
607 elif command == '-':
608 affects_old = True
608 affects_old = True
609 action = 'del'
609 action = 'del'
610 deleted += 1
610 deleted += 1
611 elif command == ' ':
611 elif command == ' ':
612 affects_old = affects_new = True
612 affects_old = affects_new = True
613 action = 'unmod'
613 action = 'unmod'
614 else:
614 else:
615 raise Exception('error parsing diff - unknown command in line %r at -%s+%s' % (line, old_line, new_line))
615 raise Exception('error parsing diff - unknown command in line %r at -%s+%s' % (line, old_line, new_line))
616
616
617 if not _newline_marker.match(line):
617 if not _newline_marker.match(line):
618 old_line += affects_old
618 old_line += affects_old
619 new_line += affects_new
619 new_line += affects_new
620 lines.append({
620 lines.append({
621 'old_lineno': affects_old and old_line or '',
621 'old_lineno': affects_old and old_line or '',
622 'new_lineno': affects_new and new_line or '',
622 'new_lineno': affects_new and new_line or '',
623 'action': action,
623 'action': action,
624 'line': line[1:],
624 'line': line[1:],
625 })
625 })
626
626
627 line = next(diff_lines)
627 line = next(diff_lines)
628
628
629 if _newline_marker.match(line):
629 if _newline_marker.match(line):
630 # we need to append to lines, since this is not
630 # we need to append to lines, since this is not
631 # counted in the line specs of diff
631 # counted in the line specs of diff
632 lines.append({
632 lines.append({
633 'old_lineno': '...',
633 'old_lineno': '...',
634 'new_lineno': '...',
634 'new_lineno': '...',
635 'action': 'context',
635 'action': 'context',
636 'line': line,
636 'line': line,
637 })
637 })
638 line = next(diff_lines)
638 line = next(diff_lines)
639 if old_line > old_end:
639 if old_line > old_end:
640 raise Exception('error parsing diff - more than %s "-" lines at -%s+%s' % (old_end, old_line, new_line))
640 raise Exception('error parsing diff - more than %s "-" lines at -%s+%s' % (old_end, old_line, new_line))
641 if new_line > new_end:
641 if new_line > new_end:
642 raise Exception('error parsing diff - more than %s "+" lines at -%s+%s' % (new_end, old_line, new_line))
642 raise Exception('error parsing diff - more than %s "+" lines at -%s+%s' % (new_end, old_line, new_line))
643 except StopIteration:
643 except StopIteration:
644 pass
644 pass
645 if old_line != old_end or new_line != new_end:
645 if old_line != old_end or new_line != new_end:
646 raise Exception('diff processing broken when old %s<>%s or new %s<>%s line %r' % (old_line, old_end, new_line, new_end, line))
646 raise Exception('diff processing broken when old %s<>%s or new %s<>%s line %r' % (old_line, old_end, new_line, new_end, line))
647
647
648 return chunks, added, deleted
648 return chunks, added, deleted
649
649
650 # Used for inline highlighter word split, must match the substitutions in _escaper
650 # Used for inline highlighter word split, must match the substitutions in _escaper
651 _token_re = re.compile(r'()(&amp;|&lt;|&gt;|<u>\t</u>|<u class="cr"></u>| <i></i>|\W+?)')
651 _token_re = re.compile(r'()(&amp;|&lt;|&gt;|<u>\t</u>|<u class="cr"></u>| <i></i>|\W+?)')
652
652
653
653
654 def _highlight_inline_diff(old, new):
654 def _highlight_inline_diff(old, new):
655 """
655 """
656 Highlight simple add/remove in two lines given as info dicts. They are
656 Highlight simple add/remove in two lines given as info dicts. They are
657 modified in place and given markup with <del>/<ins>.
657 modified in place and given markup with <del>/<ins>.
658 """
658 """
659 assert old['action'] == 'del'
659 assert old['action'] == 'del'
660 assert new['action'] == 'add'
660 assert new['action'] == 'add'
661
661
662 oldwords = _token_re.split(old['line'])
662 oldwords = _token_re.split(old['line'])
663 newwords = _token_re.split(new['line'])
663 newwords = _token_re.split(new['line'])
664 sequence = difflib.SequenceMatcher(None, oldwords, newwords)
664 sequence = difflib.SequenceMatcher(None, oldwords, newwords)
665
665
666 oldfragments, newfragments = [], []
666 oldfragments, newfragments = [], []
667 for tag, i1, i2, j1, j2 in sequence.get_opcodes():
667 for tag, i1, i2, j1, j2 in sequence.get_opcodes():
668 oldfrag = ''.join(oldwords[i1:i2])
668 oldfrag = ''.join(oldwords[i1:i2])
669 newfrag = ''.join(newwords[j1:j2])
669 newfrag = ''.join(newwords[j1:j2])
670 if tag != 'equal':
670 if tag != 'equal':
671 if oldfrag:
671 if oldfrag:
672 oldfrag = '<del>%s</del>' % oldfrag
672 oldfrag = '<del>%s</del>' % oldfrag
673 if newfrag:
673 if newfrag:
674 newfrag = '<ins>%s</ins>' % newfrag
674 newfrag = '<ins>%s</ins>' % newfrag
675 oldfragments.append(oldfrag)
675 oldfragments.append(oldfrag)
676 newfragments.append(newfrag)
676 newfragments.append(newfrag)
677
677
678 old['line'] = "".join(oldfragments)
678 old['line'] = "".join(oldfragments)
679 new['line'] = "".join(newfragments)
679 new['line'] = "".join(newfragments)
@@ -1,400 +1,400 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 # This program is free software: you can redistribute it and/or modify
2 # This program is free software: you can redistribute it and/or modify
3 # it under the terms of the GNU General Public License as published by
3 # it under the terms of the GNU General Public License as published by
4 # the Free Software Foundation, either version 3 of the License, or
4 # the Free Software Foundation, either version 3 of the License, or
5 # (at your option) any later version.
5 # (at your option) any later version.
6 #
6 #
7 # This program is distributed in the hope that it will be useful,
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
10 # GNU General Public License for more details.
11 #
11 #
12 # You should have received a copy of the GNU General Public License
12 # You should have received a copy of the GNU General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 """
14 """
15 kallithea.lib.hooks
15 kallithea.lib.hooks
16 ~~~~~~~~~~~~~~~~~~~
16 ~~~~~~~~~~~~~~~~~~~
17
17
18 Hooks run by Kallithea
18 Hooks run by Kallithea
19
19
20 This file was forked by the Kallithea project in July 2014.
20 This file was forked by the Kallithea project in July 2014.
21 Original author and date, and relevant copyright and licensing information is below:
21 Original author and date, and relevant copyright and licensing information is below:
22 :created_on: Aug 6, 2010
22 :created_on: Aug 6, 2010
23 :author: marcink
23 :author: marcink
24 :copyright: (c) 2013 RhodeCode GmbH, and others.
24 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 :license: GPLv3, see LICENSE.md for more details.
25 :license: GPLv3, see LICENSE.md for more details.
26 """
26 """
27
27
28 import os
28 import os
29 import sys
29 import sys
30 import time
30 import time
31
31
32 import mercurial.scmutil
32 import mercurial.scmutil
33
33
34 from kallithea.lib import helpers as h
34 from kallithea.lib import helpers as h
35 from kallithea.lib.exceptions import UserCreationError
35 from kallithea.lib.exceptions import UserCreationError
36 from kallithea.lib.utils import action_logger, make_ui
36 from kallithea.lib.utils import action_logger, make_ui
37 from kallithea.lib.utils2 import HookEnvironmentError, ascii_str, get_hook_environment, safe_bytes
37 from kallithea.lib.utils2 import HookEnvironmentError, ascii_str, get_hook_environment, safe_bytes, safe_str
38 from kallithea.lib.vcs.backends.base import EmptyChangeset
38 from kallithea.lib.vcs.backends.base import EmptyChangeset
39 from kallithea.model.db import Repository, User
39 from kallithea.model.db import Repository, User
40
40
41
41
42 def _get_scm_size(alias, root_path):
42 def _get_scm_size(alias, root_path):
43 if not alias.startswith('.'):
43 if not alias.startswith('.'):
44 alias += '.'
44 alias += '.'
45
45
46 size_scm, size_root = 0, 0
46 size_scm, size_root = 0, 0
47 for path, dirs, files in os.walk(root_path):
47 for path, dirs, files in os.walk(root_path):
48 if path.find(alias) != -1:
48 if path.find(alias) != -1:
49 for f in files:
49 for f in files:
50 try:
50 try:
51 size_scm += os.path.getsize(os.path.join(path, f))
51 size_scm += os.path.getsize(os.path.join(path, f))
52 except OSError:
52 except OSError:
53 pass
53 pass
54 else:
54 else:
55 for f in files:
55 for f in files:
56 try:
56 try:
57 size_root += os.path.getsize(os.path.join(path, f))
57 size_root += os.path.getsize(os.path.join(path, f))
58 except OSError:
58 except OSError:
59 pass
59 pass
60
60
61 size_scm_f = h.format_byte_size(size_scm)
61 size_scm_f = h.format_byte_size(size_scm)
62 size_root_f = h.format_byte_size(size_root)
62 size_root_f = h.format_byte_size(size_root)
63 size_total_f = h.format_byte_size(size_root + size_scm)
63 size_total_f = h.format_byte_size(size_root + size_scm)
64
64
65 return size_scm_f, size_root_f, size_total_f
65 return size_scm_f, size_root_f, size_total_f
66
66
67
67
68 def repo_size(ui, repo, hooktype=None, **kwargs):
68 def repo_size(ui, repo, hooktype=None, **kwargs):
69 """Show size of Mercurial repository, to be called after push."""
69 """Show size of Mercurial repository, to be called after push."""
70 size_hg_f, size_root_f, size_total_f = _get_scm_size('.hg', repo.root)
70 size_hg_f, size_root_f, size_total_f = _get_scm_size('.hg', safe_str(repo.root))
71
71
72 last_cs = repo[len(repo) - 1]
72 last_cs = repo[len(repo) - 1]
73
73
74 msg = ('Repository size .hg: %s Checkout: %s Total: %s\n'
74 msg = ('Repository size .hg: %s Checkout: %s Total: %s\n'
75 'Last revision is now r%s:%s\n') % (
75 'Last revision is now r%s:%s\n') % (
76 size_hg_f, size_root_f, size_total_f, last_cs.rev(), ascii_str(last_cs.hex())[:12]
76 size_hg_f, size_root_f, size_total_f, last_cs.rev(), ascii_str(last_cs.hex())[:12]
77 )
77 )
78 ui.status(safe_bytes(msg))
78 ui.status(safe_bytes(msg))
79
79
80
80
81 def log_pull_action(ui, repo, **kwargs):
81 def log_pull_action(ui, repo, **kwargs):
82 """Logs user last pull action
82 """Logs user last pull action
83
83
84 Called as Mercurial hook outgoing.pull_logger or from Kallithea before invoking Git.
84 Called as Mercurial hook outgoing.pull_logger or from Kallithea before invoking Git.
85
85
86 Does *not* use the action from the hook environment but is always 'pull'.
86 Does *not* use the action from the hook environment but is always 'pull'.
87 """
87 """
88 ex = get_hook_environment()
88 ex = get_hook_environment()
89
89
90 user = User.get_by_username(ex.username)
90 user = User.get_by_username(ex.username)
91 action = 'pull'
91 action = 'pull'
92 action_logger(user, action, ex.repository, ex.ip, commit=True)
92 action_logger(user, action, ex.repository, ex.ip, commit=True)
93 # extension hook call
93 # extension hook call
94 from kallithea import EXTENSIONS
94 from kallithea import EXTENSIONS
95 callback = getattr(EXTENSIONS, 'PULL_HOOK', None)
95 callback = getattr(EXTENSIONS, 'PULL_HOOK', None)
96 if callable(callback):
96 if callable(callback):
97 kw = {}
97 kw = {}
98 kw.update(ex)
98 kw.update(ex)
99 callback(**kw)
99 callback(**kw)
100
100
101 return 0
101 return 0
102
102
103
103
104 def log_push_action(ui, repo, node, node_last, **kwargs):
104 def log_push_action(ui, repo, node, node_last, **kwargs):
105 """
105 """
106 Entry point for Mercurial hook changegroup.push_logger.
106 Entry point for Mercurial hook changegroup.push_logger.
107
107
108 The pushed changesets is given by the revset 'node:node_last'.
108 The pushed changesets is given by the revset 'node:node_last'.
109
109
110 Note: This hook is not only logging, but also the side effect invalidating
110 Note: This hook is not only logging, but also the side effect invalidating
111 cahes! The function should perhaps be renamed.
111 cahes! The function should perhaps be renamed.
112 """
112 """
113 revs = [ascii_str(repo[r].hex()) for r in mercurial.scmutil.revrange(repo, [b'%s:%s' % (node, node_last)])]
113 revs = [ascii_str(repo[r].hex()) for r in mercurial.scmutil.revrange(repo, [b'%s:%s' % (node, node_last)])]
114 process_pushed_raw_ids(revs)
114 process_pushed_raw_ids(revs)
115 return 0
115 return 0
116
116
117
117
118 def process_pushed_raw_ids(revs):
118 def process_pushed_raw_ids(revs):
119 """
119 """
120 Register that changes have been added to the repo - log the action *and* invalidate caches.
120 Register that changes have been added to the repo - log the action *and* invalidate caches.
121
121
122 Called from Mercurial changegroup.push_logger calling hook log_push_action,
122 Called from Mercurial changegroup.push_logger calling hook log_push_action,
123 or from the Git post-receive hook calling handle_git_post_receive ...
123 or from the Git post-receive hook calling handle_git_post_receive ...
124 or from scm _handle_push.
124 or from scm _handle_push.
125 """
125 """
126 ex = get_hook_environment()
126 ex = get_hook_environment()
127
127
128 action = '%s:%s' % (ex.action, ','.join(revs))
128 action = '%s:%s' % (ex.action, ','.join(revs))
129 action_logger(ex.username, action, ex.repository, ex.ip, commit=True)
129 action_logger(ex.username, action, ex.repository, ex.ip, commit=True)
130
130
131 from kallithea.model.scm import ScmModel
131 from kallithea.model.scm import ScmModel
132 ScmModel().mark_for_invalidation(ex.repository)
132 ScmModel().mark_for_invalidation(ex.repository)
133
133
134 # extension hook call
134 # extension hook call
135 from kallithea import EXTENSIONS
135 from kallithea import EXTENSIONS
136 callback = getattr(EXTENSIONS, 'PUSH_HOOK', None)
136 callback = getattr(EXTENSIONS, 'PUSH_HOOK', None)
137 if callable(callback):
137 if callable(callback):
138 kw = {'pushed_revs': revs}
138 kw = {'pushed_revs': revs}
139 kw.update(ex)
139 kw.update(ex)
140 callback(**kw)
140 callback(**kw)
141
141
142
142
143 def log_create_repository(repository_dict, created_by, **kwargs):
143 def log_create_repository(repository_dict, created_by, **kwargs):
144 """
144 """
145 Post create repository Hook.
145 Post create repository Hook.
146
146
147 :param repository: dict dump of repository object
147 :param repository: dict dump of repository object
148 :param created_by: username who created repository
148 :param created_by: username who created repository
149
149
150 available keys of repository_dict:
150 available keys of repository_dict:
151
151
152 'repo_type',
152 'repo_type',
153 'description',
153 'description',
154 'private',
154 'private',
155 'created_on',
155 'created_on',
156 'enable_downloads',
156 'enable_downloads',
157 'repo_id',
157 'repo_id',
158 'owner_id',
158 'owner_id',
159 'enable_statistics',
159 'enable_statistics',
160 'clone_uri',
160 'clone_uri',
161 'fork_id',
161 'fork_id',
162 'group_id',
162 'group_id',
163 'repo_name'
163 'repo_name'
164
164
165 """
165 """
166 from kallithea import EXTENSIONS
166 from kallithea import EXTENSIONS
167 callback = getattr(EXTENSIONS, 'CREATE_REPO_HOOK', None)
167 callback = getattr(EXTENSIONS, 'CREATE_REPO_HOOK', None)
168 if callable(callback):
168 if callable(callback):
169 kw = {}
169 kw = {}
170 kw.update(repository_dict)
170 kw.update(repository_dict)
171 kw.update({'created_by': created_by})
171 kw.update({'created_by': created_by})
172 kw.update(kwargs)
172 kw.update(kwargs)
173 return callback(**kw)
173 return callback(**kw)
174
174
175 return 0
175 return 0
176
176
177
177
178 def check_allowed_create_user(user_dict, created_by, **kwargs):
178 def check_allowed_create_user(user_dict, created_by, **kwargs):
179 # pre create hooks
179 # pre create hooks
180 from kallithea import EXTENSIONS
180 from kallithea import EXTENSIONS
181 callback = getattr(EXTENSIONS, 'PRE_CREATE_USER_HOOK', None)
181 callback = getattr(EXTENSIONS, 'PRE_CREATE_USER_HOOK', None)
182 if callable(callback):
182 if callable(callback):
183 allowed, reason = callback(created_by=created_by, **user_dict)
183 allowed, reason = callback(created_by=created_by, **user_dict)
184 if not allowed:
184 if not allowed:
185 raise UserCreationError(reason)
185 raise UserCreationError(reason)
186
186
187
187
188 def log_create_user(user_dict, created_by, **kwargs):
188 def log_create_user(user_dict, created_by, **kwargs):
189 """
189 """
190 Post create user Hook.
190 Post create user Hook.
191
191
192 :param user_dict: dict dump of user object
192 :param user_dict: dict dump of user object
193
193
194 available keys for user_dict:
194 available keys for user_dict:
195
195
196 'username',
196 'username',
197 'full_name_or_username',
197 'full_name_or_username',
198 'full_contact',
198 'full_contact',
199 'user_id',
199 'user_id',
200 'name',
200 'name',
201 'firstname',
201 'firstname',
202 'short_contact',
202 'short_contact',
203 'admin',
203 'admin',
204 'lastname',
204 'lastname',
205 'ip_addresses',
205 'ip_addresses',
206 'ldap_dn',
206 'ldap_dn',
207 'email',
207 'email',
208 'api_key',
208 'api_key',
209 'last_login',
209 'last_login',
210 'full_name',
210 'full_name',
211 'active',
211 'active',
212 'password',
212 'password',
213 'emails',
213 'emails',
214
214
215 """
215 """
216 from kallithea import EXTENSIONS
216 from kallithea import EXTENSIONS
217 callback = getattr(EXTENSIONS, 'CREATE_USER_HOOK', None)
217 callback = getattr(EXTENSIONS, 'CREATE_USER_HOOK', None)
218 if callable(callback):
218 if callable(callback):
219 return callback(created_by=created_by, **user_dict)
219 return callback(created_by=created_by, **user_dict)
220
220
221 return 0
221 return 0
222
222
223
223
224 def log_delete_repository(repository_dict, deleted_by, **kwargs):
224 def log_delete_repository(repository_dict, deleted_by, **kwargs):
225 """
225 """
226 Post delete repository Hook.
226 Post delete repository Hook.
227
227
228 :param repository: dict dump of repository object
228 :param repository: dict dump of repository object
229 :param deleted_by: username who deleted the repository
229 :param deleted_by: username who deleted the repository
230
230
231 available keys of repository_dict:
231 available keys of repository_dict:
232
232
233 'repo_type',
233 'repo_type',
234 'description',
234 'description',
235 'private',
235 'private',
236 'created_on',
236 'created_on',
237 'enable_downloads',
237 'enable_downloads',
238 'repo_id',
238 'repo_id',
239 'owner_id',
239 'owner_id',
240 'enable_statistics',
240 'enable_statistics',
241 'clone_uri',
241 'clone_uri',
242 'fork_id',
242 'fork_id',
243 'group_id',
243 'group_id',
244 'repo_name'
244 'repo_name'
245
245
246 """
246 """
247 from kallithea import EXTENSIONS
247 from kallithea import EXTENSIONS
248 callback = getattr(EXTENSIONS, 'DELETE_REPO_HOOK', None)
248 callback = getattr(EXTENSIONS, 'DELETE_REPO_HOOK', None)
249 if callable(callback):
249 if callable(callback):
250 kw = {}
250 kw = {}
251 kw.update(repository_dict)
251 kw.update(repository_dict)
252 kw.update({'deleted_by': deleted_by,
252 kw.update({'deleted_by': deleted_by,
253 'deleted_on': time.time()})
253 'deleted_on': time.time()})
254 kw.update(kwargs)
254 kw.update(kwargs)
255 return callback(**kw)
255 return callback(**kw)
256
256
257 return 0
257 return 0
258
258
259
259
260 def log_delete_user(user_dict, deleted_by, **kwargs):
260 def log_delete_user(user_dict, deleted_by, **kwargs):
261 """
261 """
262 Post delete user Hook.
262 Post delete user Hook.
263
263
264 :param user_dict: dict dump of user object
264 :param user_dict: dict dump of user object
265
265
266 available keys for user_dict:
266 available keys for user_dict:
267
267
268 'username',
268 'username',
269 'full_name_or_username',
269 'full_name_or_username',
270 'full_contact',
270 'full_contact',
271 'user_id',
271 'user_id',
272 'name',
272 'name',
273 'firstname',
273 'firstname',
274 'short_contact',
274 'short_contact',
275 'admin',
275 'admin',
276 'lastname',
276 'lastname',
277 'ip_addresses',
277 'ip_addresses',
278 'ldap_dn',
278 'ldap_dn',
279 'email',
279 'email',
280 'api_key',
280 'api_key',
281 'last_login',
281 'last_login',
282 'full_name',
282 'full_name',
283 'active',
283 'active',
284 'password',
284 'password',
285 'emails',
285 'emails',
286
286
287 """
287 """
288 from kallithea import EXTENSIONS
288 from kallithea import EXTENSIONS
289 callback = getattr(EXTENSIONS, 'DELETE_USER_HOOK', None)
289 callback = getattr(EXTENSIONS, 'DELETE_USER_HOOK', None)
290 if callable(callback):
290 if callable(callback):
291 return callback(deleted_by=deleted_by, **user_dict)
291 return callback(deleted_by=deleted_by, **user_dict)
292
292
293 return 0
293 return 0
294
294
295
295
296 def _hook_environment(repo_path):
296 def _hook_environment(repo_path):
297 """
297 """
298 Create a light-weight environment for stand-alone scripts and return an UI and the
298 Create a light-weight environment for stand-alone scripts and return an UI and the
299 db repository.
299 db repository.
300
300
301 Git hooks are executed as subprocess of Git while Kallithea is waiting, and
301 Git hooks are executed as subprocess of Git while Kallithea is waiting, and
302 they thus need enough info to be able to create an app environment and
302 they thus need enough info to be able to create an app environment and
303 connect to the database.
303 connect to the database.
304 """
304 """
305 import paste.deploy
305 import paste.deploy
306 import kallithea.config.middleware
306 import kallithea.config.middleware
307
307
308 extras = get_hook_environment()
308 extras = get_hook_environment()
309
309
310 path_to_ini_file = extras['config']
310 path_to_ini_file = extras['config']
311 kallithea.CONFIG = paste.deploy.appconfig('config:' + path_to_ini_file)
311 kallithea.CONFIG = paste.deploy.appconfig('config:' + path_to_ini_file)
312 #logging.config.fileConfig(ini_file_path) # Note: we are in a different process - don't use configured logging
312 #logging.config.fileConfig(ini_file_path) # Note: we are in a different process - don't use configured logging
313 kallithea.config.middleware.make_app(kallithea.CONFIG.global_conf, **kallithea.CONFIG.local_conf)
313 kallithea.config.middleware.make_app(kallithea.CONFIG.global_conf, **kallithea.CONFIG.local_conf)
314
314
315 # fix if it's not a bare repo
315 # fix if it's not a bare repo
316 if repo_path.endswith(os.sep + '.git'):
316 if repo_path.endswith(os.sep + '.git'):
317 repo_path = repo_path[:-5]
317 repo_path = repo_path[:-5]
318
318
319 repo = Repository.get_by_full_path(repo_path)
319 repo = Repository.get_by_full_path(repo_path)
320 if not repo:
320 if not repo:
321 raise OSError('Repository %s not found in database' % repo_path)
321 raise OSError('Repository %s not found in database' % repo_path)
322
322
323 baseui = make_ui()
323 baseui = make_ui()
324 return baseui, repo
324 return baseui, repo
325
325
326
326
327 def handle_git_pre_receive(repo_path, git_stdin_lines):
327 def handle_git_pre_receive(repo_path, git_stdin_lines):
328 """Called from Git pre-receive hook"""
328 """Called from Git pre-receive hook"""
329 # Currently unused. TODO: remove?
329 # Currently unused. TODO: remove?
330 return 0
330 return 0
331
331
332
332
333 def handle_git_post_receive(repo_path, git_stdin_lines):
333 def handle_git_post_receive(repo_path, git_stdin_lines):
334 """Called from Git post-receive hook"""
334 """Called from Git post-receive hook"""
335 try:
335 try:
336 baseui, repo = _hook_environment(repo_path)
336 baseui, repo = _hook_environment(repo_path)
337 except HookEnvironmentError as e:
337 except HookEnvironmentError as e:
338 sys.stderr.write("Skipping Kallithea Git post-recieve hook %r.\nGit was apparently not invoked by Kallithea: %s\n" % (sys.argv[0], e))
338 sys.stderr.write("Skipping Kallithea Git post-recieve hook %r.\nGit was apparently not invoked by Kallithea: %s\n" % (sys.argv[0], e))
339 return 0
339 return 0
340
340
341 # the post push hook should never use the cached instance
341 # the post push hook should never use the cached instance
342 scm_repo = repo.scm_instance_no_cache()
342 scm_repo = repo.scm_instance_no_cache()
343
343
344 rev_data = []
344 rev_data = []
345 for l in git_stdin_lines:
345 for l in git_stdin_lines:
346 old_rev, new_rev, ref = l.strip().split(' ')
346 old_rev, new_rev, ref = l.strip().split(' ')
347 _ref_data = ref.split('/')
347 _ref_data = ref.split('/')
348 if _ref_data[1] in ['tags', 'heads']:
348 if _ref_data[1] in ['tags', 'heads']:
349 rev_data.append({'old_rev': old_rev,
349 rev_data.append({'old_rev': old_rev,
350 'new_rev': new_rev,
350 'new_rev': new_rev,
351 'ref': ref,
351 'ref': ref,
352 'type': _ref_data[1],
352 'type': _ref_data[1],
353 'name': '/'.join(_ref_data[2:])})
353 'name': '/'.join(_ref_data[2:])})
354
354
355 git_revs = []
355 git_revs = []
356 for push_ref in rev_data:
356 for push_ref in rev_data:
357 _type = push_ref['type']
357 _type = push_ref['type']
358 if _type == 'heads':
358 if _type == 'heads':
359 if push_ref['old_rev'] == EmptyChangeset().raw_id:
359 if push_ref['old_rev'] == EmptyChangeset().raw_id:
360 # update the symbolic ref if we push new repo
360 # update the symbolic ref if we push new repo
361 if scm_repo.is_empty():
361 if scm_repo.is_empty():
362 scm_repo._repo.refs.set_symbolic_ref(
362 scm_repo._repo.refs.set_symbolic_ref(
363 b'HEAD',
363 b'HEAD',
364 b'refs/heads/%s' % safe_bytes(push_ref['name']))
364 b'refs/heads/%s' % safe_bytes(push_ref['name']))
365
365
366 # build exclude list without the ref
366 # build exclude list without the ref
367 cmd = ['for-each-ref', '--format=%(refname)', 'refs/heads/*']
367 cmd = ['for-each-ref', '--format=%(refname)', 'refs/heads/*']
368 stdout = scm_repo.run_git_command(cmd)
368 stdout = scm_repo.run_git_command(cmd)
369 ref = push_ref['ref']
369 ref = push_ref['ref']
370 heads = [head for head in stdout.splitlines() if head != ref]
370 heads = [head for head in stdout.splitlines() if head != ref]
371 # now list the git revs while excluding from the list
371 # now list the git revs while excluding from the list
372 cmd = ['log', push_ref['new_rev'], '--reverse', '--pretty=format:%H']
372 cmd = ['log', push_ref['new_rev'], '--reverse', '--pretty=format:%H']
373 cmd.append('--not')
373 cmd.append('--not')
374 cmd.extend(heads) # empty list is ok
374 cmd.extend(heads) # empty list is ok
375 stdout = scm_repo.run_git_command(cmd)
375 stdout = scm_repo.run_git_command(cmd)
376 git_revs += stdout.splitlines()
376 git_revs += stdout.splitlines()
377
377
378 elif push_ref['new_rev'] == EmptyChangeset().raw_id:
378 elif push_ref['new_rev'] == EmptyChangeset().raw_id:
379 # delete branch case
379 # delete branch case
380 git_revs += ['delete_branch=>%s' % push_ref['name']]
380 git_revs += ['delete_branch=>%s' % push_ref['name']]
381 else:
381 else:
382 cmd = ['log', '%(old_rev)s..%(new_rev)s' % push_ref,
382 cmd = ['log', '%(old_rev)s..%(new_rev)s' % push_ref,
383 '--reverse', '--pretty=format:%H']
383 '--reverse', '--pretty=format:%H']
384 stdout = scm_repo.run_git_command(cmd)
384 stdout = scm_repo.run_git_command(cmd)
385 git_revs += stdout.splitlines()
385 git_revs += stdout.splitlines()
386
386
387 elif _type == 'tags':
387 elif _type == 'tags':
388 git_revs += ['tag=>%s' % push_ref['name']]
388 git_revs += ['tag=>%s' % push_ref['name']]
389
389
390 process_pushed_raw_ids(git_revs)
390 process_pushed_raw_ids(git_revs)
391
391
392 return 0
392 return 0
393
393
394
394
395 # Almost exactly like Mercurial contrib/hg-ssh:
395 # Almost exactly like Mercurial contrib/hg-ssh:
396 def rejectpush(ui, **kwargs):
396 def rejectpush(ui, **kwargs):
397 """Mercurial hook to be installed as pretxnopen and prepushkey for read-only repos"""
397 """Mercurial hook to be installed as pretxnopen and prepushkey for read-only repos"""
398 ex = get_hook_environment()
398 ex = get_hook_environment()
399 ui.warn(safe_bytes("Push access to %r denied\n" % ex.repository))
399 ui.warn(safe_bytes("Push access to %r denied\n" % ex.repository))
400 return 1
400 return 1
@@ -1,646 +1,646 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 # This program is free software: you can redistribute it and/or modify
2 # This program is free software: you can redistribute it and/or modify
3 # it under the terms of the GNU General Public License as published by
3 # it under the terms of the GNU General Public License as published by
4 # the Free Software Foundation, either version 3 of the License, or
4 # the Free Software Foundation, either version 3 of the License, or
5 # (at your option) any later version.
5 # (at your option) any later version.
6 #
6 #
7 # This program is distributed in the hope that it will be useful,
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
10 # GNU General Public License for more details.
11 #
11 #
12 # You should have received a copy of the GNU General Public License
12 # You should have received a copy of the GNU General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 """
14 """
15 kallithea.lib.utils
15 kallithea.lib.utils
16 ~~~~~~~~~~~~~~~~~~~
16 ~~~~~~~~~~~~~~~~~~~
17
17
18 Utilities library for Kallithea
18 Utilities library for Kallithea
19
19
20 This file was forked by the Kallithea project in July 2014.
20 This file was forked by the Kallithea project in July 2014.
21 Original author and date, and relevant copyright and licensing information is below:
21 Original author and date, and relevant copyright and licensing information is below:
22 :created_on: Apr 18, 2010
22 :created_on: Apr 18, 2010
23 :author: marcink
23 :author: marcink
24 :copyright: (c) 2013 RhodeCode GmbH, and others.
24 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 :license: GPLv3, see LICENSE.md for more details.
25 :license: GPLv3, see LICENSE.md for more details.
26 """
26 """
27
27
28 import datetime
28 import datetime
29 import logging
29 import logging
30 import os
30 import os
31 import re
31 import re
32 import sys
32 import sys
33 import traceback
33 import traceback
34 from distutils.version import StrictVersion
34 from distutils.version import StrictVersion
35
35
36 import beaker.cache
36 import beaker.cache
37 import mercurial.config
37 import mercurial.config
38 import mercurial.ui
38 import mercurial.ui
39 from tg.i18n import ugettext as _
39 from tg.i18n import ugettext as _
40
40
41 import kallithea.config.conf
41 import kallithea.config.conf
42 from kallithea.lib.exceptions import HgsubversionImportError
42 from kallithea.lib.exceptions import HgsubversionImportError
43 from kallithea.lib.utils2 import ascii_bytes, aslist, get_current_authuser, safe_bytes
43 from kallithea.lib.utils2 import ascii_bytes, aslist, get_current_authuser, safe_bytes, safe_str
44 from kallithea.lib.vcs.backends.git.repository import GitRepository
44 from kallithea.lib.vcs.backends.git.repository import GitRepository
45 from kallithea.lib.vcs.backends.hg.repository import MercurialRepository
45 from kallithea.lib.vcs.backends.hg.repository import MercurialRepository
46 from kallithea.lib.vcs.conf import settings
46 from kallithea.lib.vcs.conf import settings
47 from kallithea.lib.vcs.exceptions import RepositoryError, VCSError
47 from kallithea.lib.vcs.exceptions import RepositoryError, VCSError
48 from kallithea.lib.vcs.utils.fakemod import create_module
48 from kallithea.lib.vcs.utils.fakemod import create_module
49 from kallithea.lib.vcs.utils.helpers import get_scm
49 from kallithea.lib.vcs.utils.helpers import get_scm
50 from kallithea.model import meta
50 from kallithea.model import meta
51 from kallithea.model.db import RepoGroup, Repository, Setting, Ui, User, UserGroup, UserLog
51 from kallithea.model.db import RepoGroup, Repository, Setting, Ui, User, UserGroup, UserLog
52
52
53
53
54 log = logging.getLogger(__name__)
54 log = logging.getLogger(__name__)
55
55
56 REMOVED_REPO_PAT = re.compile(r'rm__\d{8}_\d{6}_\d{6}_.*')
56 REMOVED_REPO_PAT = re.compile(r'rm__\d{8}_\d{6}_\d{6}_.*')
57
57
58
58
59 #==============================================================================
59 #==============================================================================
60 # PERM DECORATOR HELPERS FOR EXTRACTING NAMES FOR PERM CHECKS
60 # PERM DECORATOR HELPERS FOR EXTRACTING NAMES FOR PERM CHECKS
61 #==============================================================================
61 #==============================================================================
62 def get_repo_slug(request):
62 def get_repo_slug(request):
63 _repo = request.environ['pylons.routes_dict'].get('repo_name')
63 _repo = request.environ['pylons.routes_dict'].get('repo_name')
64 if _repo:
64 if _repo:
65 _repo = _repo.rstrip('/')
65 _repo = _repo.rstrip('/')
66 return _repo
66 return _repo
67
67
68
68
69 def get_repo_group_slug(request):
69 def get_repo_group_slug(request):
70 _group = request.environ['pylons.routes_dict'].get('group_name')
70 _group = request.environ['pylons.routes_dict'].get('group_name')
71 if _group:
71 if _group:
72 _group = _group.rstrip('/')
72 _group = _group.rstrip('/')
73 return _group
73 return _group
74
74
75
75
76 def get_user_group_slug(request):
76 def get_user_group_slug(request):
77 _group = request.environ['pylons.routes_dict'].get('id')
77 _group = request.environ['pylons.routes_dict'].get('id')
78 _group = UserGroup.get(_group)
78 _group = UserGroup.get(_group)
79 if _group:
79 if _group:
80 return _group.users_group_name
80 return _group.users_group_name
81 return None
81 return None
82
82
83
83
84 def _get_permanent_id(s):
84 def _get_permanent_id(s):
85 """Helper for decoding stable URLs with repo ID. For a string like '_123'
85 """Helper for decoding stable URLs with repo ID. For a string like '_123'
86 return 123.
86 return 123.
87 """
87 """
88 by_id_match = re.match(r'^_(\d+)$', s)
88 by_id_match = re.match(r'^_(\d+)$', s)
89 if by_id_match is None:
89 if by_id_match is None:
90 return None
90 return None
91 return int(by_id_match.group(1))
91 return int(by_id_match.group(1))
92
92
93
93
94 def fix_repo_id_name(path):
94 def fix_repo_id_name(path):
95 """
95 """
96 Rewrite repo_name for _<ID> permanent URLs.
96 Rewrite repo_name for _<ID> permanent URLs.
97
97
98 Given a path, if the first path element is like _<ID>, return the path with
98 Given a path, if the first path element is like _<ID>, return the path with
99 this part expanded to the corresponding full repo name, else return the
99 this part expanded to the corresponding full repo name, else return the
100 provided path.
100 provided path.
101 """
101 """
102 first, rest = path, ''
102 first, rest = path, ''
103 if '/' in path:
103 if '/' in path:
104 first, rest_ = path.split('/', 1)
104 first, rest_ = path.split('/', 1)
105 rest = '/' + rest_
105 rest = '/' + rest_
106 repo_id = _get_permanent_id(first)
106 repo_id = _get_permanent_id(first)
107 if repo_id is not None:
107 if repo_id is not None:
108 repo = Repository.get(repo_id)
108 repo = Repository.get(repo_id)
109 if repo is not None:
109 if repo is not None:
110 return repo.repo_name + rest
110 return repo.repo_name + rest
111 return path
111 return path
112
112
113
113
114 def action_logger(user, action, repo, ipaddr='', commit=False):
114 def action_logger(user, action, repo, ipaddr='', commit=False):
115 """
115 """
116 Action logger for various actions made by users
116 Action logger for various actions made by users
117
117
118 :param user: user that made this action, can be a unique username string or
118 :param user: user that made this action, can be a unique username string or
119 object containing user_id attribute
119 object containing user_id attribute
120 :param action: action to log, should be on of predefined unique actions for
120 :param action: action to log, should be on of predefined unique actions for
121 easy translations
121 easy translations
122 :param repo: string name of repository or object containing repo_id,
122 :param repo: string name of repository or object containing repo_id,
123 that action was made on
123 that action was made on
124 :param ipaddr: optional IP address from what the action was made
124 :param ipaddr: optional IP address from what the action was made
125
125
126 """
126 """
127
127
128 # if we don't get explicit IP address try to get one from registered user
128 # if we don't get explicit IP address try to get one from registered user
129 # in tmpl context var
129 # in tmpl context var
130 if not ipaddr:
130 if not ipaddr:
131 ipaddr = getattr(get_current_authuser(), 'ip_addr', '')
131 ipaddr = getattr(get_current_authuser(), 'ip_addr', '')
132
132
133 if getattr(user, 'user_id', None):
133 if getattr(user, 'user_id', None):
134 user_obj = User.get(user.user_id)
134 user_obj = User.get(user.user_id)
135 elif isinstance(user, str):
135 elif isinstance(user, str):
136 user_obj = User.get_by_username(user)
136 user_obj = User.get_by_username(user)
137 else:
137 else:
138 raise Exception('You have to provide a user object or a username')
138 raise Exception('You have to provide a user object or a username')
139
139
140 if getattr(repo, 'repo_id', None):
140 if getattr(repo, 'repo_id', None):
141 repo_obj = Repository.get(repo.repo_id)
141 repo_obj = Repository.get(repo.repo_id)
142 repo_name = repo_obj.repo_name
142 repo_name = repo_obj.repo_name
143 elif isinstance(repo, str):
143 elif isinstance(repo, str):
144 repo_name = repo.lstrip('/')
144 repo_name = repo.lstrip('/')
145 repo_obj = Repository.get_by_repo_name(repo_name)
145 repo_obj = Repository.get_by_repo_name(repo_name)
146 else:
146 else:
147 repo_obj = None
147 repo_obj = None
148 repo_name = u''
148 repo_name = u''
149
149
150 user_log = UserLog()
150 user_log = UserLog()
151 user_log.user_id = user_obj.user_id
151 user_log.user_id = user_obj.user_id
152 user_log.username = user_obj.username
152 user_log.username = user_obj.username
153 user_log.action = action
153 user_log.action = action
154
154
155 user_log.repository = repo_obj
155 user_log.repository = repo_obj
156 user_log.repository_name = repo_name
156 user_log.repository_name = repo_name
157
157
158 user_log.action_date = datetime.datetime.now()
158 user_log.action_date = datetime.datetime.now()
159 user_log.user_ip = ipaddr
159 user_log.user_ip = ipaddr
160 meta.Session().add(user_log)
160 meta.Session().add(user_log)
161
161
162 log.info('Logging action:%s on %s by user:%s ip:%s',
162 log.info('Logging action:%s on %s by user:%s ip:%s',
163 action, repo, user_obj, ipaddr)
163 action, repo, user_obj, ipaddr)
164 if commit:
164 if commit:
165 meta.Session().commit()
165 meta.Session().commit()
166
166
167
167
168 def get_filesystem_repos(path):
168 def get_filesystem_repos(path):
169 """
169 """
170 Scans given path for repos and return (name,(type,path)) tuple
170 Scans given path for repos and return (name,(type,path)) tuple
171
171
172 :param path: path to scan for repositories
172 :param path: path to scan for repositories
173 :param recursive: recursive search and return names with subdirs in front
173 :param recursive: recursive search and return names with subdirs in front
174 """
174 """
175
175
176 # remove ending slash for better results
176 # remove ending slash for better results
177 path = path.rstrip(os.sep)
177 path = path.rstrip(os.sep)
178 log.debug('now scanning in %s', path)
178 log.debug('now scanning in %s', path)
179
179
180 def isdir(*n):
180 def isdir(*n):
181 return os.path.isdir(os.path.join(*n))
181 return os.path.isdir(os.path.join(*n))
182
182
183 for root, dirs, _files in os.walk(path):
183 for root, dirs, _files in os.walk(path):
184 recurse_dirs = []
184 recurse_dirs = []
185 for subdir in dirs:
185 for subdir in dirs:
186 # skip removed repos
186 # skip removed repos
187 if REMOVED_REPO_PAT.match(subdir):
187 if REMOVED_REPO_PAT.match(subdir):
188 continue
188 continue
189
189
190 # skip .<something> dirs TODO: rly? then we should prevent creating them ...
190 # skip .<something> dirs TODO: rly? then we should prevent creating them ...
191 if subdir.startswith('.'):
191 if subdir.startswith('.'):
192 continue
192 continue
193
193
194 cur_path = os.path.join(root, subdir)
194 cur_path = os.path.join(root, subdir)
195 if isdir(cur_path, '.git'):
195 if isdir(cur_path, '.git'):
196 log.warning('ignoring non-bare Git repo: %s', cur_path)
196 log.warning('ignoring non-bare Git repo: %s', cur_path)
197 continue
197 continue
198
198
199 if (isdir(cur_path, '.hg') or
199 if (isdir(cur_path, '.hg') or
200 isdir(cur_path, '.svn') or
200 isdir(cur_path, '.svn') or
201 isdir(cur_path, 'objects') and (isdir(cur_path, 'refs') or
201 isdir(cur_path, 'objects') and (isdir(cur_path, 'refs') or
202 os.path.isfile(os.path.join(cur_path, 'packed-refs')))):
202 os.path.isfile(os.path.join(cur_path, 'packed-refs')))):
203
203
204 if not os.access(cur_path, os.R_OK) or not os.access(cur_path, os.X_OK):
204 if not os.access(cur_path, os.R_OK) or not os.access(cur_path, os.X_OK):
205 log.warning('ignoring repo path without access: %s', cur_path)
205 log.warning('ignoring repo path without access: %s', cur_path)
206 continue
206 continue
207
207
208 if not os.access(cur_path, os.W_OK):
208 if not os.access(cur_path, os.W_OK):
209 log.warning('repo path without write access: %s', cur_path)
209 log.warning('repo path without write access: %s', cur_path)
210
210
211 try:
211 try:
212 scm_info = get_scm(cur_path)
212 scm_info = get_scm(cur_path)
213 assert cur_path.startswith(path)
213 assert cur_path.startswith(path)
214 repo_path = cur_path[len(path) + 1:]
214 repo_path = cur_path[len(path) + 1:]
215 yield repo_path, scm_info
215 yield repo_path, scm_info
216 continue # no recursion
216 continue # no recursion
217 except VCSError:
217 except VCSError:
218 # We should perhaps ignore such broken repos, but especially
218 # We should perhaps ignore such broken repos, but especially
219 # the bare git detection is unreliable so we dive into it
219 # the bare git detection is unreliable so we dive into it
220 pass
220 pass
221
221
222 recurse_dirs.append(subdir)
222 recurse_dirs.append(subdir)
223
223
224 dirs[:] = recurse_dirs
224 dirs[:] = recurse_dirs
225
225
226
226
227 def is_valid_repo_uri(repo_type, url, ui):
227 def is_valid_repo_uri(repo_type, url, ui):
228 """Check if the url seems like a valid remote repo location - raise an Exception if any problems"""
228 """Check if the url seems like a valid remote repo location - raise an Exception if any problems"""
229 if repo_type == 'hg':
229 if repo_type == 'hg':
230 if url.startswith('http') or url.startswith('ssh'):
230 if url.startswith('http') or url.startswith('ssh'):
231 # initially check if it's at least the proper URL
231 # initially check if it's at least the proper URL
232 # or does it pass basic auth
232 # or does it pass basic auth
233 MercurialRepository._check_url(url, ui)
233 MercurialRepository._check_url(url, ui)
234 elif url.startswith('svn+http'):
234 elif url.startswith('svn+http'):
235 try:
235 try:
236 from hgsubversion.svnrepo import svnremoterepo
236 from hgsubversion.svnrepo import svnremoterepo
237 except ImportError:
237 except ImportError:
238 raise HgsubversionImportError(_('Unable to activate hgsubversion support. '
238 raise HgsubversionImportError(_('Unable to activate hgsubversion support. '
239 'The "hgsubversion" library is missing'))
239 'The "hgsubversion" library is missing'))
240 svnremoterepo(ui, url).svn.uuid
240 svnremoterepo(ui, url).svn.uuid
241 elif url.startswith('git+http'):
241 elif url.startswith('git+http'):
242 raise NotImplementedError()
242 raise NotImplementedError()
243 else:
243 else:
244 raise Exception('URI %s not allowed' % (url,))
244 raise Exception('URI %s not allowed' % (url,))
245
245
246 elif repo_type == 'git':
246 elif repo_type == 'git':
247 if url.startswith('http') or url.startswith('git'):
247 if url.startswith('http') or url.startswith('git'):
248 # initially check if it's at least the proper URL
248 # initially check if it's at least the proper URL
249 # or does it pass basic auth
249 # or does it pass basic auth
250 GitRepository._check_url(url)
250 GitRepository._check_url(url)
251 elif url.startswith('svn+http'):
251 elif url.startswith('svn+http'):
252 raise NotImplementedError()
252 raise NotImplementedError()
253 elif url.startswith('hg+http'):
253 elif url.startswith('hg+http'):
254 raise NotImplementedError()
254 raise NotImplementedError()
255 else:
255 else:
256 raise Exception('URI %s not allowed' % (url))
256 raise Exception('URI %s not allowed' % (url))
257
257
258
258
259 def is_valid_repo(repo_name, base_path, scm=None):
259 def is_valid_repo(repo_name, base_path, scm=None):
260 """
260 """
261 Returns True if given path is a valid repository False otherwise.
261 Returns True if given path is a valid repository False otherwise.
262 If scm param is given also compare if given scm is the same as expected
262 If scm param is given also compare if given scm is the same as expected
263 from scm parameter
263 from scm parameter
264
264
265 :param repo_name:
265 :param repo_name:
266 :param base_path:
266 :param base_path:
267 :param scm:
267 :param scm:
268
268
269 :return True: if given path is a valid repository
269 :return True: if given path is a valid repository
270 """
270 """
271 # TODO: paranoid security checks?
271 # TODO: paranoid security checks?
272 full_path = os.path.join(base_path, repo_name)
272 full_path = os.path.join(base_path, repo_name)
273
273
274 try:
274 try:
275 scm_ = get_scm(full_path)
275 scm_ = get_scm(full_path)
276 if scm:
276 if scm:
277 return scm_[0] == scm
277 return scm_[0] == scm
278 return True
278 return True
279 except VCSError:
279 except VCSError:
280 return False
280 return False
281
281
282
282
283 def is_valid_repo_group(repo_group_name, base_path, skip_path_check=False):
283 def is_valid_repo_group(repo_group_name, base_path, skip_path_check=False):
284 """
284 """
285 Returns True if given path is a repository group False otherwise
285 Returns True if given path is a repository group False otherwise
286
286
287 :param repo_name:
287 :param repo_name:
288 :param base_path:
288 :param base_path:
289 """
289 """
290 full_path = os.path.join(base_path, repo_group_name)
290 full_path = os.path.join(base_path, repo_group_name)
291
291
292 # check if it's not a repo
292 # check if it's not a repo
293 if is_valid_repo(repo_group_name, base_path):
293 if is_valid_repo(repo_group_name, base_path):
294 return False
294 return False
295
295
296 try:
296 try:
297 # we need to check bare git repos at higher level
297 # we need to check bare git repos at higher level
298 # since we might match branches/hooks/info/objects or possible
298 # since we might match branches/hooks/info/objects or possible
299 # other things inside bare git repo
299 # other things inside bare git repo
300 get_scm(os.path.dirname(full_path))
300 get_scm(os.path.dirname(full_path))
301 return False
301 return False
302 except VCSError:
302 except VCSError:
303 pass
303 pass
304
304
305 # check if it's a valid path
305 # check if it's a valid path
306 if skip_path_check or os.path.isdir(full_path):
306 if skip_path_check or os.path.isdir(full_path):
307 return True
307 return True
308
308
309 return False
309 return False
310
310
311
311
312 # propagated from mercurial documentation
312 # propagated from mercurial documentation
313 ui_sections = ['alias', 'auth',
313 ui_sections = ['alias', 'auth',
314 'decode/encode', 'defaults',
314 'decode/encode', 'defaults',
315 'diff', 'email',
315 'diff', 'email',
316 'extensions', 'format',
316 'extensions', 'format',
317 'merge-patterns', 'merge-tools',
317 'merge-patterns', 'merge-tools',
318 'hooks', 'http_proxy',
318 'hooks', 'http_proxy',
319 'smtp', 'patch',
319 'smtp', 'patch',
320 'paths', 'profiling',
320 'paths', 'profiling',
321 'server', 'trusted',
321 'server', 'trusted',
322 'ui', 'web', ]
322 'ui', 'web', ]
323
323
324
324
325 def make_ui(repo_path=None):
325 def make_ui(repo_path=None):
326 """
326 """
327 Create an Mercurial 'ui' object based on database Ui settings, possibly
327 Create an Mercurial 'ui' object based on database Ui settings, possibly
328 augmenting with content from a hgrc file.
328 augmenting with content from a hgrc file.
329 """
329 """
330 baseui = mercurial.ui.ui()
330 baseui = mercurial.ui.ui()
331
331
332 # clean the baseui object
332 # clean the baseui object
333 baseui._ocfg = mercurial.config.config()
333 baseui._ocfg = mercurial.config.config()
334 baseui._ucfg = mercurial.config.config()
334 baseui._ucfg = mercurial.config.config()
335 baseui._tcfg = mercurial.config.config()
335 baseui._tcfg = mercurial.config.config()
336
336
337 sa = meta.Session()
337 sa = meta.Session()
338 for ui_ in sa.query(Ui).all():
338 for ui_ in sa.query(Ui).all():
339 if ui_.ui_active:
339 if ui_.ui_active:
340 log.debug('config from db: [%s] %s=%r', ui_.ui_section,
340 log.debug('config from db: [%s] %s=%r', ui_.ui_section,
341 ui_.ui_key, ui_.ui_value)
341 ui_.ui_key, ui_.ui_value)
342 baseui.setconfig(ascii_bytes(ui_.ui_section), ascii_bytes(ui_.ui_key),
342 baseui.setconfig(ascii_bytes(ui_.ui_section), ascii_bytes(ui_.ui_key),
343 b'' if ui_.ui_value is None else safe_bytes(ui_.ui_value))
343 b'' if ui_.ui_value is None else safe_bytes(ui_.ui_value))
344
344
345 # force set push_ssl requirement to False, Kallithea handles that
345 # force set push_ssl requirement to False, Kallithea handles that
346 baseui.setconfig(b'web', b'push_ssl', False)
346 baseui.setconfig(b'web', b'push_ssl', False)
347 baseui.setconfig(b'web', b'allow_push', b'*')
347 baseui.setconfig(b'web', b'allow_push', b'*')
348 # prevent interactive questions for ssh password / passphrase
348 # prevent interactive questions for ssh password / passphrase
349 ssh = baseui.config(b'ui', b'ssh', default=b'ssh')
349 ssh = baseui.config(b'ui', b'ssh', default=b'ssh')
350 baseui.setconfig(b'ui', b'ssh', b'%s -oBatchMode=yes -oIdentitiesOnly=yes' % ssh)
350 baseui.setconfig(b'ui', b'ssh', b'%s -oBatchMode=yes -oIdentitiesOnly=yes' % ssh)
351 # push / pull hooks
351 # push / pull hooks
352 baseui.setconfig(b'hooks', b'changegroup.kallithea_log_push_action', b'python:kallithea.lib.hooks.log_push_action')
352 baseui.setconfig(b'hooks', b'changegroup.kallithea_log_push_action', b'python:kallithea.lib.hooks.log_push_action')
353 baseui.setconfig(b'hooks', b'outgoing.kallithea_log_pull_action', b'python:kallithea.lib.hooks.log_pull_action')
353 baseui.setconfig(b'hooks', b'outgoing.kallithea_log_pull_action', b'python:kallithea.lib.hooks.log_pull_action')
354
354
355 if repo_path is not None:
355 if repo_path is not None:
356 hgrc_path = os.path.join(repo_path, '.hg', 'hgrc')
356 hgrc_path = os.path.join(repo_path, '.hg', 'hgrc')
357 if os.path.isfile(hgrc_path):
357 if os.path.isfile(hgrc_path):
358 log.debug('reading hgrc from %s', hgrc_path)
358 log.debug('reading hgrc from %s', hgrc_path)
359 cfg = mercurial.config.config()
359 cfg = mercurial.config.config()
360 cfg.read(safe_bytes(hgrc_path))
360 cfg.read(safe_bytes(hgrc_path))
361 for section in ui_sections:
361 for section in ui_sections:
362 for k, v in cfg.items(section):
362 for k, v in cfg.items(section):
363 log.debug('config from file: [%s] %s=%s', section, k, v)
363 log.debug('config from file: [%s] %s=%s', section, k, v)
364 baseui.setconfig(ascii_bytes(section), ascii_bytes(k), safe_bytes(v))
364 baseui.setconfig(ascii_bytes(section), ascii_bytes(k), safe_bytes(v))
365 else:
365 else:
366 log.debug('hgrc file is not present at %s, skipping...', hgrc_path)
366 log.debug('hgrc file is not present at %s, skipping...', hgrc_path)
367
367
368 return baseui
368 return baseui
369
369
370
370
371 def set_app_settings(config):
371 def set_app_settings(config):
372 """
372 """
373 Updates app config with new settings from database
373 Updates app config with new settings from database
374
374
375 :param config:
375 :param config:
376 """
376 """
377 hgsettings = Setting.get_app_settings()
377 hgsettings = Setting.get_app_settings()
378 for k, v in hgsettings.items():
378 for k, v in hgsettings.items():
379 config[k] = v
379 config[k] = v
380
380
381
381
382 def set_vcs_config(config):
382 def set_vcs_config(config):
383 """
383 """
384 Patch VCS config with some Kallithea specific stuff
384 Patch VCS config with some Kallithea specific stuff
385
385
386 :param config: kallithea.CONFIG
386 :param config: kallithea.CONFIG
387 """
387 """
388 settings.BACKENDS = {
388 settings.BACKENDS = {
389 'hg': 'kallithea.lib.vcs.backends.hg.MercurialRepository',
389 'hg': 'kallithea.lib.vcs.backends.hg.MercurialRepository',
390 'git': 'kallithea.lib.vcs.backends.git.GitRepository',
390 'git': 'kallithea.lib.vcs.backends.git.GitRepository',
391 }
391 }
392
392
393 settings.GIT_EXECUTABLE_PATH = config.get('git_path', 'git')
393 settings.GIT_EXECUTABLE_PATH = config.get('git_path', 'git')
394 settings.GIT_REV_FILTER = config.get('git_rev_filter', '--all').strip()
394 settings.GIT_REV_FILTER = config.get('git_rev_filter', '--all').strip()
395 settings.DEFAULT_ENCODINGS = aslist(config.get('default_encoding',
395 settings.DEFAULT_ENCODINGS = aslist(config.get('default_encoding',
396 'utf-8'), sep=',')
396 'utf-8'), sep=',')
397
397
398
398
399 def set_indexer_config(config):
399 def set_indexer_config(config):
400 """
400 """
401 Update Whoosh index mapping
401 Update Whoosh index mapping
402
402
403 :param config: kallithea.CONFIG
403 :param config: kallithea.CONFIG
404 """
404 """
405 log.debug('adding extra into INDEX_EXTENSIONS')
405 log.debug('adding extra into INDEX_EXTENSIONS')
406 kallithea.config.conf.INDEX_EXTENSIONS.extend(re.split(r'\s+', config.get('index.extensions', '')))
406 kallithea.config.conf.INDEX_EXTENSIONS.extend(re.split(r'\s+', config.get('index.extensions', '')))
407
407
408 log.debug('adding extra into INDEX_FILENAMES')
408 log.debug('adding extra into INDEX_FILENAMES')
409 kallithea.config.conf.INDEX_FILENAMES.extend(re.split(r'\s+', config.get('index.filenames', '')))
409 kallithea.config.conf.INDEX_FILENAMES.extend(re.split(r'\s+', config.get('index.filenames', '')))
410
410
411
411
412 def map_groups(path):
412 def map_groups(path):
413 """
413 """
414 Given a full path to a repository, create all nested groups that this
414 Given a full path to a repository, create all nested groups that this
415 repo is inside. This function creates parent-child relationships between
415 repo is inside. This function creates parent-child relationships between
416 groups and creates default perms for all new groups.
416 groups and creates default perms for all new groups.
417
417
418 :param paths: full path to repository
418 :param paths: full path to repository
419 """
419 """
420 from kallithea.model.repo_group import RepoGroupModel
420 from kallithea.model.repo_group import RepoGroupModel
421 sa = meta.Session()
421 sa = meta.Session()
422 groups = path.split(Repository.url_sep())
422 groups = path.split(Repository.url_sep())
423 parent = None
423 parent = None
424 group = None
424 group = None
425
425
426 # last element is repo in nested groups structure
426 # last element is repo in nested groups structure
427 groups = groups[:-1]
427 groups = groups[:-1]
428 rgm = RepoGroupModel()
428 rgm = RepoGroupModel()
429 owner = User.get_first_admin()
429 owner = User.get_first_admin()
430 for lvl, group_name in enumerate(groups):
430 for lvl, group_name in enumerate(groups):
431 group_name = u'/'.join(groups[:lvl] + [group_name])
431 group_name = u'/'.join(groups[:lvl] + [group_name])
432 group = RepoGroup.get_by_group_name(group_name)
432 group = RepoGroup.get_by_group_name(group_name)
433 desc = '%s group' % group_name
433 desc = '%s group' % group_name
434
434
435 # skip folders that are now removed repos
435 # skip folders that are now removed repos
436 if REMOVED_REPO_PAT.match(group_name):
436 if REMOVED_REPO_PAT.match(group_name):
437 break
437 break
438
438
439 if group is None:
439 if group is None:
440 log.debug('creating group level: %s group_name: %s',
440 log.debug('creating group level: %s group_name: %s',
441 lvl, group_name)
441 lvl, group_name)
442 group = RepoGroup(group_name, parent)
442 group = RepoGroup(group_name, parent)
443 group.group_description = desc
443 group.group_description = desc
444 group.owner = owner
444 group.owner = owner
445 sa.add(group)
445 sa.add(group)
446 rgm._create_default_perms(group)
446 rgm._create_default_perms(group)
447 sa.flush()
447 sa.flush()
448
448
449 parent = group
449 parent = group
450 return group
450 return group
451
451
452
452
453 def repo2db_mapper(initial_repo_dict, remove_obsolete=False,
453 def repo2db_mapper(initial_repo_dict, remove_obsolete=False,
454 install_git_hooks=False, user=None, overwrite_git_hooks=False):
454 install_git_hooks=False, user=None, overwrite_git_hooks=False):
455 """
455 """
456 maps all repos given in initial_repo_dict, non existing repositories
456 maps all repos given in initial_repo_dict, non existing repositories
457 are created, if remove_obsolete is True it also check for db entries
457 are created, if remove_obsolete is True it also check for db entries
458 that are not in initial_repo_dict and removes them.
458 that are not in initial_repo_dict and removes them.
459
459
460 :param initial_repo_dict: mapping with repositories found by scanning methods
460 :param initial_repo_dict: mapping with repositories found by scanning methods
461 :param remove_obsolete: check for obsolete entries in database
461 :param remove_obsolete: check for obsolete entries in database
462 :param install_git_hooks: if this is True, also check and install git hook
462 :param install_git_hooks: if this is True, also check and install git hook
463 for a repo if missing
463 for a repo if missing
464 :param overwrite_git_hooks: if this is True, overwrite any existing git hooks
464 :param overwrite_git_hooks: if this is True, overwrite any existing git hooks
465 that may be encountered (even if user-deployed)
465 that may be encountered (even if user-deployed)
466 """
466 """
467 from kallithea.model.repo import RepoModel
467 from kallithea.model.repo import RepoModel
468 from kallithea.model.scm import ScmModel
468 from kallithea.model.scm import ScmModel
469 sa = meta.Session()
469 sa = meta.Session()
470 repo_model = RepoModel()
470 repo_model = RepoModel()
471 if user is None:
471 if user is None:
472 user = User.get_first_admin()
472 user = User.get_first_admin()
473 added = []
473 added = []
474
474
475 # creation defaults
475 # creation defaults
476 defs = Setting.get_default_repo_settings(strip_prefix=True)
476 defs = Setting.get_default_repo_settings(strip_prefix=True)
477 enable_statistics = defs.get('repo_enable_statistics')
477 enable_statistics = defs.get('repo_enable_statistics')
478 enable_downloads = defs.get('repo_enable_downloads')
478 enable_downloads = defs.get('repo_enable_downloads')
479 private = defs.get('repo_private')
479 private = defs.get('repo_private')
480
480
481 for name, repo in initial_repo_dict.items():
481 for name, repo in initial_repo_dict.items():
482 group = map_groups(name)
482 group = map_groups(name)
483 db_repo = repo_model.get_by_repo_name(name)
483 db_repo = repo_model.get_by_repo_name(name)
484 # found repo that is on filesystem not in Kallithea database
484 # found repo that is on filesystem not in Kallithea database
485 if not db_repo:
485 if not db_repo:
486 log.info('repository %s not found, creating now', name)
486 log.info('repository %s not found, creating now', name)
487 added.append(name)
487 added.append(name)
488 desc = (repo.description
488 desc = (repo.description
489 if repo.description != 'unknown'
489 if repo.description != 'unknown'
490 else '%s repository' % name)
490 else '%s repository' % name)
491
491
492 new_repo = repo_model._create_repo(
492 new_repo = repo_model._create_repo(
493 repo_name=name,
493 repo_name=name,
494 repo_type=repo.alias,
494 repo_type=repo.alias,
495 description=desc,
495 description=desc,
496 repo_group=getattr(group, 'group_id', None),
496 repo_group=getattr(group, 'group_id', None),
497 owner=user,
497 owner=user,
498 enable_downloads=enable_downloads,
498 enable_downloads=enable_downloads,
499 enable_statistics=enable_statistics,
499 enable_statistics=enable_statistics,
500 private=private,
500 private=private,
501 state=Repository.STATE_CREATED
501 state=Repository.STATE_CREATED
502 )
502 )
503 sa.commit()
503 sa.commit()
504 # we added that repo just now, and make sure it has githook
504 # we added that repo just now, and make sure it has githook
505 # installed, and updated server info
505 # installed, and updated server info
506 if new_repo.repo_type == 'git':
506 if new_repo.repo_type == 'git':
507 git_repo = new_repo.scm_instance
507 git_repo = new_repo.scm_instance
508 ScmModel().install_git_hooks(git_repo)
508 ScmModel().install_git_hooks(git_repo)
509 # update repository server-info
509 # update repository server-info
510 log.debug('Running update server info')
510 log.debug('Running update server info')
511 git_repo._update_server_info()
511 git_repo._update_server_info()
512 new_repo.update_changeset_cache()
512 new_repo.update_changeset_cache()
513 elif install_git_hooks:
513 elif install_git_hooks:
514 if db_repo.repo_type == 'git':
514 if db_repo.repo_type == 'git':
515 ScmModel().install_git_hooks(db_repo.scm_instance, force_create=overwrite_git_hooks)
515 ScmModel().install_git_hooks(db_repo.scm_instance, force_create=overwrite_git_hooks)
516
516
517 removed = []
517 removed = []
518 # remove from database those repositories that are not in the filesystem
518 # remove from database those repositories that are not in the filesystem
519 for repo in sa.query(Repository).all():
519 for repo in sa.query(Repository).all():
520 if repo.repo_name not in initial_repo_dict:
520 if repo.repo_name not in initial_repo_dict:
521 if remove_obsolete:
521 if remove_obsolete:
522 log.debug("Removing non-existing repository found in db `%s`",
522 log.debug("Removing non-existing repository found in db `%s`",
523 repo.repo_name)
523 repo.repo_name)
524 try:
524 try:
525 RepoModel().delete(repo, forks='detach', fs_remove=False)
525 RepoModel().delete(repo, forks='detach', fs_remove=False)
526 sa.commit()
526 sa.commit()
527 except Exception:
527 except Exception:
528 #don't hold further removals on error
528 #don't hold further removals on error
529 log.error(traceback.format_exc())
529 log.error(traceback.format_exc())
530 sa.rollback()
530 sa.rollback()
531 removed.append(repo.repo_name)
531 removed.append(repo.repo_name)
532 return added, removed
532 return added, removed
533
533
534
534
535 def load_rcextensions(root_path):
535 def load_rcextensions(root_path):
536 path = os.path.join(root_path, 'rcextensions', '__init__.py')
536 path = os.path.join(root_path, 'rcextensions', '__init__.py')
537 if os.path.isfile(path):
537 if os.path.isfile(path):
538 rcext = create_module('rc', path)
538 rcext = create_module('rc', path)
539 EXT = kallithea.EXTENSIONS = rcext
539 EXT = kallithea.EXTENSIONS = rcext
540 log.debug('Found rcextensions now loading %s...', rcext)
540 log.debug('Found rcextensions now loading %s...', rcext)
541
541
542 # Additional mappings that are not present in the pygments lexers
542 # Additional mappings that are not present in the pygments lexers
543 kallithea.config.conf.LANGUAGES_EXTENSIONS_MAP.update(getattr(EXT, 'EXTRA_MAPPINGS', {}))
543 kallithea.config.conf.LANGUAGES_EXTENSIONS_MAP.update(getattr(EXT, 'EXTRA_MAPPINGS', {}))
544
544
545 # OVERRIDE OUR EXTENSIONS FROM RC-EXTENSIONS (if present)
545 # OVERRIDE OUR EXTENSIONS FROM RC-EXTENSIONS (if present)
546
546
547 if getattr(EXT, 'INDEX_EXTENSIONS', []):
547 if getattr(EXT, 'INDEX_EXTENSIONS', []):
548 log.debug('settings custom INDEX_EXTENSIONS')
548 log.debug('settings custom INDEX_EXTENSIONS')
549 kallithea.config.conf.INDEX_EXTENSIONS = getattr(EXT, 'INDEX_EXTENSIONS', [])
549 kallithea.config.conf.INDEX_EXTENSIONS = getattr(EXT, 'INDEX_EXTENSIONS', [])
550
550
551 # ADDITIONAL MAPPINGS
551 # ADDITIONAL MAPPINGS
552 log.debug('adding extra into INDEX_EXTENSIONS')
552 log.debug('adding extra into INDEX_EXTENSIONS')
553 kallithea.config.conf.INDEX_EXTENSIONS.extend(getattr(EXT, 'EXTRA_INDEX_EXTENSIONS', []))
553 kallithea.config.conf.INDEX_EXTENSIONS.extend(getattr(EXT, 'EXTRA_INDEX_EXTENSIONS', []))
554
554
555 # auto check if the module is not missing any data, set to default if is
555 # auto check if the module is not missing any data, set to default if is
556 # this will help autoupdate new feature of rcext module
556 # this will help autoupdate new feature of rcext module
557 #from kallithea.config import rcextensions
557 #from kallithea.config import rcextensions
558 #for k in dir(rcextensions):
558 #for k in dir(rcextensions):
559 # if not k.startswith('_') and not hasattr(EXT, k):
559 # if not k.startswith('_') and not hasattr(EXT, k):
560 # setattr(EXT, k, getattr(rcextensions, k))
560 # setattr(EXT, k, getattr(rcextensions, k))
561
561
562
562
563 #==============================================================================
563 #==============================================================================
564 # MISC
564 # MISC
565 #==============================================================================
565 #==============================================================================
566
566
567 git_req_ver = StrictVersion('1.7.4')
567 git_req_ver = StrictVersion('1.7.4')
568
568
569 def check_git_version():
569 def check_git_version():
570 """
570 """
571 Checks what version of git is installed on the system, and raise a system exit
571 Checks what version of git is installed on the system, and raise a system exit
572 if it's too old for Kallithea to work properly.
572 if it's too old for Kallithea to work properly.
573 """
573 """
574 if 'git' not in kallithea.BACKENDS:
574 if 'git' not in kallithea.BACKENDS:
575 return None
575 return None
576
576
577 if not settings.GIT_EXECUTABLE_PATH:
577 if not settings.GIT_EXECUTABLE_PATH:
578 log.warning('No git executable configured - check "git_path" in the ini file.')
578 log.warning('No git executable configured - check "git_path" in the ini file.')
579 return None
579 return None
580
580
581 try:
581 try:
582 stdout, stderr = GitRepository._run_git_command(['--version'])
582 stdout, stderr = GitRepository._run_git_command(['--version'])
583 except RepositoryError as e:
583 except RepositoryError as e:
584 # message will already have been logged as error
584 # message will already have been logged as error
585 log.warning('No working git executable found - check "git_path" in the ini file.')
585 log.warning('No working git executable found - check "git_path" in the ini file.')
586 return None
586 return None
587
587
588 if stderr:
588 if stderr:
589 log.warning('Error/stderr from "%s --version":\n%s', settings.GIT_EXECUTABLE_PATH, stderr)
589 log.warning('Error/stderr from "%s --version":\n%s', settings.GIT_EXECUTABLE_PATH, safe_str(stderr))
590
590
591 if not stdout:
591 if not stdout:
592 log.warning('No working git executable found - check "git_path" in the ini file.')
592 log.warning('No working git executable found - check "git_path" in the ini file.')
593 return None
593 return None
594
594
595 output = stdout.strip()
595 output = safe_str(stdout).strip()
596 m = re.search(r"\d+.\d+.\d+", output)
596 m = re.search(r"\d+.\d+.\d+", output)
597 if m:
597 if m:
598 ver = StrictVersion(m.group(0))
598 ver = StrictVersion(m.group(0))
599 log.debug('Git executable: "%s", version %s (parsed from: "%s")',
599 log.debug('Git executable: "%s", version %s (parsed from: "%s")',
600 settings.GIT_EXECUTABLE_PATH, ver, output)
600 settings.GIT_EXECUTABLE_PATH, ver, output)
601 if ver < git_req_ver:
601 if ver < git_req_ver:
602 log.error('Kallithea detected %s version %s, which is too old '
602 log.error('Kallithea detected %s version %s, which is too old '
603 'for the system to function properly. '
603 'for the system to function properly. '
604 'Please upgrade to version %s or later. '
604 'Please upgrade to version %s or later. '
605 'If you strictly need Mercurial repositories, you can '
605 'If you strictly need Mercurial repositories, you can '
606 'clear the "git_path" setting in the ini file.',
606 'clear the "git_path" setting in the ini file.',
607 settings.GIT_EXECUTABLE_PATH, ver, git_req_ver)
607 settings.GIT_EXECUTABLE_PATH, ver, git_req_ver)
608 log.error("Terminating ...")
608 log.error("Terminating ...")
609 sys.exit(1)
609 sys.exit(1)
610 else:
610 else:
611 ver = StrictVersion('0.0.0')
611 ver = StrictVersion('0.0.0')
612 log.warning('Error finding version number in "%s --version" stdout:\n%s',
612 log.warning('Error finding version number in "%s --version" stdout:\n%s',
613 settings.GIT_EXECUTABLE_PATH, output)
613 settings.GIT_EXECUTABLE_PATH, output)
614
614
615 return ver
615 return ver
616
616
617
617
618 #===============================================================================
618 #===============================================================================
619 # CACHE RELATED METHODS
619 # CACHE RELATED METHODS
620 #===============================================================================
620 #===============================================================================
621
621
622 def conditional_cache(region, prefix, condition, func):
622 def conditional_cache(region, prefix, condition, func):
623 """
623 """
624
624
625 Conditional caching function use like::
625 Conditional caching function use like::
626 def _c(arg):
626 def _c(arg):
627 #heavy computation function
627 #heavy computation function
628 return data
628 return data
629
629
630 # depending from condition the compute is wrapped in cache or not
630 # depending from condition the compute is wrapped in cache or not
631 compute = conditional_cache('short_term', 'cache_desc', condition=True, func=func)
631 compute = conditional_cache('short_term', 'cache_desc', condition=True, func=func)
632 return compute(arg)
632 return compute(arg)
633
633
634 :param region: name of cache region
634 :param region: name of cache region
635 :param prefix: cache region prefix
635 :param prefix: cache region prefix
636 :param condition: condition for cache to be triggered, and return data cached
636 :param condition: condition for cache to be triggered, and return data cached
637 :param func: wrapped heavy function to compute
637 :param func: wrapped heavy function to compute
638
638
639 """
639 """
640 wrapped = func
640 wrapped = func
641 if condition:
641 if condition:
642 log.debug('conditional_cache: True, wrapping call of '
642 log.debug('conditional_cache: True, wrapping call of '
643 'func: %s into %s region cache' % (region, func))
643 'func: %s into %s region cache' % (region, func))
644 wrapped = beaker.cache._cache_decorate((prefix,), None, None, region)(func)
644 wrapped = beaker.cache._cache_decorate((prefix,), None, None, region)(func)
645
645
646 return wrapped
646 return wrapped
@@ -1,552 +1,550 b''
1 import re
1 import re
2 from io import BytesIO
2 from io import BytesIO
3 from itertools import chain
3 from itertools import chain
4 from subprocess import PIPE, Popen
4 from subprocess import PIPE, Popen
5
5
6 from dulwich import objects
6 from dulwich import objects
7 from dulwich.config import ConfigFile
7 from dulwich.config import ConfigFile
8
8
9 from kallithea.lib.vcs.backends.base import BaseChangeset, EmptyChangeset
9 from kallithea.lib.vcs.backends.base import BaseChangeset, EmptyChangeset
10 from kallithea.lib.vcs.conf import settings
10 from kallithea.lib.vcs.conf import settings
11 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, ChangesetError, ImproperArchiveTypeError, NodeDoesNotExistError, RepositoryError, VCSError
11 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, ChangesetError, ImproperArchiveTypeError, NodeDoesNotExistError, RepositoryError, VCSError
12 from kallithea.lib.vcs.nodes import (
12 from kallithea.lib.vcs.nodes import (
13 AddedFileNodesGenerator, ChangedFileNodesGenerator, DirNode, FileNode, NodeKind, RemovedFileNodesGenerator, RootNode, SubModuleNode)
13 AddedFileNodesGenerator, ChangedFileNodesGenerator, DirNode, FileNode, NodeKind, RemovedFileNodesGenerator, RootNode, SubModuleNode)
14 from kallithea.lib.vcs.utils import ascii_bytes, ascii_str, date_fromtimestamp, safe_int, safe_str
14 from kallithea.lib.vcs.utils import ascii_bytes, ascii_str, date_fromtimestamp, safe_int, safe_str
15 from kallithea.lib.vcs.utils.lazy import LazyProperty
15 from kallithea.lib.vcs.utils.lazy import LazyProperty
16
16
17
17
18 class GitChangeset(BaseChangeset):
18 class GitChangeset(BaseChangeset):
19 """
19 """
20 Represents state of the repository at a revision.
20 Represents state of the repository at a revision.
21 """
21 """
22
22
23 def __init__(self, repository, revision):
23 def __init__(self, repository, revision):
24 self._stat_modes = {}
24 self._stat_modes = {}
25 self.repository = repository
25 self.repository = repository
26 try:
26 try:
27 commit = self.repository._repo[ascii_bytes(revision)]
27 commit = self.repository._repo[ascii_bytes(revision)]
28 if isinstance(commit, objects.Tag):
28 if isinstance(commit, objects.Tag):
29 revision = safe_str(commit.object[1])
29 revision = safe_str(commit.object[1])
30 commit = self.repository._repo.get_object(commit.object[1])
30 commit = self.repository._repo.get_object(commit.object[1])
31 except KeyError:
31 except KeyError:
32 raise RepositoryError("Cannot get object with id %s" % revision)
32 raise RepositoryError("Cannot get object with id %s" % revision)
33 self.raw_id = ascii_str(commit.id)
33 self.raw_id = ascii_str(commit.id)
34 self.short_id = self.raw_id[:12]
34 self.short_id = self.raw_id[:12]
35 self._commit = commit # a Dulwich Commmit with .id
35 self._commit = commit # a Dulwich Commmit with .id
36 self._tree_id = commit.tree
36 self._tree_id = commit.tree
37 self._committer_property = 'committer'
37 self._committer_property = 'committer'
38 self._author_property = 'author'
38 self._author_property = 'author'
39 self._date_property = 'commit_time'
39 self._date_property = 'commit_time'
40 self._date_tz_property = 'commit_timezone'
40 self._date_tz_property = 'commit_timezone'
41 self.revision = repository.revisions.index(self.raw_id)
41 self.revision = repository.revisions.index(self.raw_id)
42
42
43 self.nodes = {}
43 self.nodes = {}
44 self._paths = {}
44 self._paths = {}
45
45
46 @LazyProperty
46 @LazyProperty
47 def bookmarks(self):
47 def bookmarks(self):
48 return ()
48 return ()
49
49
50 @LazyProperty
50 @LazyProperty
51 def message(self):
51 def message(self):
52 return safe_str(self._commit.message)
52 return safe_str(self._commit.message)
53
53
54 @LazyProperty
54 @LazyProperty
55 def committer(self):
55 def committer(self):
56 return safe_str(getattr(self._commit, self._committer_property))
56 return safe_str(getattr(self._commit, self._committer_property))
57
57
58 @LazyProperty
58 @LazyProperty
59 def author(self):
59 def author(self):
60 return safe_str(getattr(self._commit, self._author_property))
60 return safe_str(getattr(self._commit, self._author_property))
61
61
62 @LazyProperty
62 @LazyProperty
63 def date(self):
63 def date(self):
64 return date_fromtimestamp(getattr(self._commit, self._date_property),
64 return date_fromtimestamp(getattr(self._commit, self._date_property),
65 getattr(self._commit, self._date_tz_property))
65 getattr(self._commit, self._date_tz_property))
66
66
67 @LazyProperty
67 @LazyProperty
68 def _timestamp(self):
68 def _timestamp(self):
69 return getattr(self._commit, self._date_property)
69 return getattr(self._commit, self._date_property)
70
70
71 @LazyProperty
71 @LazyProperty
72 def status(self):
72 def status(self):
73 """
73 """
74 Returns modified, added, removed, deleted files for current changeset
74 Returns modified, added, removed, deleted files for current changeset
75 """
75 """
76 return self.changed, self.added, self.removed
76 return self.changed, self.added, self.removed
77
77
78 @LazyProperty
78 @LazyProperty
79 def tags(self):
79 def tags(self):
80 _tags = []
80 _tags = []
81 for tname, tsha in self.repository.tags.items():
81 for tname, tsha in self.repository.tags.items():
82 if tsha == self.raw_id:
82 if tsha == self.raw_id:
83 _tags.append(tname)
83 _tags.append(tname)
84 return _tags
84 return _tags
85
85
86 @LazyProperty
86 @LazyProperty
87 def branch(self):
87 def branch(self):
88 # Note: This function will return one branch name for the changeset -
88 # Note: This function will return one branch name for the changeset -
89 # that might not make sense in Git where branches() is a better match
89 # that might not make sense in Git where branches() is a better match
90 # for the basic model
90 # for the basic model
91 heads = self.repository._heads(reverse=False)
91 heads = self.repository._heads(reverse=False)
92 ref = heads.get(self._commit.id)
92 ref = heads.get(self._commit.id)
93 if ref:
93 if ref:
94 return safe_str(ref)
94 return safe_str(ref)
95
95
96 @LazyProperty
96 @LazyProperty
97 def branches(self):
97 def branches(self):
98 heads = self.repository._heads(reverse=True)
98 heads = self.repository._heads(reverse=True)
99 return [b for b in heads if heads[b] == self._commit.id] # FIXME: Inefficient ... and returning None!
99 return [safe_str(b) for b in heads if heads[b] == self._commit.id] # FIXME: Inefficient ... and returning None!
100
100
101 def _fix_path(self, path):
101 def _fix_path(self, path):
102 """
102 """
103 Paths are stored without trailing slash so we need to get rid off it if
103 Paths are stored without trailing slash so we need to get rid off it if
104 needed.
104 needed.
105 """
105 """
106 if path.endswith('/'):
106 if path.endswith('/'):
107 path = path.rstrip('/')
107 path = path.rstrip('/')
108 return path
108 return path
109
109
110 def _get_id_for_path(self, path):
110 def _get_id_for_path(self, path):
111 # FIXME: Please, spare a couple of minutes and make those codes cleaner;
111 # FIXME: Please, spare a couple of minutes and make those codes cleaner;
112 if path not in self._paths:
112 if path not in self._paths:
113 path = path.strip('/')
113 path = path.strip('/')
114 # set root tree
114 # set root tree
115 tree = self.repository._repo[self._tree_id]
115 tree = self.repository._repo[self._tree_id]
116 if path == '':
116 if path == '':
117 self._paths[''] = tree.id
117 self._paths[''] = tree.id
118 return tree.id
118 return tree.id
119 splitted = path.split('/')
119 splitted = path.split('/')
120 dirs, name = splitted[:-1], splitted[-1]
120 dirs, name = splitted[:-1], splitted[-1]
121 curdir = ''
121 curdir = ''
122
122
123 # initially extract things from root dir
123 # initially extract things from root dir
124 for item, stat, id in tree.items():
124 for item, stat, id in tree.items():
125 name = safe_str(item)
125 if curdir:
126 if curdir:
126 name = '/'.join((curdir, item))
127 name = '/'.join((curdir, name))
127 else:
128 name = item
129 self._paths[name] = id
128 self._paths[name] = id
130 self._stat_modes[name] = stat
129 self._stat_modes[name] = stat
131
130
132 for dir in dirs:
131 for dir in dirs:
133 if curdir:
132 if curdir:
134 curdir = '/'.join((curdir, dir))
133 curdir = '/'.join((curdir, dir))
135 else:
134 else:
136 curdir = dir
135 curdir = dir
137 dir_id = None
136 dir_id = None
138 for item, stat, id in tree.items():
137 for item, stat, id in tree.items():
139 if dir == item:
138 name = safe_str(item)
139 if dir == name:
140 dir_id = id
140 dir_id = id
141 if dir_id:
141 if dir_id:
142 # Update tree
142 # Update tree
143 tree = self.repository._repo[dir_id]
143 tree = self.repository._repo[dir_id]
144 if not isinstance(tree, objects.Tree):
144 if not isinstance(tree, objects.Tree):
145 raise ChangesetError('%s is not a directory' % curdir)
145 raise ChangesetError('%s is not a directory' % curdir)
146 else:
146 else:
147 raise ChangesetError('%s have not been found' % curdir)
147 raise ChangesetError('%s have not been found' % curdir)
148
148
149 # cache all items from the given traversed tree
149 # cache all items from the given traversed tree
150 for item, stat, id in tree.items():
150 for item, stat, id in tree.items():
151 name = safe_str(item)
151 if curdir:
152 if curdir:
152 name = '/'.join((curdir, item))
153 name = '/'.join((curdir, name))
153 else:
154 name = item
155 self._paths[name] = id
154 self._paths[name] = id
156 self._stat_modes[name] = stat
155 self._stat_modes[name] = stat
157 if path not in self._paths:
156 if path not in self._paths:
158 raise NodeDoesNotExistError("There is no file nor directory "
157 raise NodeDoesNotExistError("There is no file nor directory "
159 "at the given path '%s' at revision %s"
158 "at the given path '%s' at revision %s"
160 % (path, self.short_id))
159 % (path, self.short_id))
161 return self._paths[path]
160 return self._paths[path]
162
161
163 def _get_kind(self, path):
162 def _get_kind(self, path):
164 obj = self.repository._repo[self._get_id_for_path(path)]
163 obj = self.repository._repo[self._get_id_for_path(path)]
165 if isinstance(obj, objects.Blob):
164 if isinstance(obj, objects.Blob):
166 return NodeKind.FILE
165 return NodeKind.FILE
167 elif isinstance(obj, objects.Tree):
166 elif isinstance(obj, objects.Tree):
168 return NodeKind.DIR
167 return NodeKind.DIR
169
168
170 def _get_filectx(self, path):
169 def _get_filectx(self, path):
171 path = self._fix_path(path)
170 path = self._fix_path(path)
172 if self._get_kind(path) != NodeKind.FILE:
171 if self._get_kind(path) != NodeKind.FILE:
173 raise ChangesetError("File does not exist for revision %s at "
172 raise ChangesetError("File does not exist for revision %s at "
174 " '%s'" % (self.raw_id, path))
173 " '%s'" % (self.raw_id, path))
175 return path
174 return path
176
175
177 def _get_file_nodes(self):
176 def _get_file_nodes(self):
178 return chain(*(t[2] for t in self.walk()))
177 return chain(*(t[2] for t in self.walk()))
179
178
180 @LazyProperty
179 @LazyProperty
181 def parents(self):
180 def parents(self):
182 """
181 """
183 Returns list of parents changesets.
182 Returns list of parents changesets.
184 """
183 """
185 return [self.repository.get_changeset(ascii_str(parent_id))
184 return [self.repository.get_changeset(ascii_str(parent_id))
186 for parent_id in self._commit.parents]
185 for parent_id in self._commit.parents]
187
186
188 @LazyProperty
187 @LazyProperty
189 def children(self):
188 def children(self):
190 """
189 """
191 Returns list of children changesets.
190 Returns list of children changesets.
192 """
191 """
193 rev_filter = settings.GIT_REV_FILTER
192 rev_filter = settings.GIT_REV_FILTER
194 so = self.repository.run_git_command(
193 so = self.repository.run_git_command(
195 ['rev-list', rev_filter, '--children']
194 ['rev-list', rev_filter, '--children']
196 )
195 )
197 return [
196 return [
198 self.repository.get_changeset(cs)
197 self.repository.get_changeset(cs)
199 for parts in (l.split(' ') for l in so.splitlines())
198 for parts in (l.split(' ') for l in so.splitlines())
200 if parts[0] == self.raw_id
199 if parts[0] == self.raw_id
201 for cs in parts[1:]
200 for cs in parts[1:]
202 ]
201 ]
203
202
204 def next(self, branch=None):
203 def next(self, branch=None):
205 if branch and self.branch != branch:
204 if branch and self.branch != branch:
206 raise VCSError('Branch option used on changeset not belonging '
205 raise VCSError('Branch option used on changeset not belonging '
207 'to that branch')
206 'to that branch')
208
207
209 cs = self
208 cs = self
210 while True:
209 while True:
211 try:
210 try:
212 next_ = cs.revision + 1
211 next_ = cs.revision + 1
213 next_rev = cs.repository.revisions[next_]
212 next_rev = cs.repository.revisions[next_]
214 except IndexError:
213 except IndexError:
215 raise ChangesetDoesNotExistError
214 raise ChangesetDoesNotExistError
216 cs = cs.repository.get_changeset(next_rev)
215 cs = cs.repository.get_changeset(next_rev)
217
216
218 if not branch or branch == cs.branch:
217 if not branch or branch == cs.branch:
219 return cs
218 return cs
220
219
221 def prev(self, branch=None):
220 def prev(self, branch=None):
222 if branch and self.branch != branch:
221 if branch and self.branch != branch:
223 raise VCSError('Branch option used on changeset not belonging '
222 raise VCSError('Branch option used on changeset not belonging '
224 'to that branch')
223 'to that branch')
225
224
226 cs = self
225 cs = self
227 while True:
226 while True:
228 try:
227 try:
229 prev_ = cs.revision - 1
228 prev_ = cs.revision - 1
230 if prev_ < 0:
229 if prev_ < 0:
231 raise IndexError
230 raise IndexError
232 prev_rev = cs.repository.revisions[prev_]
231 prev_rev = cs.repository.revisions[prev_]
233 except IndexError:
232 except IndexError:
234 raise ChangesetDoesNotExistError
233 raise ChangesetDoesNotExistError
235 cs = cs.repository.get_changeset(prev_rev)
234 cs = cs.repository.get_changeset(prev_rev)
236
235
237 if not branch or branch == cs.branch:
236 if not branch or branch == cs.branch:
238 return cs
237 return cs
239
238
240 def diff(self, ignore_whitespace=True, context=3):
239 def diff(self, ignore_whitespace=True, context=3):
241 # Only used to feed diffstat
240 # Only used to feed diffstat
242 rev1 = self.parents[0] if self.parents else self.repository.EMPTY_CHANGESET
241 rev1 = self.parents[0] if self.parents else self.repository.EMPTY_CHANGESET
243 rev2 = self
242 rev2 = self
244 return b''.join(self.repository.get_diff(rev1, rev2,
243 return b''.join(self.repository.get_diff(rev1, rev2,
245 ignore_whitespace=ignore_whitespace,
244 ignore_whitespace=ignore_whitespace,
246 context=context))
245 context=context))
247
246
248 def get_file_mode(self, path):
247 def get_file_mode(self, path):
249 """
248 """
250 Returns stat mode of the file at the given ``path``.
249 Returns stat mode of the file at the given ``path``.
251 """
250 """
252 # ensure path is traversed
251 # ensure path is traversed
253 self._get_id_for_path(path)
252 self._get_id_for_path(path)
254 return self._stat_modes[path]
253 return self._stat_modes[path]
255
254
256 def get_file_content(self, path):
255 def get_file_content(self, path):
257 """
256 """
258 Returns content of the file at given ``path``.
257 Returns content of the file at given ``path``.
259 """
258 """
260 id = self._get_id_for_path(path)
259 id = self._get_id_for_path(path)
261 blob = self.repository._repo[id]
260 blob = self.repository._repo[id]
262 return blob.as_pretty_string()
261 return blob.as_pretty_string()
263
262
264 def get_file_size(self, path):
263 def get_file_size(self, path):
265 """
264 """
266 Returns size of the file at given ``path``.
265 Returns size of the file at given ``path``.
267 """
266 """
268 id = self._get_id_for_path(path)
267 id = self._get_id_for_path(path)
269 blob = self.repository._repo[id]
268 blob = self.repository._repo[id]
270 return blob.raw_length()
269 return blob.raw_length()
271
270
272 def get_file_changeset(self, path):
271 def get_file_changeset(self, path):
273 """
272 """
274 Returns last commit of the file at the given ``path``.
273 Returns last commit of the file at the given ``path``.
275 """
274 """
276 return self.get_file_history(path, limit=1)[0]
275 return self.get_file_history(path, limit=1)[0]
277
276
278 def get_file_history(self, path, limit=None):
277 def get_file_history(self, path, limit=None):
279 """
278 """
280 Returns history of file as reversed list of ``Changeset`` objects for
279 Returns history of file as reversed list of ``Changeset`` objects for
281 which file at given ``path`` has been modified.
280 which file at given ``path`` has been modified.
282
281
283 TODO: This function now uses os underlying 'git' and 'grep' commands
282 TODO: This function now uses os underlying 'git' and 'grep' commands
284 which is generally not good. Should be replaced with algorithm
283 which is generally not good. Should be replaced with algorithm
285 iterating commits.
284 iterating commits.
286 """
285 """
287 self._get_filectx(path)
286 self._get_filectx(path)
288
287
289 if limit is not None:
288 if limit is not None:
290 cmd = ['log', '-n', str(safe_int(limit, 0)),
289 cmd = ['log', '-n', str(safe_int(limit, 0)),
291 '--pretty=format:%H', '-s', self.raw_id, '--', path]
290 '--pretty=format:%H', '-s', self.raw_id, '--', path]
292
291
293 else:
292 else:
294 cmd = ['log',
293 cmd = ['log',
295 '--pretty=format:%H', '-s', self.raw_id, '--', path]
294 '--pretty=format:%H', '-s', self.raw_id, '--', path]
296 so = self.repository.run_git_command(cmd)
295 so = self.repository.run_git_command(cmd)
297 ids = re.findall(r'[0-9a-fA-F]{40}', so)
296 ids = re.findall(r'[0-9a-fA-F]{40}', so)
298 return [self.repository.get_changeset(sha) for sha in ids]
297 return [self.repository.get_changeset(sha) for sha in ids]
299
298
300 def get_file_history_2(self, path):
299 def get_file_history_2(self, path):
301 """
300 """
302 Returns history of file as reversed list of ``Changeset`` objects for
301 Returns history of file as reversed list of ``Changeset`` objects for
303 which file at given ``path`` has been modified.
302 which file at given ``path`` has been modified.
304
303
305 """
304 """
306 self._get_filectx(path)
305 self._get_filectx(path)
307 from dulwich.walk import Walker
306 from dulwich.walk import Walker
308 include = [self.raw_id]
307 include = [self.raw_id]
309 walker = Walker(self.repository._repo.object_store, include,
308 walker = Walker(self.repository._repo.object_store, include,
310 paths=[path], max_entries=1)
309 paths=[path], max_entries=1)
311 return [self.repository.get_changeset(ascii_str(x.commit.id.decode))
310 return [self.repository.get_changeset(ascii_str(x.commit.id.decode))
312 for x in walker]
311 for x in walker]
313
312
314 def get_file_annotate(self, path):
313 def get_file_annotate(self, path):
315 """
314 """
316 Returns a generator of four element tuples with
315 Returns a generator of four element tuples with
317 lineno, sha, changeset lazy loader and line
316 lineno, sha, changeset lazy loader and line
318 """
317 """
319 # TODO: This function now uses os underlying 'git' command which is
318 # TODO: This function now uses os underlying 'git' command which is
320 # generally not good. Should be replaced with algorithm iterating
319 # generally not good. Should be replaced with algorithm iterating
321 # commits.
320 # commits.
322 cmd = ['blame', '-l', '--root', '-r', self.raw_id, '--', path]
321 cmd = ['blame', '-l', '--root', '-r', self.raw_id, '--', path]
323 # -l ==> outputs long shas (and we need all 40 characters)
322 # -l ==> outputs long shas (and we need all 40 characters)
324 # --root ==> doesn't put '^' character for boundaries
323 # --root ==> doesn't put '^' character for boundaries
325 # -r sha ==> blames for the given revision
324 # -r sha ==> blames for the given revision
326 so = self.repository.run_git_command(cmd)
325 so = self.repository.run_git_command(cmd)
327
326
328 for i, blame_line in enumerate(so.split('\n')[:-1]):
327 for i, blame_line in enumerate(so.split('\n')[:-1]):
329 sha, line = re.split(r' ', blame_line, 1)
328 sha, line = re.split(r' ', blame_line, 1)
330 yield (i + 1, sha, lambda sha=sha: self.repository.get_changeset(sha), line)
329 yield (i + 1, sha, lambda sha=sha: self.repository.get_changeset(sha), line)
331
330
332 def fill_archive(self, stream=None, kind='tgz', prefix=None,
331 def fill_archive(self, stream=None, kind='tgz', prefix=None,
333 subrepos=False):
332 subrepos=False):
334 """
333 """
335 Fills up given stream.
334 Fills up given stream.
336
335
337 :param stream: file like object.
336 :param stream: file like object.
338 :param kind: one of following: ``zip``, ``tgz`` or ``tbz2``.
337 :param kind: one of following: ``zip``, ``tgz`` or ``tbz2``.
339 Default: ``tgz``.
338 Default: ``tgz``.
340 :param prefix: name of root directory in archive.
339 :param prefix: name of root directory in archive.
341 Default is repository name and changeset's raw_id joined with dash
340 Default is repository name and changeset's raw_id joined with dash
342 (``repo-tip.<KIND>``).
341 (``repo-tip.<KIND>``).
343 :param subrepos: include subrepos in this archive.
342 :param subrepos: include subrepos in this archive.
344
343
345 :raise ImproperArchiveTypeError: If given kind is wrong.
344 :raise ImproperArchiveTypeError: If given kind is wrong.
346 :raise VcsError: If given stream is None
345 :raise VcsError: If given stream is None
347 """
346 """
348 allowed_kinds = settings.ARCHIVE_SPECS
347 allowed_kinds = settings.ARCHIVE_SPECS
349 if kind not in allowed_kinds:
348 if kind not in allowed_kinds:
350 raise ImproperArchiveTypeError('Archive kind not supported use one'
349 raise ImproperArchiveTypeError('Archive kind not supported use one'
351 'of %s' % ' '.join(allowed_kinds))
350 'of %s' % ' '.join(allowed_kinds))
352
351
353 if stream is None:
352 if stream is None:
354 raise VCSError('You need to pass in a valid stream for filling'
353 raise VCSError('You need to pass in a valid stream for filling'
355 ' with archival data')
354 ' with archival data')
356
355
357 if prefix is None:
356 if prefix is None:
358 prefix = '%s-%s' % (self.repository.name, self.short_id)
357 prefix = '%s-%s' % (self.repository.name, self.short_id)
359 elif prefix.startswith('/'):
358 elif prefix.startswith('/'):
360 raise VCSError("Prefix cannot start with leading slash")
359 raise VCSError("Prefix cannot start with leading slash")
361 elif prefix.strip() == '':
360 elif prefix.strip() == '':
362 raise VCSError("Prefix cannot be empty")
361 raise VCSError("Prefix cannot be empty")
363
362
364 if kind == 'zip':
363 if kind == 'zip':
365 frmt = 'zip'
364 frmt = 'zip'
366 else:
365 else:
367 frmt = 'tar'
366 frmt = 'tar'
368 _git_path = settings.GIT_EXECUTABLE_PATH
367 _git_path = settings.GIT_EXECUTABLE_PATH
369 cmd = '%s archive --format=%s --prefix=%s/ %s' % (_git_path,
368 cmd = '%s archive --format=%s --prefix=%s/ %s' % (_git_path,
370 frmt, prefix, self.raw_id)
369 frmt, prefix, self.raw_id)
371 if kind == 'tgz':
370 if kind == 'tgz':
372 cmd += ' | gzip -9'
371 cmd += ' | gzip -9'
373 elif kind == 'tbz2':
372 elif kind == 'tbz2':
374 cmd += ' | bzip2 -9'
373 cmd += ' | bzip2 -9'
375
374
376 if stream is None:
375 if stream is None:
377 raise VCSError('You need to pass in a valid stream for filling'
376 raise VCSError('You need to pass in a valid stream for filling'
378 ' with archival data')
377 ' with archival data')
379 popen = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=True,
378 popen = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=True,
380 cwd=self.repository.path)
379 cwd=self.repository.path)
381
380
382 buffer_size = 1024 * 8
381 buffer_size = 1024 * 8
383 chunk = popen.stdout.read(buffer_size)
382 chunk = popen.stdout.read(buffer_size)
384 while chunk:
383 while chunk:
385 stream.write(chunk)
384 stream.write(chunk)
386 chunk = popen.stdout.read(buffer_size)
385 chunk = popen.stdout.read(buffer_size)
387 # Make sure all descriptors would be read
386 # Make sure all descriptors would be read
388 popen.communicate()
387 popen.communicate()
389
388
390 def get_nodes(self, path):
389 def get_nodes(self, path):
391 """
390 """
392 Returns combined ``DirNode`` and ``FileNode`` objects list representing
391 Returns combined ``DirNode`` and ``FileNode`` objects list representing
393 state of changeset at the given ``path``. If node at the given ``path``
392 state of changeset at the given ``path``. If node at the given ``path``
394 is not instance of ``DirNode``, ChangesetError would be raised.
393 is not instance of ``DirNode``, ChangesetError would be raised.
395 """
394 """
396
395
397 if self._get_kind(path) != NodeKind.DIR:
396 if self._get_kind(path) != NodeKind.DIR:
398 raise ChangesetError("Directory does not exist for revision %s at "
397 raise ChangesetError("Directory does not exist for revision %s at "
399 " '%s'" % (self.revision, path))
398 " '%s'" % (self.revision, path))
400 path = self._fix_path(path)
399 path = self._fix_path(path)
401 id = self._get_id_for_path(path)
400 id = self._get_id_for_path(path)
402 tree = self.repository._repo[id]
401 tree = self.repository._repo[id]
403 dirnodes = []
402 dirnodes = []
404 filenodes = []
403 filenodes = []
405 als = self.repository.alias
404 als = self.repository.alias
406 for name, stat, id in tree.items():
405 for name, stat, id in tree.items():
406 obj_path = safe_str(name)
407 if path != '':
407 if path != '':
408 obj_path = '/'.join((path, name))
408 obj_path = '/'.join((path, obj_path))
409 else:
410 obj_path = name
411 if objects.S_ISGITLINK(stat):
409 if objects.S_ISGITLINK(stat):
412 root_tree = self.repository._repo[self._tree_id]
410 root_tree = self.repository._repo[self._tree_id]
413 cf = ConfigFile.from_file(BytesIO(self.repository._repo.get_object(root_tree[b'.gitmodules'][1]).data))
411 cf = ConfigFile.from_file(BytesIO(self.repository._repo.get_object(root_tree[b'.gitmodules'][1]).data))
414 url = ascii_str(cf.get(('submodule', obj_path), 'url'))
412 url = ascii_str(cf.get(('submodule', obj_path), 'url'))
415 dirnodes.append(SubModuleNode(obj_path, url=url, changeset=ascii_str(id),
413 dirnodes.append(SubModuleNode(obj_path, url=url, changeset=ascii_str(id),
416 alias=als))
414 alias=als))
417 continue
415 continue
418
416
419 obj = self.repository._repo.get_object(id)
417 obj = self.repository._repo.get_object(id)
420 if obj_path not in self._stat_modes:
418 if obj_path not in self._stat_modes:
421 self._stat_modes[obj_path] = stat
419 self._stat_modes[obj_path] = stat
422 if isinstance(obj, objects.Tree):
420 if isinstance(obj, objects.Tree):
423 dirnodes.append(DirNode(obj_path, changeset=self))
421 dirnodes.append(DirNode(obj_path, changeset=self))
424 elif isinstance(obj, objects.Blob):
422 elif isinstance(obj, objects.Blob):
425 filenodes.append(FileNode(obj_path, changeset=self, mode=stat))
423 filenodes.append(FileNode(obj_path, changeset=self, mode=stat))
426 else:
424 else:
427 raise ChangesetError("Requested object should be Tree "
425 raise ChangesetError("Requested object should be Tree "
428 "or Blob, is %r" % type(obj))
426 "or Blob, is %r" % type(obj))
429 nodes = dirnodes + filenodes
427 nodes = dirnodes + filenodes
430 for node in nodes:
428 for node in nodes:
431 if node.path not in self.nodes:
429 if node.path not in self.nodes:
432 self.nodes[node.path] = node
430 self.nodes[node.path] = node
433 nodes.sort()
431 nodes.sort()
434 return nodes
432 return nodes
435
433
436 def get_node(self, path):
434 def get_node(self, path):
437 """
435 """
438 Returns ``Node`` object from the given ``path``. If there is no node at
436 Returns ``Node`` object from the given ``path``. If there is no node at
439 the given ``path``, ``ChangesetError`` would be raised.
437 the given ``path``, ``ChangesetError`` would be raised.
440 """
438 """
441 path = self._fix_path(path)
439 path = self._fix_path(path)
442 if path not in self.nodes:
440 if path not in self.nodes:
443 try:
441 try:
444 id_ = self._get_id_for_path(path)
442 id_ = self._get_id_for_path(path)
445 except ChangesetError:
443 except ChangesetError:
446 raise NodeDoesNotExistError("Cannot find one of parents' "
444 raise NodeDoesNotExistError("Cannot find one of parents' "
447 "directories for a given path: %s" % path)
445 "directories for a given path: %s" % path)
448
446
449 _GL = lambda m: m and objects.S_ISGITLINK(m)
447 _GL = lambda m: m and objects.S_ISGITLINK(m)
450 if _GL(self._stat_modes.get(path)):
448 if _GL(self._stat_modes.get(path)):
451 tree = self.repository._repo[self._tree_id]
449 tree = self.repository._repo[self._tree_id]
452 cf = ConfigFile.from_file(BytesIO(self.repository._repo.get_object(tree[b'.gitmodules'][1]).data))
450 cf = ConfigFile.from_file(BytesIO(self.repository._repo.get_object(tree[b'.gitmodules'][1]).data))
453 url = ascii_str(cf.get(('submodule', path), 'url'))
451 url = ascii_str(cf.get(('submodule', path), 'url'))
454 node = SubModuleNode(path, url=url, changeset=ascii_str(id_),
452 node = SubModuleNode(path, url=url, changeset=ascii_str(id_),
455 alias=self.repository.alias)
453 alias=self.repository.alias)
456 else:
454 else:
457 obj = self.repository._repo.get_object(id_)
455 obj = self.repository._repo.get_object(id_)
458
456
459 if isinstance(obj, objects.Tree):
457 if isinstance(obj, objects.Tree):
460 if path == '':
458 if path == '':
461 node = RootNode(changeset=self)
459 node = RootNode(changeset=self)
462 else:
460 else:
463 node = DirNode(path, changeset=self)
461 node = DirNode(path, changeset=self)
464 node._tree = obj
462 node._tree = obj
465 elif isinstance(obj, objects.Blob):
463 elif isinstance(obj, objects.Blob):
466 node = FileNode(path, changeset=self)
464 node = FileNode(path, changeset=self)
467 node._blob = obj
465 node._blob = obj
468 else:
466 else:
469 raise NodeDoesNotExistError("There is no file nor directory "
467 raise NodeDoesNotExistError("There is no file nor directory "
470 "at the given path: '%s' at revision %s"
468 "at the given path: '%s' at revision %s"
471 % (path, self.short_id))
469 % (path, self.short_id))
472 # cache node
470 # cache node
473 self.nodes[path] = node
471 self.nodes[path] = node
474 return self.nodes[path]
472 return self.nodes[path]
475
473
476 @LazyProperty
474 @LazyProperty
477 def affected_files(self):
475 def affected_files(self):
478 """
476 """
479 Gets a fast accessible file changes for given changeset
477 Gets a fast accessible file changes for given changeset
480 """
478 """
481 added, modified, deleted = self._changes_cache
479 added, modified, deleted = self._changes_cache
482 return list(added.union(modified).union(deleted))
480 return list(added.union(modified).union(deleted))
483
481
484 @LazyProperty
482 @LazyProperty
485 def _changes_cache(self):
483 def _changes_cache(self):
486 added = set()
484 added = set()
487 modified = set()
485 modified = set()
488 deleted = set()
486 deleted = set()
489 _r = self.repository._repo
487 _r = self.repository._repo
490
488
491 parents = self.parents
489 parents = self.parents
492 if not self.parents:
490 if not self.parents:
493 parents = [EmptyChangeset()]
491 parents = [EmptyChangeset()]
494 for parent in parents:
492 for parent in parents:
495 if isinstance(parent, EmptyChangeset):
493 if isinstance(parent, EmptyChangeset):
496 oid = None
494 oid = None
497 else:
495 else:
498 oid = _r[parent._commit.id].tree
496 oid = _r[parent._commit.id].tree
499 changes = _r.object_store.tree_changes(oid, _r[self._commit.id].tree)
497 changes = _r.object_store.tree_changes(oid, _r[self._commit.id].tree)
500 for (oldpath, newpath), (_, _), (_, _) in changes:
498 for (oldpath, newpath), (_, _), (_, _) in changes:
501 if newpath and oldpath:
499 if newpath and oldpath:
502 modified.add(newpath)
500 modified.add(safe_str(newpath))
503 elif newpath and not oldpath:
501 elif newpath and not oldpath:
504 added.add(newpath)
502 added.add(safe_str(newpath))
505 elif not newpath and oldpath:
503 elif not newpath and oldpath:
506 deleted.add(oldpath)
504 deleted.add(safe_str(oldpath))
507 return added, modified, deleted
505 return added, modified, deleted
508
506
509 def _get_paths_for_status(self, status):
507 def _get_paths_for_status(self, status):
510 """
508 """
511 Returns sorted list of paths for given ``status``.
509 Returns sorted list of paths for given ``status``.
512
510
513 :param status: one of: *added*, *modified* or *deleted*
511 :param status: one of: *added*, *modified* or *deleted*
514 """
512 """
515 added, modified, deleted = self._changes_cache
513 added, modified, deleted = self._changes_cache
516 return sorted({
514 return sorted({
517 'added': list(added),
515 'added': list(added),
518 'modified': list(modified),
516 'modified': list(modified),
519 'deleted': list(deleted)}[status]
517 'deleted': list(deleted)}[status]
520 )
518 )
521
519
522 @LazyProperty
520 @LazyProperty
523 def added(self):
521 def added(self):
524 """
522 """
525 Returns list of added ``FileNode`` objects.
523 Returns list of added ``FileNode`` objects.
526 """
524 """
527 if not self.parents:
525 if not self.parents:
528 return list(self._get_file_nodes())
526 return list(self._get_file_nodes())
529 return AddedFileNodesGenerator([n for n in
527 return AddedFileNodesGenerator([n for n in
530 self._get_paths_for_status('added')], self)
528 self._get_paths_for_status('added')], self)
531
529
532 @LazyProperty
530 @LazyProperty
533 def changed(self):
531 def changed(self):
534 """
532 """
535 Returns list of modified ``FileNode`` objects.
533 Returns list of modified ``FileNode`` objects.
536 """
534 """
537 if not self.parents:
535 if not self.parents:
538 return []
536 return []
539 return ChangedFileNodesGenerator([n for n in
537 return ChangedFileNodesGenerator([n for n in
540 self._get_paths_for_status('modified')], self)
538 self._get_paths_for_status('modified')], self)
541
539
542 @LazyProperty
540 @LazyProperty
543 def removed(self):
541 def removed(self):
544 """
542 """
545 Returns list of removed ``FileNode`` objects.
543 Returns list of removed ``FileNode`` objects.
546 """
544 """
547 if not self.parents:
545 if not self.parents:
548 return []
546 return []
549 return RemovedFileNodesGenerator([n for n in
547 return RemovedFileNodesGenerator([n for n in
550 self._get_paths_for_status('deleted')], self)
548 self._get_paths_for_status('deleted')], self)
551
549
552 extra = {}
550 extra = {}
@@ -1,736 +1,737 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 """
2 """
3 vcs.backends.git.repository
3 vcs.backends.git.repository
4 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
4 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
5
5
6 Git repository implementation.
6 Git repository implementation.
7
7
8 :created_on: Apr 8, 2010
8 :created_on: Apr 8, 2010
9 :copyright: (c) 2010-2011 by Marcin Kuzminski, Lukasz Balcerzak.
9 :copyright: (c) 2010-2011 by Marcin Kuzminski, Lukasz Balcerzak.
10 """
10 """
11
11
12 import errno
12 import errno
13 import logging
13 import logging
14 import os
14 import os
15 import re
15 import re
16 import time
16 import time
17 import urllib.error
17 import urllib.error
18 import urllib.parse
18 import urllib.parse
19 import urllib.request
19 import urllib.request
20 from collections import OrderedDict
20 from collections import OrderedDict
21
21
22 import mercurial.url # import httpbasicauthhandler, httpdigestauthhandler
22 import mercurial.url # import httpbasicauthhandler, httpdigestauthhandler
23 import mercurial.util # import url as hg_url
23 import mercurial.util # import url as hg_url
24 from dulwich.config import ConfigFile
24 from dulwich.config import ConfigFile
25 from dulwich.objects import Tag
25 from dulwich.objects import Tag
26 from dulwich.repo import NotGitRepository, Repo
26 from dulwich.repo import NotGitRepository, Repo
27
27
28 from kallithea.lib.vcs import subprocessio
28 from kallithea.lib.vcs import subprocessio
29 from kallithea.lib.vcs.backends.base import BaseRepository, CollectionGenerator
29 from kallithea.lib.vcs.backends.base import BaseRepository, CollectionGenerator
30 from kallithea.lib.vcs.conf import settings
30 from kallithea.lib.vcs.conf import settings
31 from kallithea.lib.vcs.exceptions import (
31 from kallithea.lib.vcs.exceptions import (
32 BranchDoesNotExistError, ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError, TagAlreadyExistError, TagDoesNotExistError)
32 BranchDoesNotExistError, ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError, TagAlreadyExistError, TagDoesNotExistError)
33 from kallithea.lib.vcs.utils import ascii_str, date_fromtimestamp, makedate, safe_bytes, safe_str
33 from kallithea.lib.vcs.utils import ascii_str, date_fromtimestamp, makedate, safe_bytes, safe_str
34 from kallithea.lib.vcs.utils.lazy import LazyProperty
34 from kallithea.lib.vcs.utils.lazy import LazyProperty
35 from kallithea.lib.vcs.utils.paths import abspath, get_user_home
35 from kallithea.lib.vcs.utils.paths import abspath, get_user_home
36
36
37 from .changeset import GitChangeset
37 from .changeset import GitChangeset
38 from .inmemory import GitInMemoryChangeset
38 from .inmemory import GitInMemoryChangeset
39 from .workdir import GitWorkdir
39 from .workdir import GitWorkdir
40
40
41
41
42 SHA_PATTERN = re.compile(r'^([0-9a-fA-F]{12}|[0-9a-fA-F]{40})$')
42 SHA_PATTERN = re.compile(r'^([0-9a-fA-F]{12}|[0-9a-fA-F]{40})$')
43
43
44 log = logging.getLogger(__name__)
44 log = logging.getLogger(__name__)
45
45
46
46
47 class GitRepository(BaseRepository):
47 class GitRepository(BaseRepository):
48 """
48 """
49 Git repository backend.
49 Git repository backend.
50 """
50 """
51 DEFAULT_BRANCH_NAME = 'master'
51 DEFAULT_BRANCH_NAME = 'master'
52 scm = 'git'
52 scm = 'git'
53
53
54 def __init__(self, repo_path, create=False, src_url=None,
54 def __init__(self, repo_path, create=False, src_url=None,
55 update_after_clone=False, bare=False):
55 update_after_clone=False, bare=False):
56
56
57 self.path = abspath(repo_path)
57 self.path = abspath(repo_path)
58 self.repo = self._get_repo(create, src_url, update_after_clone, bare)
58 self.repo = self._get_repo(create, src_url, update_after_clone, bare)
59 self.bare = self.repo.bare
59 self.bare = self.repo.bare
60
60
61 @property
61 @property
62 def _config_files(self):
62 def _config_files(self):
63 return [
63 return [
64 self.bare and abspath(self.path, 'config')
64 self.bare and abspath(self.path, 'config')
65 or abspath(self.path, '.git', 'config'),
65 or abspath(self.path, '.git', 'config'),
66 abspath(get_user_home(), '.gitconfig'),
66 abspath(get_user_home(), '.gitconfig'),
67 ]
67 ]
68
68
69 @property
69 @property
70 def _repo(self):
70 def _repo(self):
71 return self.repo
71 return self.repo
72
72
73 @property
73 @property
74 def head(self):
74 def head(self):
75 try:
75 try:
76 return self._repo.head()
76 return self._repo.head()
77 except KeyError:
77 except KeyError:
78 return None
78 return None
79
79
80 @property
80 @property
81 def _empty(self):
81 def _empty(self):
82 """
82 """
83 Checks if repository is empty ie. without any changesets
83 Checks if repository is empty ie. without any changesets
84 """
84 """
85
85
86 try:
86 try:
87 self.revisions[0]
87 self.revisions[0]
88 except (KeyError, IndexError):
88 except (KeyError, IndexError):
89 return True
89 return True
90 return False
90 return False
91
91
92 @LazyProperty
92 @LazyProperty
93 def revisions(self):
93 def revisions(self):
94 """
94 """
95 Returns list of revisions' ids, in ascending order. Being lazy
95 Returns list of revisions' ids, in ascending order. Being lazy
96 attribute allows external tools to inject shas from cache.
96 attribute allows external tools to inject shas from cache.
97 """
97 """
98 return self._get_all_revisions()
98 return self._get_all_revisions()
99
99
100 @classmethod
100 @classmethod
101 def _run_git_command(cls, cmd, cwd=None):
101 def _run_git_command(cls, cmd, cwd=None):
102 """
102 """
103 Runs given ``cmd`` as git command and returns output bytes in a tuple
103 Runs given ``cmd`` as git command and returns output bytes in a tuple
104 (stdout, stderr) ... or raise RepositoryError.
104 (stdout, stderr) ... or raise RepositoryError.
105
105
106 :param cmd: git command to be executed
106 :param cmd: git command to be executed
107 :param cwd: passed directly to subprocess
107 :param cwd: passed directly to subprocess
108 """
108 """
109 # need to clean fix GIT_DIR !
109 # need to clean fix GIT_DIR !
110 gitenv = dict(os.environ)
110 gitenv = dict(os.environ)
111 gitenv.pop('GIT_DIR', None)
111 gitenv.pop('GIT_DIR', None)
112 gitenv['GIT_CONFIG_NOGLOBAL'] = '1'
112 gitenv['GIT_CONFIG_NOGLOBAL'] = '1'
113
113
114 assert isinstance(cmd, list), cmd
114 assert isinstance(cmd, list), cmd
115 cmd = [settings.GIT_EXECUTABLE_PATH, '-c', 'core.quotepath=false'] + cmd
115 cmd = [settings.GIT_EXECUTABLE_PATH, '-c', 'core.quotepath=false'] + cmd
116 try:
116 try:
117 p = subprocessio.SubprocessIOChunker(cmd, cwd=cwd, env=gitenv, shell=False)
117 p = subprocessio.SubprocessIOChunker(cmd, cwd=cwd, env=gitenv, shell=False)
118 except (EnvironmentError, OSError) as err:
118 except (EnvironmentError, OSError) as err:
119 # output from the failing process is in str(EnvironmentError)
119 # output from the failing process is in str(EnvironmentError)
120 msg = ("Couldn't run git command %s.\n"
120 msg = ("Couldn't run git command %s.\n"
121 "Subprocess failed with '%s': %s\n" %
121 "Subprocess failed with '%s': %s\n" %
122 (cmd, type(err).__name__, err)
122 (cmd, type(err).__name__, err)
123 ).strip()
123 ).strip()
124 log.error(msg)
124 log.error(msg)
125 raise RepositoryError(msg)
125 raise RepositoryError(msg)
126
126
127 try:
127 try:
128 stdout = b''.join(p.output)
128 stdout = b''.join(p.output)
129 stderr = b''.join(p.error)
129 stderr = b''.join(p.error)
130 finally:
130 finally:
131 p.close()
131 p.close()
132 # TODO: introduce option to make commands fail if they have any stderr output?
132 # TODO: introduce option to make commands fail if they have any stderr output?
133 if stderr:
133 if stderr:
134 log.debug('stderr from %s:\n%s', cmd, stderr)
134 log.debug('stderr from %s:\n%s', cmd, stderr)
135 else:
135 else:
136 log.debug('stderr from %s: None', cmd)
136 log.debug('stderr from %s: None', cmd)
137 return stdout, stderr
137 return stdout, stderr
138
138
139 def run_git_command(self, cmd):
139 def run_git_command(self, cmd):
140 """
140 """
141 Runs given ``cmd`` as git command with cwd set to current repo.
141 Runs given ``cmd`` as git command with cwd set to current repo.
142 Returns stdout as unicode str ... or raise RepositoryError.
142 Returns stdout as unicode str ... or raise RepositoryError.
143 """
143 """
144 cwd = None
144 cwd = None
145 if os.path.isdir(self.path):
145 if os.path.isdir(self.path):
146 cwd = self.path
146 cwd = self.path
147 stdout, _stderr = self._run_git_command(cmd, cwd=cwd)
147 stdout, _stderr = self._run_git_command(cmd, cwd=cwd)
148 return safe_str(stdout)
148 return safe_str(stdout)
149
149
150 @classmethod
150 @classmethod
151 def _check_url(cls, url):
151 def _check_url(cls, url):
152 """
152 """
153 Function will check given url and try to verify if it's a valid
153 Function will check given url and try to verify if it's a valid
154 link. Sometimes it may happened that git will issue basic
154 link. Sometimes it may happened that git will issue basic
155 auth request that can cause whole API to hang when used from python
155 auth request that can cause whole API to hang when used from python
156 or other external calls.
156 or other external calls.
157
157
158 On failures it'll raise urllib2.HTTPError, exception is also thrown
158 On failures it'll raise urllib2.HTTPError, exception is also thrown
159 when the return code is non 200
159 when the return code is non 200
160 """
160 """
161 # check first if it's not an local url
161 # check first if it's not an local url
162 if os.path.isdir(url) or url.startswith('file:'):
162 if os.path.isdir(url) or url.startswith('file:'):
163 return True
163 return True
164
164
165 if url.startswith('git://'):
165 if url.startswith('git://'):
166 return True
166 return True
167
167
168 if '+' in url[:url.find('://')]:
168 if '+' in url[:url.find('://')]:
169 url = url[url.find('+') + 1:]
169 url = url[url.find('+') + 1:]
170
170
171 handlers = []
171 handlers = []
172 url_obj = mercurial.util.url(safe_bytes(url))
172 url_obj = mercurial.util.url(safe_bytes(url))
173 test_uri, authinfo = url_obj.authinfo()
173 test_uri, authinfo = url_obj.authinfo()
174 if not test_uri.endswith('info/refs'):
174 if not test_uri.endswith('info/refs'):
175 test_uri = test_uri.rstrip('/') + '/info/refs'
175 test_uri = test_uri.rstrip('/') + '/info/refs'
176
176
177 url_obj.passwd = b'*****'
177 url_obj.passwd = b'*****'
178 cleaned_uri = str(url_obj)
178 cleaned_uri = str(url_obj)
179
179
180 if authinfo:
180 if authinfo:
181 # create a password manager
181 # create a password manager
182 passmgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
182 passmgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
183 passmgr.add_password(*authinfo)
183 passmgr.add_password(*authinfo)
184
184
185 handlers.extend((mercurial.url.httpbasicauthhandler(passmgr),
185 handlers.extend((mercurial.url.httpbasicauthhandler(passmgr),
186 mercurial.url.httpdigestauthhandler(passmgr)))
186 mercurial.url.httpdigestauthhandler(passmgr)))
187
187
188 o = urllib.request.build_opener(*handlers)
188 o = urllib.request.build_opener(*handlers)
189 o.addheaders = [('User-Agent', 'git/1.7.8.0')] # fake some git
189 o.addheaders = [('User-Agent', 'git/1.7.8.0')] # fake some git
190
190
191 req = urllib.request.Request(
191 req = urllib.request.Request(
192 "%s?%s" % (
192 "%s?%s" % (
193 test_uri,
193 test_uri,
194 urllib.parse.urlencode({"service": 'git-upload-pack'})
194 urllib.parse.urlencode({"service": 'git-upload-pack'})
195 ))
195 ))
196
196
197 try:
197 try:
198 resp = o.open(req)
198 resp = o.open(req)
199 if resp.code != 200:
199 if resp.code != 200:
200 raise Exception('Return Code is not 200')
200 raise Exception('Return Code is not 200')
201 except Exception as e:
201 except Exception as e:
202 # means it cannot be cloned
202 # means it cannot be cloned
203 raise urllib.error.URLError("[%s] org_exc: %s" % (cleaned_uri, e))
203 raise urllib.error.URLError("[%s] org_exc: %s" % (cleaned_uri, e))
204
204
205 # now detect if it's proper git repo
205 # now detect if it's proper git repo
206 gitdata = resp.read()
206 gitdata = resp.read()
207 if 'service=git-upload-pack' not in gitdata:
207 if 'service=git-upload-pack' not in gitdata:
208 raise urllib.error.URLError(
208 raise urllib.error.URLError(
209 "url [%s] does not look like an git" % cleaned_uri)
209 "url [%s] does not look like an git" % cleaned_uri)
210
210
211 return True
211 return True
212
212
213 def _get_repo(self, create, src_url=None, update_after_clone=False,
213 def _get_repo(self, create, src_url=None, update_after_clone=False,
214 bare=False):
214 bare=False):
215 if create and os.path.exists(self.path):
215 if create and os.path.exists(self.path):
216 raise RepositoryError("Location already exist")
216 raise RepositoryError("Location already exist")
217 if src_url and not create:
217 if src_url and not create:
218 raise RepositoryError("Create should be set to True if src_url is "
218 raise RepositoryError("Create should be set to True if src_url is "
219 "given (clone operation creates repository)")
219 "given (clone operation creates repository)")
220 try:
220 try:
221 if create and src_url:
221 if create and src_url:
222 GitRepository._check_url(src_url)
222 GitRepository._check_url(src_url)
223 self.clone(src_url, update_after_clone, bare)
223 self.clone(src_url, update_after_clone, bare)
224 return Repo(self.path)
224 return Repo(self.path)
225 elif create:
225 elif create:
226 os.makedirs(self.path)
226 os.makedirs(self.path)
227 if bare:
227 if bare:
228 return Repo.init_bare(self.path)
228 return Repo.init_bare(self.path)
229 else:
229 else:
230 return Repo.init(self.path)
230 return Repo.init(self.path)
231 else:
231 else:
232 return Repo(self.path)
232 return Repo(self.path)
233 except (NotGitRepository, OSError) as err:
233 except (NotGitRepository, OSError) as err:
234 raise RepositoryError(err)
234 raise RepositoryError(err)
235
235
236 def _get_all_revisions(self):
236 def _get_all_revisions(self):
237 # we must check if this repo is not empty, since later command
237 # we must check if this repo is not empty, since later command
238 # fails if it is. And it's cheaper to ask than throw the subprocess
238 # fails if it is. And it's cheaper to ask than throw the subprocess
239 # errors
239 # errors
240 try:
240 try:
241 self._repo.head()
241 self._repo.head()
242 except KeyError:
242 except KeyError:
243 return []
243 return []
244
244
245 rev_filter = settings.GIT_REV_FILTER
245 rev_filter = settings.GIT_REV_FILTER
246 cmd = ['rev-list', rev_filter, '--reverse', '--date-order']
246 cmd = ['rev-list', rev_filter, '--reverse', '--date-order']
247 try:
247 try:
248 so = self.run_git_command(cmd)
248 so = self.run_git_command(cmd)
249 except RepositoryError:
249 except RepositoryError:
250 # Can be raised for empty repositories
250 # Can be raised for empty repositories
251 return []
251 return []
252 return so.splitlines()
252 return so.splitlines()
253
253
254 def _get_all_revisions2(self):
254 def _get_all_revisions2(self):
255 # alternate implementation using dulwich
255 # alternate implementation using dulwich
256 includes = [ascii_str(sha) for key, (sha, type_) in self._parsed_refs.items()
256 includes = [ascii_str(sha) for key, (sha, type_) in self._parsed_refs.items()
257 if type_ != b'T']
257 if type_ != b'T']
258 return [c.commit.id for c in self._repo.get_walker(include=includes)]
258 return [c.commit.id for c in self._repo.get_walker(include=includes)]
259
259
260 def _get_revision(self, revision):
260 def _get_revision(self, revision):
261 """
261 """
262 Given any revision identifier, returns a 40 char string with revision hash.
262 Given any revision identifier, returns a 40 char string with revision hash.
263 """
263 """
264 if self._empty:
264 if self._empty:
265 raise EmptyRepositoryError("There are no changesets yet")
265 raise EmptyRepositoryError("There are no changesets yet")
266
266
267 if revision in (None, '', 'tip', 'HEAD', 'head', -1):
267 if revision in (None, '', 'tip', 'HEAD', 'head', -1):
268 revision = -1
268 revision = -1
269
269
270 if isinstance(revision, int):
270 if isinstance(revision, int):
271 try:
271 try:
272 return self.revisions[revision]
272 return self.revisions[revision]
273 except IndexError:
273 except IndexError:
274 msg = "Revision %r does not exist for %s" % (revision, self.name)
274 msg = "Revision %r does not exist for %s" % (revision, self.name)
275 raise ChangesetDoesNotExistError(msg)
275 raise ChangesetDoesNotExistError(msg)
276
276
277 if isinstance(revision, (str, unicode)):
277 if isinstance(revision, (str, unicode)):
278 if revision.isdigit() and (len(revision) < 12 or len(revision) == revision.count('0')):
278 if revision.isdigit() and (len(revision) < 12 or len(revision) == revision.count('0')):
279 try:
279 try:
280 return self.revisions[int(revision)]
280 return self.revisions[int(revision)]
281 except IndexError:
281 except IndexError:
282 msg = "Revision %r does not exist for %s" % (revision, self)
282 msg = "Revision %r does not exist for %s" % (revision, self)
283 raise ChangesetDoesNotExistError(msg)
283 raise ChangesetDoesNotExistError(msg)
284
284
285 # get by branch/tag name
285 # get by branch/tag name
286 _ref_revision = self._parsed_refs.get(safe_bytes(revision))
286 _ref_revision = self._parsed_refs.get(safe_bytes(revision))
287 if _ref_revision: # and _ref_revision[1] in [b'H', b'RH', b'T']:
287 if _ref_revision: # and _ref_revision[1] in [b'H', b'RH', b'T']:
288 return ascii_str(_ref_revision[0])
288 return ascii_str(_ref_revision[0])
289
289
290 if revision in self.revisions:
290 if revision in self.revisions:
291 return revision
291 return revision
292
292
293 # maybe it's a tag ? we don't have them in self.revisions
293 # maybe it's a tag ? we don't have them in self.revisions
294 if revision in self.tags.values():
294 if revision in self.tags.values():
295 return revision
295 return revision
296
296
297 if SHA_PATTERN.match(revision):
297 if SHA_PATTERN.match(revision):
298 msg = "Revision %r does not exist for %s" % (revision, self.name)
298 msg = "Revision %r does not exist for %s" % (revision, self.name)
299 raise ChangesetDoesNotExistError(msg)
299 raise ChangesetDoesNotExistError(msg)
300
300
301 raise ChangesetDoesNotExistError("Given revision %r not recognized" % revision)
301 raise ChangesetDoesNotExistError("Given revision %r not recognized" % revision)
302
302
303 def get_ref_revision(self, ref_type, ref_name):
303 def get_ref_revision(self, ref_type, ref_name):
304 """
304 """
305 Returns ``GitChangeset`` object representing repository's
305 Returns ``GitChangeset`` object representing repository's
306 changeset at the given ``revision``.
306 changeset at the given ``revision``.
307 """
307 """
308 return self._get_revision(ref_name)
308 return self._get_revision(ref_name)
309
309
310 def _get_archives(self, archive_name='tip'):
310 def _get_archives(self, archive_name='tip'):
311
311
312 for i in [('zip', '.zip'), ('gz', '.tar.gz'), ('bz2', '.tar.bz2')]:
312 for i in [('zip', '.zip'), ('gz', '.tar.gz'), ('bz2', '.tar.bz2')]:
313 yield {"type": i[0], "extension": i[1], "node": archive_name}
313 yield {"type": i[0], "extension": i[1], "node": archive_name}
314
314
315 def _get_url(self, url):
315 def _get_url(self, url):
316 """
316 """
317 Returns normalized url. If schema is not given, would fall to
317 Returns normalized url. If schema is not given, would fall to
318 filesystem (``file:///``) schema.
318 filesystem (``file:///``) schema.
319 """
319 """
320 if url != 'default' and '://' not in url:
320 if url != 'default' and '://' not in url:
321 url = ':///'.join(('file', url))
321 url = ':///'.join(('file', url))
322 return url
322 return url
323
323
324 @LazyProperty
324 @LazyProperty
325 def name(self):
325 def name(self):
326 return os.path.basename(self.path)
326 return os.path.basename(self.path)
327
327
328 @LazyProperty
328 @LazyProperty
329 def last_change(self):
329 def last_change(self):
330 """
330 """
331 Returns last change made on this repository as datetime object
331 Returns last change made on this repository as datetime object
332 """
332 """
333 return date_fromtimestamp(self._get_mtime(), makedate()[1])
333 return date_fromtimestamp(self._get_mtime(), makedate()[1])
334
334
335 def _get_mtime(self):
335 def _get_mtime(self):
336 try:
336 try:
337 return time.mktime(self.get_changeset().date.timetuple())
337 return time.mktime(self.get_changeset().date.timetuple())
338 except RepositoryError:
338 except RepositoryError:
339 idx_loc = '' if self.bare else '.git'
339 idx_loc = '' if self.bare else '.git'
340 # fallback to filesystem
340 # fallback to filesystem
341 in_path = os.path.join(self.path, idx_loc, "index")
341 in_path = os.path.join(self.path, idx_loc, "index")
342 he_path = os.path.join(self.path, idx_loc, "HEAD")
342 he_path = os.path.join(self.path, idx_loc, "HEAD")
343 if os.path.exists(in_path):
343 if os.path.exists(in_path):
344 return os.stat(in_path).st_mtime
344 return os.stat(in_path).st_mtime
345 else:
345 else:
346 return os.stat(he_path).st_mtime
346 return os.stat(he_path).st_mtime
347
347
348 @LazyProperty
348 @LazyProperty
349 def description(self):
349 def description(self):
350 return safe_str(self._repo.get_description() or b'unknown')
350 return safe_str(self._repo.get_description() or b'unknown')
351
351
352 @LazyProperty
352 @LazyProperty
353 def contact(self):
353 def contact(self):
354 undefined_contact = u'Unknown'
354 undefined_contact = u'Unknown'
355 return undefined_contact
355 return undefined_contact
356
356
357 @property
357 @property
358 def branches(self):
358 def branches(self):
359 if not self.revisions:
359 if not self.revisions:
360 return {}
360 return {}
361 sortkey = lambda ctx: ctx[0]
361 sortkey = lambda ctx: ctx[0]
362 _branches = [(key, ascii_str(sha))
362 _branches = [(safe_str(key), ascii_str(sha))
363 for key, (sha, type_) in self._parsed_refs.items() if type_ == b'H']
363 for key, (sha, type_) in self._parsed_refs.items() if type_ == b'H']
364 return OrderedDict(sorted(_branches, key=sortkey, reverse=False))
364 return OrderedDict(sorted(_branches, key=sortkey, reverse=False))
365
365
366 @LazyProperty
366 @LazyProperty
367 def closed_branches(self):
367 def closed_branches(self):
368 return {}
368 return {}
369
369
370 @LazyProperty
370 @LazyProperty
371 def tags(self):
371 def tags(self):
372 return self._get_tags()
372 return self._get_tags()
373
373
374 def _get_tags(self):
374 def _get_tags(self):
375 if not self.revisions:
375 if not self.revisions:
376 return {}
376 return {}
377
377
378 sortkey = lambda ctx: ctx[0]
378 sortkey = lambda ctx: ctx[0]
379 _tags = [(key, ascii_str(sha))
379 _tags = [(safe_str(key), ascii_str(sha))
380 for key, (sha, type_) in self._parsed_refs.items() if type_ == b'T']
380 for key, (sha, type_) in self._parsed_refs.items() if type_ == b'T']
381 return OrderedDict(sorted(_tags, key=sortkey, reverse=True))
381 return OrderedDict(sorted(_tags, key=sortkey, reverse=True))
382
382
383 def tag(self, name, user, revision=None, message=None, date=None,
383 def tag(self, name, user, revision=None, message=None, date=None,
384 **kwargs):
384 **kwargs):
385 """
385 """
386 Creates and returns a tag for the given ``revision``.
386 Creates and returns a tag for the given ``revision``.
387
387
388 :param name: name for new tag
388 :param name: name for new tag
389 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
389 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
390 :param revision: changeset id for which new tag would be created
390 :param revision: changeset id for which new tag would be created
391 :param message: message of the tag's commit
391 :param message: message of the tag's commit
392 :param date: date of tag's commit
392 :param date: date of tag's commit
393
393
394 :raises TagAlreadyExistError: if tag with same name already exists
394 :raises TagAlreadyExistError: if tag with same name already exists
395 """
395 """
396 if name in self.tags:
396 if name in self.tags:
397 raise TagAlreadyExistError("Tag %s already exists" % name)
397 raise TagAlreadyExistError("Tag %s already exists" % name)
398 changeset = self.get_changeset(revision)
398 changeset = self.get_changeset(revision)
399 message = message or "Added tag %s for commit %s" % (name,
399 message = message or "Added tag %s for commit %s" % (name,
400 changeset.raw_id)
400 changeset.raw_id)
401 self._repo.refs[b"refs/tags/%s" % safe_bytes(name)] = changeset._commit.id
401 self._repo.refs[b"refs/tags/%s" % safe_bytes(name)] = changeset._commit.id
402
402
403 self._parsed_refs = self._get_parsed_refs()
403 self._parsed_refs = self._get_parsed_refs()
404 self.tags = self._get_tags()
404 self.tags = self._get_tags()
405 return changeset
405 return changeset
406
406
407 def remove_tag(self, name, user, message=None, date=None):
407 def remove_tag(self, name, user, message=None, date=None):
408 """
408 """
409 Removes tag with the given ``name``.
409 Removes tag with the given ``name``.
410
410
411 :param name: name of the tag to be removed
411 :param name: name of the tag to be removed
412 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
412 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
413 :param message: message of the tag's removal commit
413 :param message: message of the tag's removal commit
414 :param date: date of tag's removal commit
414 :param date: date of tag's removal commit
415
415
416 :raises TagDoesNotExistError: if tag with given name does not exists
416 :raises TagDoesNotExistError: if tag with given name does not exists
417 """
417 """
418 if name not in self.tags:
418 if name not in self.tags:
419 raise TagDoesNotExistError("Tag %s does not exist" % name)
419 raise TagDoesNotExistError("Tag %s does not exist" % name)
420 # self._repo.refs is a DiskRefsContainer, and .path gives the full absolute path of '.git'
420 # self._repo.refs is a DiskRefsContainer, and .path gives the full absolute path of '.git'
421 tagpath = os.path.join(self._repo.refs.path, 'refs', 'tags', name)
421 tagpath = os.path.join(safe_str(self._repo.refs.path), 'refs', 'tags', name)
422 try:
422 try:
423 os.remove(tagpath)
423 os.remove(tagpath)
424 self._parsed_refs = self._get_parsed_refs()
424 self._parsed_refs = self._get_parsed_refs()
425 self.tags = self._get_tags()
425 self.tags = self._get_tags()
426 except OSError as e:
426 except OSError as e:
427 raise RepositoryError(e.strerror)
427 raise RepositoryError(e.strerror)
428
428
429 @LazyProperty
429 @LazyProperty
430 def bookmarks(self):
430 def bookmarks(self):
431 """
431 """
432 Gets bookmarks for this repository
432 Gets bookmarks for this repository
433 """
433 """
434 return {}
434 return {}
435
435
436 @LazyProperty
436 @LazyProperty
437 def _parsed_refs(self):
437 def _parsed_refs(self):
438 return self._get_parsed_refs()
438 return self._get_parsed_refs()
439
439
440 def _get_parsed_refs(self):
440 def _get_parsed_refs(self):
441 """Return refs as a dict, like:
441 """Return refs as a dict, like:
442 { b'v0.2.0': [b'599ba911aa24d2981225f3966eb659dfae9e9f30', b'T'] }
442 { b'v0.2.0': [b'599ba911aa24d2981225f3966eb659dfae9e9f30', b'T'] }
443 """
443 """
444 _repo = self._repo
444 _repo = self._repo
445 refs = _repo.get_refs()
445 refs = _repo.get_refs()
446 keys = [(b'refs/heads/', b'H'),
446 keys = [(b'refs/heads/', b'H'),
447 (b'refs/remotes/origin/', b'RH'),
447 (b'refs/remotes/origin/', b'RH'),
448 (b'refs/tags/', b'T')]
448 (b'refs/tags/', b'T')]
449 _refs = {}
449 _refs = {}
450 for ref, sha in refs.items():
450 for ref, sha in refs.items():
451 for k, type_ in keys:
451 for k, type_ in keys:
452 if ref.startswith(k):
452 if ref.startswith(k):
453 _key = ref[len(k):]
453 _key = ref[len(k):]
454 if type_ == b'T':
454 if type_ == b'T':
455 obj = _repo.get_object(sha)
455 obj = _repo.get_object(sha)
456 if isinstance(obj, Tag):
456 if isinstance(obj, Tag):
457 sha = _repo.get_object(sha).object[1]
457 sha = _repo.get_object(sha).object[1]
458 _refs[_key] = [sha, type_]
458 _refs[_key] = [sha, type_]
459 break
459 break
460 return _refs
460 return _refs
461
461
462 def _heads(self, reverse=False):
462 def _heads(self, reverse=False):
463 refs = self._repo.get_refs()
463 refs = self._repo.get_refs()
464 heads = {}
464 heads = {}
465
465
466 for key, val in refs.items():
466 for key, val in refs.items():
467 for ref_key in [b'refs/heads/', b'refs/remotes/origin/']:
467 for ref_key in [b'refs/heads/', b'refs/remotes/origin/']:
468 if key.startswith(ref_key):
468 if key.startswith(ref_key):
469 n = key[len(ref_key):]
469 n = key[len(ref_key):]
470 if n not in [b'HEAD']:
470 if n not in [b'HEAD']:
471 heads[n] = val
471 heads[n] = val
472
472
473 return heads if reverse else dict((y, x) for x, y in heads.items())
473 return heads if reverse else dict((y, x) for x, y in heads.items())
474
474
475 def get_changeset(self, revision=None):
475 def get_changeset(self, revision=None):
476 """
476 """
477 Returns ``GitChangeset`` object representing commit from git repository
477 Returns ``GitChangeset`` object representing commit from git repository
478 at the given revision or head (most recent commit) if None given.
478 at the given revision or head (most recent commit) if None given.
479 """
479 """
480 if isinstance(revision, GitChangeset):
480 if isinstance(revision, GitChangeset):
481 return revision
481 return revision
482 return GitChangeset(repository=self, revision=self._get_revision(revision))
482 return GitChangeset(repository=self, revision=self._get_revision(revision))
483
483
484 def get_changesets(self, start=None, end=None, start_date=None,
484 def get_changesets(self, start=None, end=None, start_date=None,
485 end_date=None, branch_name=None, reverse=False, max_revisions=None):
485 end_date=None, branch_name=None, reverse=False, max_revisions=None):
486 """
486 """
487 Returns iterator of ``GitChangeset`` objects from start to end (both
487 Returns iterator of ``GitChangeset`` objects from start to end (both
488 are inclusive), in ascending date order (unless ``reverse`` is set).
488 are inclusive), in ascending date order (unless ``reverse`` is set).
489
489
490 :param start: changeset ID, as str; first returned changeset
490 :param start: changeset ID, as str; first returned changeset
491 :param end: changeset ID, as str; last returned changeset
491 :param end: changeset ID, as str; last returned changeset
492 :param start_date: if specified, changesets with commit date less than
492 :param start_date: if specified, changesets with commit date less than
493 ``start_date`` would be filtered out from returned set
493 ``start_date`` would be filtered out from returned set
494 :param end_date: if specified, changesets with commit date greater than
494 :param end_date: if specified, changesets with commit date greater than
495 ``end_date`` would be filtered out from returned set
495 ``end_date`` would be filtered out from returned set
496 :param branch_name: if specified, changesets not reachable from given
496 :param branch_name: if specified, changesets not reachable from given
497 branch would be filtered out from returned set
497 branch would be filtered out from returned set
498 :param reverse: if ``True``, returned generator would be reversed
498 :param reverse: if ``True``, returned generator would be reversed
499 (meaning that returned changesets would have descending date order)
499 (meaning that returned changesets would have descending date order)
500
500
501 :raise BranchDoesNotExistError: If given ``branch_name`` does not
501 :raise BranchDoesNotExistError: If given ``branch_name`` does not
502 exist.
502 exist.
503 :raise ChangesetDoesNotExistError: If changeset for given ``start`` or
503 :raise ChangesetDoesNotExistError: If changeset for given ``start`` or
504 ``end`` could not be found.
504 ``end`` could not be found.
505
505
506 """
506 """
507 if branch_name and branch_name not in self.branches:
507 if branch_name and branch_name not in self.branches:
508 raise BranchDoesNotExistError("Branch '%s' not found"
508 raise BranchDoesNotExistError("Branch '%s' not found"
509 % branch_name)
509 % branch_name)
510 # actually we should check now if it's not an empty repo to not spaw
510 # actually we should check now if it's not an empty repo to not spaw
511 # subprocess commands
511 # subprocess commands
512 if self._empty:
512 if self._empty:
513 raise EmptyRepositoryError("There are no changesets yet")
513 raise EmptyRepositoryError("There are no changesets yet")
514
514
515 # %H at format means (full) commit hash, initial hashes are retrieved
515 # %H at format means (full) commit hash, initial hashes are retrieved
516 # in ascending date order
516 # in ascending date order
517 cmd = ['log', '--date-order', '--reverse', '--pretty=format:%H']
517 cmd = ['log', '--date-order', '--reverse', '--pretty=format:%H']
518 if max_revisions:
518 if max_revisions:
519 cmd += ['--max-count=%s' % max_revisions]
519 cmd += ['--max-count=%s' % max_revisions]
520 if start_date:
520 if start_date:
521 cmd += ['--since', start_date.strftime('%m/%d/%y %H:%M:%S')]
521 cmd += ['--since', start_date.strftime('%m/%d/%y %H:%M:%S')]
522 if end_date:
522 if end_date:
523 cmd += ['--until', end_date.strftime('%m/%d/%y %H:%M:%S')]
523 cmd += ['--until', end_date.strftime('%m/%d/%y %H:%M:%S')]
524 if branch_name:
524 if branch_name:
525 cmd.append(branch_name)
525 cmd.append(branch_name)
526 else:
526 else:
527 cmd.append(settings.GIT_REV_FILTER)
527 cmd.append(settings.GIT_REV_FILTER)
528
528
529 revs = self.run_git_command(cmd).splitlines()
529 revs = self.run_git_command(cmd).splitlines()
530 start_pos = 0
530 start_pos = 0
531 end_pos = len(revs)
531 end_pos = len(revs)
532 if start:
532 if start:
533 _start = self._get_revision(start)
533 _start = self._get_revision(start)
534 try:
534 try:
535 start_pos = revs.index(_start)
535 start_pos = revs.index(_start)
536 except ValueError:
536 except ValueError:
537 pass
537 pass
538
538
539 if end is not None:
539 if end is not None:
540 _end = self._get_revision(end)
540 _end = self._get_revision(end)
541 try:
541 try:
542 end_pos = revs.index(_end)
542 end_pos = revs.index(_end)
543 except ValueError:
543 except ValueError:
544 pass
544 pass
545
545
546 if None not in [start, end] and start_pos > end_pos:
546 if None not in [start, end] and start_pos > end_pos:
547 raise RepositoryError('start cannot be after end')
547 raise RepositoryError('start cannot be after end')
548
548
549 if end_pos is not None:
549 if end_pos is not None:
550 end_pos += 1
550 end_pos += 1
551
551
552 revs = revs[start_pos:end_pos]
552 revs = revs[start_pos:end_pos]
553 if reverse:
553 if reverse:
554 revs.reverse()
554 revs.reverse()
555
555
556 return CollectionGenerator(self, revs)
556 return CollectionGenerator(self, revs)
557
557
558 def get_diff(self, rev1, rev2, path=None, ignore_whitespace=False,
558 def get_diff(self, rev1, rev2, path=None, ignore_whitespace=False,
559 context=3):
559 context=3):
560 """
560 """
561 Returns (git like) *diff*, as plain bytes text. Shows changes
561 Returns (git like) *diff*, as plain bytes text. Shows changes
562 introduced by ``rev2`` since ``rev1``.
562 introduced by ``rev2`` since ``rev1``.
563
563
564 :param rev1: Entry point from which diff is shown. Can be
564 :param rev1: Entry point from which diff is shown. Can be
565 ``self.EMPTY_CHANGESET`` - in this case, patch showing all
565 ``self.EMPTY_CHANGESET`` - in this case, patch showing all
566 the changes since empty state of the repository until ``rev2``
566 the changes since empty state of the repository until ``rev2``
567 :param rev2: Until which revision changes should be shown.
567 :param rev2: Until which revision changes should be shown.
568 :param ignore_whitespace: If set to ``True``, would not show whitespace
568 :param ignore_whitespace: If set to ``True``, would not show whitespace
569 changes. Defaults to ``False``.
569 changes. Defaults to ``False``.
570 :param context: How many lines before/after changed lines should be
570 :param context: How many lines before/after changed lines should be
571 shown. Defaults to ``3``. Due to limitations in Git, if
571 shown. Defaults to ``3``. Due to limitations in Git, if
572 value passed-in is greater than ``2**31-1``
572 value passed-in is greater than ``2**31-1``
573 (``2147483647``), it will be set to ``2147483647``
573 (``2147483647``), it will be set to ``2147483647``
574 instead. If negative value is passed-in, it will be set to
574 instead. If negative value is passed-in, it will be set to
575 ``0`` instead.
575 ``0`` instead.
576 """
576 """
577
577
578 # Git internally uses a signed long int for storing context
578 # Git internally uses a signed long int for storing context
579 # size (number of lines to show before and after the
579 # size (number of lines to show before and after the
580 # differences). This can result in integer overflow, so we
580 # differences). This can result in integer overflow, so we
581 # ensure the requested context is smaller by one than the
581 # ensure the requested context is smaller by one than the
582 # number that would cause the overflow. It is highly unlikely
582 # number that would cause the overflow. It is highly unlikely
583 # that a single file will contain that many lines, so this
583 # that a single file will contain that many lines, so this
584 # kind of change should not cause any realistic consequences.
584 # kind of change should not cause any realistic consequences.
585 overflowed_long_int = 2**31
585 overflowed_long_int = 2**31
586
586
587 if context >= overflowed_long_int:
587 if context >= overflowed_long_int:
588 context = overflowed_long_int - 1
588 context = overflowed_long_int - 1
589
589
590 # Negative context values make no sense, and will result in
590 # Negative context values make no sense, and will result in
591 # errors. Ensure this does not happen.
591 # errors. Ensure this does not happen.
592 if context < 0:
592 if context < 0:
593 context = 0
593 context = 0
594
594
595 flags = ['-U%s' % context, '--full-index', '--binary', '-p', '-M', '--abbrev=40']
595 flags = ['-U%s' % context, '--full-index', '--binary', '-p', '-M', '--abbrev=40']
596 if ignore_whitespace:
596 if ignore_whitespace:
597 flags.append('-w')
597 flags.append('-w')
598
598
599 if hasattr(rev1, 'raw_id'):
599 if hasattr(rev1, 'raw_id'):
600 rev1 = getattr(rev1, 'raw_id')
600 rev1 = getattr(rev1, 'raw_id')
601
601
602 if hasattr(rev2, 'raw_id'):
602 if hasattr(rev2, 'raw_id'):
603 rev2 = getattr(rev2, 'raw_id')
603 rev2 = getattr(rev2, 'raw_id')
604
604
605 if rev1 == self.EMPTY_CHANGESET:
605 if rev1 == self.EMPTY_CHANGESET:
606 rev2 = self.get_changeset(rev2).raw_id
606 rev2 = self.get_changeset(rev2).raw_id
607 cmd = ['show'] + flags + [rev2]
607 cmd = ['show'] + flags + [rev2]
608 else:
608 else:
609 rev1 = self.get_changeset(rev1).raw_id
609 rev1 = self.get_changeset(rev1).raw_id
610 rev2 = self.get_changeset(rev2).raw_id
610 rev2 = self.get_changeset(rev2).raw_id
611 cmd = ['diff'] + flags + [rev1, rev2]
611 cmd = ['diff'] + flags + [rev1, rev2]
612
612
613 if path:
613 if path:
614 cmd += ['--', path]
614 cmd += ['--', path]
615
615
616 stdout, stderr = self._run_git_command(cmd, cwd=self.path)
616 stdout, stderr = self._run_git_command(cmd, cwd=self.path)
617 # If we used 'show' command, strip first few lines (until actual diff
617 # If we used 'show' command, strip first few lines (until actual diff
618 # starts)
618 # starts)
619 if rev1 == self.EMPTY_CHANGESET:
619 if rev1 == self.EMPTY_CHANGESET:
620 parts = stdout.split(b'\ndiff ', 1)
620 parts = stdout.split(b'\ndiff ', 1)
621 if len(parts) > 1:
621 if len(parts) > 1:
622 stdout = b'diff ' + parts[1]
622 stdout = b'diff ' + parts[1]
623 return stdout
623 return stdout
624
624
625 @LazyProperty
625 @LazyProperty
626 def in_memory_changeset(self):
626 def in_memory_changeset(self):
627 """
627 """
628 Returns ``GitInMemoryChangeset`` object for this repository.
628 Returns ``GitInMemoryChangeset`` object for this repository.
629 """
629 """
630 return GitInMemoryChangeset(self)
630 return GitInMemoryChangeset(self)
631
631
632 def clone(self, url, update_after_clone=True, bare=False):
632 def clone(self, url, update_after_clone=True, bare=False):
633 """
633 """
634 Tries to clone changes from external location.
634 Tries to clone changes from external location.
635
635
636 :param update_after_clone: If set to ``False``, git won't checkout
636 :param update_after_clone: If set to ``False``, git won't checkout
637 working directory
637 working directory
638 :param bare: If set to ``True``, repository would be cloned into
638 :param bare: If set to ``True``, repository would be cloned into
639 *bare* git repository (no working directory at all).
639 *bare* git repository (no working directory at all).
640 """
640 """
641 url = self._get_url(url)
641 url = self._get_url(url)
642 cmd = ['clone', '-q']
642 cmd = ['clone', '-q']
643 if bare:
643 if bare:
644 cmd.append('--bare')
644 cmd.append('--bare')
645 elif not update_after_clone:
645 elif not update_after_clone:
646 cmd.append('--no-checkout')
646 cmd.append('--no-checkout')
647 cmd += ['--', url, self.path]
647 cmd += ['--', url, self.path]
648 # If error occurs run_git_command raises RepositoryError already
648 # If error occurs run_git_command raises RepositoryError already
649 self.run_git_command(cmd)
649 self.run_git_command(cmd)
650
650
651 def pull(self, url):
651 def pull(self, url):
652 """
652 """
653 Tries to pull changes from external location.
653 Tries to pull changes from external location.
654 """
654 """
655 url = self._get_url(url)
655 url = self._get_url(url)
656 cmd = ['pull', '--ff-only', url]
656 cmd = ['pull', '--ff-only', url]
657 # If error occurs run_git_command raises RepositoryError already
657 # If error occurs run_git_command raises RepositoryError already
658 self.run_git_command(cmd)
658 self.run_git_command(cmd)
659
659
660 def fetch(self, url):
660 def fetch(self, url):
661 """
661 """
662 Tries to pull changes from external location.
662 Tries to pull changes from external location.
663 """
663 """
664 url = self._get_url(url)
664 url = self._get_url(url)
665 so = self.run_git_command(['ls-remote', '-h', url])
665 so = self.run_git_command(['ls-remote', '-h', url])
666 cmd = ['fetch', url, '--']
666 cmd = ['fetch', url, '--']
667 for line in (x for x in so.splitlines()):
667 for line in (x for x in so.splitlines()):
668 sha, ref = line.split('\t')
668 sha, ref = line.split('\t')
669 cmd.append('+%s:%s' % (ref, ref))
669 cmd.append('+%s:%s' % (ref, ref))
670 self.run_git_command(cmd)
670 self.run_git_command(cmd)
671
671
672 def _update_server_info(self):
672 def _update_server_info(self):
673 """
673 """
674 runs gits update-server-info command in this repo instance
674 runs gits update-server-info command in this repo instance
675 """
675 """
676 from dulwich.server import update_server_info
676 from dulwich.server import update_server_info
677 try:
677 try:
678 update_server_info(self._repo)
678 update_server_info(self._repo)
679 except OSError as e:
679 except OSError as e:
680 if e.errno not in [errno.ENOENT, errno.EROFS]:
680 if e.errno not in [errno.ENOENT, errno.EROFS]:
681 raise
681 raise
682 # Workaround for dulwich crashing on for example its own dulwich/tests/data/repos/simple_merge.git/info/refs.lock
682 # Workaround for dulwich crashing on for example its own dulwich/tests/data/repos/simple_merge.git/info/refs.lock
683 log.error('Ignoring %s running update-server-info: %s', type(e).__name__, e)
683 log.error('Ignoring %s running update-server-info: %s', type(e).__name__, e)
684
684
685 @LazyProperty
685 @LazyProperty
686 def workdir(self):
686 def workdir(self):
687 """
687 """
688 Returns ``Workdir`` instance for this repository.
688 Returns ``Workdir`` instance for this repository.
689 """
689 """
690 return GitWorkdir(self)
690 return GitWorkdir(self)
691
691
692 def get_config_value(self, section, name, config_file=None):
692 def get_config_value(self, section, name, config_file=None):
693 """
693 """
694 Returns configuration value for a given [``section``] and ``name``.
694 Returns configuration value for a given [``section``] and ``name``.
695
695
696 :param section: Section we want to retrieve value from
696 :param section: Section we want to retrieve value from
697 :param name: Name of configuration we want to retrieve
697 :param name: Name of configuration we want to retrieve
698 :param config_file: A path to file which should be used to retrieve
698 :param config_file: A path to file which should be used to retrieve
699 configuration from (might also be a list of file paths)
699 configuration from (might also be a list of file paths)
700 """
700 """
701 if config_file is None:
701 if config_file is None:
702 config_file = []
702 config_file = []
703 elif isinstance(config_file, str):
703 elif isinstance(config_file, str):
704 config_file = [config_file]
704 config_file = [config_file]
705
705
706 def gen_configs():
706 def gen_configs():
707 for path in config_file + self._config_files:
707 for path in config_file + self._config_files:
708 try:
708 try:
709 yield ConfigFile.from_path(path)
709 yield ConfigFile.from_path(path)
710 except (IOError, OSError, ValueError):
710 except (IOError, OSError, ValueError):
711 continue
711 continue
712
712
713 for config in gen_configs():
713 for config in gen_configs():
714 try:
714 try:
715 return config.get(section, name)
715 value = config.get(section, name)
716 except KeyError:
716 except KeyError:
717 continue
717 continue
718 return None if value is None else safe_str(value)
718 return None
719 return None
719
720
720 def get_user_name(self, config_file=None):
721 def get_user_name(self, config_file=None):
721 """
722 """
722 Returns user's name from global configuration file.
723 Returns user's name from global configuration file.
723
724
724 :param config_file: A path to file which should be used to retrieve
725 :param config_file: A path to file which should be used to retrieve
725 configuration from (might also be a list of file paths)
726 configuration from (might also be a list of file paths)
726 """
727 """
727 return self.get_config_value('user', 'name', config_file)
728 return self.get_config_value('user', 'name', config_file)
728
729
729 def get_user_email(self, config_file=None):
730 def get_user_email(self, config_file=None):
730 """
731 """
731 Returns user's email from global configuration file.
732 Returns user's email from global configuration file.
732
733
733 :param config_file: A path to file which should be used to retrieve
734 :param config_file: A path to file which should be used to retrieve
734 configuration from (might also be a list of file paths)
735 configuration from (might also be a list of file paths)
735 """
736 """
736 return self.get_config_value('user', 'email', config_file)
737 return self.get_config_value('user', 'email', config_file)
@@ -1,32 +1,32 b''
1 import re
1 import re
2
2
3 from kallithea.lib.utils2 import ascii_str
3 from kallithea.lib.utils2 import ascii_str, safe_str
4 from kallithea.lib.vcs.backends.base import BaseWorkdir
4 from kallithea.lib.vcs.backends.base import BaseWorkdir
5 from kallithea.lib.vcs.exceptions import BranchDoesNotExistError, RepositoryError
5 from kallithea.lib.vcs.exceptions import BranchDoesNotExistError, RepositoryError
6
6
7
7
8 class GitWorkdir(BaseWorkdir):
8 class GitWorkdir(BaseWorkdir):
9
9
10 def get_branch(self):
10 def get_branch(self):
11 headpath = self.repository._repo.refs.refpath(b'HEAD')
11 headpath = self.repository._repo.refs.refpath(b'HEAD')
12 try:
12 try:
13 content = open(headpath).read()
13 content = safe_str(open(headpath, 'rb').read())
14 match = re.match(r'^ref: refs/heads/(?P<branch>.+)\n$', content)
14 match = re.match(r'^ref: refs/heads/(?P<branch>.+)\n$', content)
15 if match:
15 if match:
16 return match.groupdict()['branch']
16 return match.groupdict()['branch']
17 else:
17 else:
18 raise RepositoryError("Couldn't compute workdir's branch")
18 raise RepositoryError("Couldn't compute workdir's branch")
19 except IOError:
19 except IOError:
20 # Try naive way...
20 # Try naive way...
21 raise RepositoryError("Couldn't compute workdir's branch")
21 raise RepositoryError("Couldn't compute workdir's branch")
22
22
23 def get_changeset(self):
23 def get_changeset(self):
24 wk_dir_id = ascii_str(self.repository._repo.refs.as_dict().get(b'HEAD'))
24 wk_dir_id = ascii_str(self.repository._repo.refs.as_dict().get(b'HEAD'))
25 return self.repository.get_changeset(wk_dir_id)
25 return self.repository.get_changeset(wk_dir_id)
26
26
27 def checkout_branch(self, branch=None):
27 def checkout_branch(self, branch=None):
28 if branch is None:
28 if branch is None:
29 branch = self.repository.DEFAULT_BRANCH_NAME
29 branch = self.repository.DEFAULT_BRANCH_NAME
30 if branch not in self.repository.branches:
30 if branch not in self.repository.branches:
31 raise BranchDoesNotExistError
31 raise BranchDoesNotExistError
32 self.repository.run_git_command(['checkout', branch])
32 self.repository.run_git_command(['checkout', branch])
@@ -1,407 +1,407 b''
1 import os
1 import os
2 import posixpath
2 import posixpath
3
3
4 import mercurial.archival
4 import mercurial.archival
5 import mercurial.node
5 import mercurial.node
6 import mercurial.obsutil
6 import mercurial.obsutil
7
7
8 from kallithea.lib.vcs.backends.base import BaseChangeset
8 from kallithea.lib.vcs.backends.base import BaseChangeset
9 from kallithea.lib.vcs.conf import settings
9 from kallithea.lib.vcs.conf import settings
10 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, ChangesetError, ImproperArchiveTypeError, NodeDoesNotExistError, VCSError
10 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, ChangesetError, ImproperArchiveTypeError, NodeDoesNotExistError, VCSError
11 from kallithea.lib.vcs.nodes import (
11 from kallithea.lib.vcs.nodes import (
12 AddedFileNodesGenerator, ChangedFileNodesGenerator, DirNode, FileNode, NodeKind, RemovedFileNodesGenerator, RootNode, SubModuleNode)
12 AddedFileNodesGenerator, ChangedFileNodesGenerator, DirNode, FileNode, NodeKind, RemovedFileNodesGenerator, RootNode, SubModuleNode)
13 from kallithea.lib.vcs.utils import ascii_bytes, ascii_str, date_fromtimestamp, safe_bytes, safe_str
13 from kallithea.lib.vcs.utils import ascii_bytes, ascii_str, date_fromtimestamp, safe_bytes, safe_str
14 from kallithea.lib.vcs.utils.lazy import LazyProperty
14 from kallithea.lib.vcs.utils.lazy import LazyProperty
15 from kallithea.lib.vcs.utils.paths import get_dirs_for_path
15 from kallithea.lib.vcs.utils.paths import get_dirs_for_path
16
16
17
17
18 class MercurialChangeset(BaseChangeset):
18 class MercurialChangeset(BaseChangeset):
19 """
19 """
20 Represents state of the repository at a revision.
20 Represents state of the repository at a revision.
21 """
21 """
22
22
23 def __init__(self, repository, revision):
23 def __init__(self, repository, revision):
24 self.repository = repository
24 self.repository = repository
25 assert isinstance(revision, str), repr(revision)
25 assert isinstance(revision, str), repr(revision)
26 self._ctx = repository._repo[ascii_bytes(revision)]
26 self._ctx = repository._repo[ascii_bytes(revision)]
27 self.raw_id = ascii_str(self._ctx.hex())
27 self.raw_id = ascii_str(self._ctx.hex())
28 self.revision = self._ctx._rev
28 self.revision = self._ctx._rev
29 self.nodes = {}
29 self.nodes = {}
30
30
31 @LazyProperty
31 @LazyProperty
32 def tags(self):
32 def tags(self):
33 return [safe_str(tag) for tag in self._ctx.tags()]
33 return [safe_str(tag) for tag in self._ctx.tags()]
34
34
35 @LazyProperty
35 @LazyProperty
36 def branch(self):
36 def branch(self):
37 return safe_str(self._ctx.branch())
37 return safe_str(self._ctx.branch())
38
38
39 @LazyProperty
39 @LazyProperty
40 def branches(self):
40 def branches(self):
41 return [safe_str(self._ctx.branch())]
41 return [safe_str(self._ctx.branch())]
42
42
43 @LazyProperty
43 @LazyProperty
44 def closesbranch(self):
44 def closesbranch(self):
45 return self._ctx.closesbranch()
45 return self._ctx.closesbranch()
46
46
47 @LazyProperty
47 @LazyProperty
48 def obsolete(self):
48 def obsolete(self):
49 return self._ctx.obsolete()
49 return self._ctx.obsolete()
50
50
51 @LazyProperty
51 @LazyProperty
52 def bumped(self):
52 def bumped(self):
53 return self._ctx.phasedivergent()
53 return self._ctx.phasedivergent()
54
54
55 @LazyProperty
55 @LazyProperty
56 def divergent(self):
56 def divergent(self):
57 return self._ctx.contentdivergent()
57 return self._ctx.contentdivergent()
58
58
59 @LazyProperty
59 @LazyProperty
60 def extinct(self):
60 def extinct(self):
61 return self._ctx.extinct()
61 return self._ctx.extinct()
62
62
63 @LazyProperty
63 @LazyProperty
64 def unstable(self):
64 def unstable(self):
65 return self._ctx.orphan()
65 return self._ctx.orphan()
66
66
67 @LazyProperty
67 @LazyProperty
68 def phase(self):
68 def phase(self):
69 if(self._ctx.phase() == 1):
69 if(self._ctx.phase() == 1):
70 return 'Draft'
70 return 'Draft'
71 elif(self._ctx.phase() == 2):
71 elif(self._ctx.phase() == 2):
72 return 'Secret'
72 return 'Secret'
73 else:
73 else:
74 return ''
74 return ''
75
75
76 @LazyProperty
76 @LazyProperty
77 def successors(self):
77 def successors(self):
78 successors = mercurial.obsutil.successorssets(self._ctx._repo, self._ctx.node(), closest=True)
78 successors = mercurial.obsutil.successorssets(self._ctx._repo, self._ctx.node(), closest=True)
79 if successors:
79 if successors:
80 # flatten the list here handles both divergent (len > 1)
80 # flatten the list here handles both divergent (len > 1)
81 # and the usual case (len = 1)
81 # and the usual case (len = 1)
82 successors = [mercurial.node.hex(n)[:12] for sub in successors for n in sub if n != self._ctx.node()]
82 successors = [mercurial.node.hex(n)[:12] for sub in successors for n in sub if n != self._ctx.node()]
83
83
84 return successors
84 return successors
85
85
86 @LazyProperty
86 @LazyProperty
87 def predecessors(self):
87 def predecessors(self):
88 return [mercurial.node.hex(n)[:12] for n in mercurial.obsutil.closestpredecessors(self._ctx._repo, self._ctx.node())]
88 return [mercurial.node.hex(n)[:12] for n in mercurial.obsutil.closestpredecessors(self._ctx._repo, self._ctx.node())]
89
89
90 @LazyProperty
90 @LazyProperty
91 def bookmarks(self):
91 def bookmarks(self):
92 return [safe_str(bookmark) for bookmark in self._ctx.bookmarks()]
92 return [safe_str(bookmark) for bookmark in self._ctx.bookmarks()]
93
93
94 @LazyProperty
94 @LazyProperty
95 def message(self):
95 def message(self):
96 return safe_str(self._ctx.description())
96 return safe_str(self._ctx.description())
97
97
98 @LazyProperty
98 @LazyProperty
99 def committer(self):
99 def committer(self):
100 return safe_str(self.author)
100 return safe_str(self.author)
101
101
102 @LazyProperty
102 @LazyProperty
103 def author(self):
103 def author(self):
104 return safe_str(self._ctx.user())
104 return safe_str(self._ctx.user())
105
105
106 @LazyProperty
106 @LazyProperty
107 def date(self):
107 def date(self):
108 return date_fromtimestamp(*self._ctx.date())
108 return date_fromtimestamp(*self._ctx.date())
109
109
110 @LazyProperty
110 @LazyProperty
111 def _timestamp(self):
111 def _timestamp(self):
112 return self._ctx.date()[0]
112 return self._ctx.date()[0]
113
113
114 @LazyProperty
114 @LazyProperty
115 def status(self):
115 def status(self):
116 """
116 """
117 Returns modified, added, removed, deleted files for current changeset
117 Returns modified, added, removed, deleted files for current changeset
118 """
118 """
119 return self.repository._repo.status(self._ctx.p1().node(),
119 return self.repository._repo.status(self._ctx.p1().node(),
120 self._ctx.node())
120 self._ctx.node())
121
121
122 @LazyProperty
122 @LazyProperty
123 def _file_paths(self):
123 def _file_paths(self):
124 return list(self._ctx)
124 return list(safe_str(f) for f in self._ctx)
125
125
126 @LazyProperty
126 @LazyProperty
127 def _dir_paths(self):
127 def _dir_paths(self):
128 p = list(set(get_dirs_for_path(*self._file_paths)))
128 p = list(set(get_dirs_for_path(*self._file_paths)))
129 p.insert(0, '')
129 p.insert(0, '')
130 return p
130 return p
131
131
132 @LazyProperty
132 @LazyProperty
133 def _paths(self):
133 def _paths(self):
134 return self._dir_paths + self._file_paths
134 return self._dir_paths + self._file_paths
135
135
136 @LazyProperty
136 @LazyProperty
137 def short_id(self):
137 def short_id(self):
138 return self.raw_id[:12]
138 return self.raw_id[:12]
139
139
140 @LazyProperty
140 @LazyProperty
141 def parents(self):
141 def parents(self):
142 """
142 """
143 Returns list of parents changesets.
143 Returns list of parents changesets.
144 """
144 """
145 return [self.repository.get_changeset(parent.rev())
145 return [self.repository.get_changeset(parent.rev())
146 for parent in self._ctx.parents() if parent.rev() >= 0]
146 for parent in self._ctx.parents() if parent.rev() >= 0]
147
147
148 @LazyProperty
148 @LazyProperty
149 def children(self):
149 def children(self):
150 """
150 """
151 Returns list of children changesets.
151 Returns list of children changesets.
152 """
152 """
153 return [self.repository.get_changeset(child.rev())
153 return [self.repository.get_changeset(child.rev())
154 for child in self._ctx.children() if child.rev() >= 0]
154 for child in self._ctx.children() if child.rev() >= 0]
155
155
156 def next(self, branch=None):
156 def next(self, branch=None):
157 if branch and self.branch != branch:
157 if branch and self.branch != branch:
158 raise VCSError('Branch option used on changeset not belonging '
158 raise VCSError('Branch option used on changeset not belonging '
159 'to that branch')
159 'to that branch')
160
160
161 cs = self
161 cs = self
162 while True:
162 while True:
163 try:
163 try:
164 next_ = cs.repository.revisions.index(cs.raw_id) + 1
164 next_ = cs.repository.revisions.index(cs.raw_id) + 1
165 next_rev = cs.repository.revisions[next_]
165 next_rev = cs.repository.revisions[next_]
166 except IndexError:
166 except IndexError:
167 raise ChangesetDoesNotExistError
167 raise ChangesetDoesNotExistError
168 cs = cs.repository.get_changeset(next_rev)
168 cs = cs.repository.get_changeset(next_rev)
169
169
170 if not branch or branch == cs.branch:
170 if not branch or branch == cs.branch:
171 return cs
171 return cs
172
172
173 def prev(self, branch=None):
173 def prev(self, branch=None):
174 if branch and self.branch != branch:
174 if branch and self.branch != branch:
175 raise VCSError('Branch option used on changeset not belonging '
175 raise VCSError('Branch option used on changeset not belonging '
176 'to that branch')
176 'to that branch')
177
177
178 cs = self
178 cs = self
179 while True:
179 while True:
180 try:
180 try:
181 prev_ = cs.repository.revisions.index(cs.raw_id) - 1
181 prev_ = cs.repository.revisions.index(cs.raw_id) - 1
182 if prev_ < 0:
182 if prev_ < 0:
183 raise IndexError
183 raise IndexError
184 prev_rev = cs.repository.revisions[prev_]
184 prev_rev = cs.repository.revisions[prev_]
185 except IndexError:
185 except IndexError:
186 raise ChangesetDoesNotExistError
186 raise ChangesetDoesNotExistError
187 cs = cs.repository.get_changeset(prev_rev)
187 cs = cs.repository.get_changeset(prev_rev)
188
188
189 if not branch or branch == cs.branch:
189 if not branch or branch == cs.branch:
190 return cs
190 return cs
191
191
192 def diff(self):
192 def diff(self):
193 # Only used to feed diffstat
193 # Only used to feed diffstat
194 return b''.join(self._ctx.diff())
194 return b''.join(self._ctx.diff())
195
195
196 def _fix_path(self, path):
196 def _fix_path(self, path):
197 """
197 """
198 Paths are stored without trailing slash so we need to get rid off it if
198 Paths are stored without trailing slash so we need to get rid off it if
199 needed. Also mercurial keeps filenodes as str so we need to decode
199 needed. Also mercurial keeps filenodes as str so we need to decode
200 from unicode to str
200 from unicode to str
201 """
201 """
202 if path.endswith('/'):
202 if path.endswith('/'):
203 path = path.rstrip('/')
203 path = path.rstrip('/')
204
204
205 return path
205 return path
206
206
207 def _get_kind(self, path):
207 def _get_kind(self, path):
208 path = self._fix_path(path)
208 path = self._fix_path(path)
209 if path in self._file_paths:
209 if path in self._file_paths:
210 return NodeKind.FILE
210 return NodeKind.FILE
211 elif path in self._dir_paths:
211 elif path in self._dir_paths:
212 return NodeKind.DIR
212 return NodeKind.DIR
213 else:
213 else:
214 raise ChangesetError("Node does not exist at the given path '%s'"
214 raise ChangesetError("Node does not exist at the given path '%s'"
215 % (path))
215 % (path))
216
216
217 def _get_filectx(self, path):
217 def _get_filectx(self, path):
218 path = self._fix_path(path)
218 path = self._fix_path(path)
219 if self._get_kind(path) != NodeKind.FILE:
219 if self._get_kind(path) != NodeKind.FILE:
220 raise ChangesetError("File does not exist for revision %s at "
220 raise ChangesetError("File does not exist for revision %s at "
221 " '%s'" % (self.raw_id, path))
221 " '%s'" % (self.raw_id, path))
222 return self._ctx.filectx(safe_bytes(path))
222 return self._ctx.filectx(safe_bytes(path))
223
223
224 def _extract_submodules(self):
224 def _extract_submodules(self):
225 """
225 """
226 returns a dictionary with submodule information from substate file
226 returns a dictionary with submodule information from substate file
227 of hg repository
227 of hg repository
228 """
228 """
229 return self._ctx.substate
229 return self._ctx.substate
230
230
231 def get_file_mode(self, path):
231 def get_file_mode(self, path):
232 """
232 """
233 Returns stat mode of the file at the given ``path``.
233 Returns stat mode of the file at the given ``path``.
234 """
234 """
235 fctx = self._get_filectx(path)
235 fctx = self._get_filectx(path)
236 if b'x' in fctx.flags():
236 if b'x' in fctx.flags():
237 return 0o100755
237 return 0o100755
238 else:
238 else:
239 return 0o100644
239 return 0o100644
240
240
241 def get_file_content(self, path):
241 def get_file_content(self, path):
242 """
242 """
243 Returns content of the file at given ``path``.
243 Returns content of the file at given ``path``.
244 """
244 """
245 fctx = self._get_filectx(path)
245 fctx = self._get_filectx(path)
246 return fctx.data()
246 return fctx.data()
247
247
248 def get_file_size(self, path):
248 def get_file_size(self, path):
249 """
249 """
250 Returns size of the file at given ``path``.
250 Returns size of the file at given ``path``.
251 """
251 """
252 fctx = self._get_filectx(path)
252 fctx = self._get_filectx(path)
253 return fctx.size()
253 return fctx.size()
254
254
255 def get_file_changeset(self, path):
255 def get_file_changeset(self, path):
256 """
256 """
257 Returns last commit of the file at the given ``path``.
257 Returns last commit of the file at the given ``path``.
258 """
258 """
259 return self.get_file_history(path, limit=1)[0]
259 return self.get_file_history(path, limit=1)[0]
260
260
261 def get_file_history(self, path, limit=None):
261 def get_file_history(self, path, limit=None):
262 """
262 """
263 Returns history of file as reversed list of ``Changeset`` objects for
263 Returns history of file as reversed list of ``Changeset`` objects for
264 which file at given ``path`` has been modified.
264 which file at given ``path`` has been modified.
265 """
265 """
266 fctx = self._get_filectx(path)
266 fctx = self._get_filectx(path)
267 hist = []
267 hist = []
268 cnt = 0
268 cnt = 0
269 for cs in reversed([x for x in fctx.filelog()]):
269 for cs in reversed([x for x in fctx.filelog()]):
270 cnt += 1
270 cnt += 1
271 hist.append(mercurial.node.hex(fctx.filectx(cs).node()))
271 hist.append(mercurial.node.hex(fctx.filectx(cs).node()))
272 if limit is not None and cnt == limit:
272 if limit is not None and cnt == limit:
273 break
273 break
274
274
275 return [self.repository.get_changeset(node) for node in hist]
275 return [self.repository.get_changeset(node) for node in hist]
276
276
277 def get_file_annotate(self, path):
277 def get_file_annotate(self, path):
278 """
278 """
279 Returns a generator of four element tuples with
279 Returns a generator of four element tuples with
280 lineno, sha, changeset lazy loader and line
280 lineno, sha, changeset lazy loader and line
281 """
281 """
282 annotations = self._get_filectx(path).annotate()
282 annotations = self._get_filectx(path).annotate()
283 annotation_lines = [(annotateline.fctx, annotateline.text) for annotateline in annotations]
283 annotation_lines = [(annotateline.fctx, annotateline.text) for annotateline in annotations]
284 for i, (fctx, line) in enumerate(annotation_lines):
284 for i, (fctx, line) in enumerate(annotation_lines):
285 sha = ascii_str(fctx.hex())
285 sha = ascii_str(fctx.hex())
286 yield (i + 1, sha, lambda sha=sha: self.repository.get_changeset(sha), line)
286 yield (i + 1, sha, lambda sha=sha: self.repository.get_changeset(sha), line)
287
287
288 def fill_archive(self, stream=None, kind='tgz', prefix=None,
288 def fill_archive(self, stream=None, kind='tgz', prefix=None,
289 subrepos=False):
289 subrepos=False):
290 """
290 """
291 Fills up given stream.
291 Fills up given stream.
292
292
293 :param stream: file like object.
293 :param stream: file like object.
294 :param kind: one of following: ``zip``, ``tgz`` or ``tbz2``.
294 :param kind: one of following: ``zip``, ``tgz`` or ``tbz2``.
295 Default: ``tgz``.
295 Default: ``tgz``.
296 :param prefix: name of root directory in archive.
296 :param prefix: name of root directory in archive.
297 Default is repository name and changeset's raw_id joined with dash
297 Default is repository name and changeset's raw_id joined with dash
298 (``repo-tip.<KIND>``).
298 (``repo-tip.<KIND>``).
299 :param subrepos: include subrepos in this archive.
299 :param subrepos: include subrepos in this archive.
300
300
301 :raise ImproperArchiveTypeError: If given kind is wrong.
301 :raise ImproperArchiveTypeError: If given kind is wrong.
302 :raise VcsError: If given stream is None
302 :raise VcsError: If given stream is None
303 """
303 """
304 allowed_kinds = settings.ARCHIVE_SPECS
304 allowed_kinds = settings.ARCHIVE_SPECS
305 if kind not in allowed_kinds:
305 if kind not in allowed_kinds:
306 raise ImproperArchiveTypeError('Archive kind not supported use one'
306 raise ImproperArchiveTypeError('Archive kind not supported use one'
307 'of %s' % ' '.join(allowed_kinds))
307 'of %s' % ' '.join(allowed_kinds))
308
308
309 if stream is None:
309 if stream is None:
310 raise VCSError('You need to pass in a valid stream for filling'
310 raise VCSError('You need to pass in a valid stream for filling'
311 ' with archival data')
311 ' with archival data')
312
312
313 if prefix is None:
313 if prefix is None:
314 prefix = '%s-%s' % (self.repository.name, self.short_id)
314 prefix = '%s-%s' % (self.repository.name, self.short_id)
315 elif prefix.startswith('/'):
315 elif prefix.startswith('/'):
316 raise VCSError("Prefix cannot start with leading slash")
316 raise VCSError("Prefix cannot start with leading slash")
317 elif prefix.strip() == '':
317 elif prefix.strip() == '':
318 raise VCSError("Prefix cannot be empty")
318 raise VCSError("Prefix cannot be empty")
319
319
320 mercurial.archival.archive(self.repository._repo, stream, ascii_bytes(self.raw_id),
320 mercurial.archival.archive(self.repository._repo, stream, ascii_bytes(self.raw_id),
321 safe_bytes(kind), prefix=safe_bytes(prefix), subrepos=subrepos)
321 safe_bytes(kind), prefix=safe_bytes(prefix), subrepos=subrepos)
322
322
323 def get_nodes(self, path):
323 def get_nodes(self, path):
324 """
324 """
325 Returns combined ``DirNode`` and ``FileNode`` objects list representing
325 Returns combined ``DirNode`` and ``FileNode`` objects list representing
326 state of changeset at the given ``path``. If node at the given ``path``
326 state of changeset at the given ``path``. If node at the given ``path``
327 is not instance of ``DirNode``, ChangesetError would be raised.
327 is not instance of ``DirNode``, ChangesetError would be raised.
328 """
328 """
329
329
330 if self._get_kind(path) != NodeKind.DIR:
330 if self._get_kind(path) != NodeKind.DIR:
331 raise ChangesetError("Directory does not exist for revision %s at "
331 raise ChangesetError("Directory does not exist for revision %s at "
332 " '%s'" % (self.revision, path))
332 " '%s'" % (self.revision, path))
333 path = self._fix_path(path)
333 path = self._fix_path(path)
334
334
335 filenodes = [FileNode(f, changeset=self) for f in self._file_paths
335 filenodes = [FileNode(f, changeset=self) for f in self._file_paths
336 if os.path.dirname(f) == path]
336 if os.path.dirname(f) == path]
337 dirs = path == '' and '' or [d for d in self._dir_paths
337 dirs = path == '' and '' or [d for d in self._dir_paths
338 if d and posixpath.dirname(d) == path]
338 if d and posixpath.dirname(d) == path]
339 dirnodes = [DirNode(d, changeset=self) for d in dirs
339 dirnodes = [DirNode(d, changeset=self) for d in dirs
340 if os.path.dirname(d) == path]
340 if os.path.dirname(d) == path]
341
341
342 als = self.repository.alias
342 als = self.repository.alias
343 for k, vals in self._extract_submodules().items():
343 for k, vals in self._extract_submodules().items():
344 #vals = url,rev,type
344 #vals = url,rev,type
345 loc = vals[0]
345 loc = vals[0]
346 cs = vals[1]
346 cs = vals[1]
347 dirnodes.append(SubModuleNode(k, url=loc, changeset=cs,
347 dirnodes.append(SubModuleNode(k, url=loc, changeset=cs,
348 alias=als))
348 alias=als))
349 nodes = dirnodes + filenodes
349 nodes = dirnodes + filenodes
350 for node in nodes:
350 for node in nodes:
351 self.nodes[node.path] = node
351 self.nodes[node.path] = node
352 nodes.sort()
352 nodes.sort()
353 return nodes
353 return nodes
354
354
355 def get_node(self, path):
355 def get_node(self, path):
356 """
356 """
357 Returns ``Node`` object from the given ``path``. If there is no node at
357 Returns ``Node`` object from the given ``path``. If there is no node at
358 the given ``path``, ``ChangesetError`` would be raised.
358 the given ``path``, ``ChangesetError`` would be raised.
359 """
359 """
360 path = self._fix_path(path)
360 path = self._fix_path(path)
361 if path not in self.nodes:
361 if path not in self.nodes:
362 if path in self._file_paths:
362 if path in self._file_paths:
363 node = FileNode(path, changeset=self)
363 node = FileNode(path, changeset=self)
364 elif path in self._dir_paths or path in self._dir_paths:
364 elif path in self._dir_paths or path in self._dir_paths:
365 if path == '':
365 if path == '':
366 node = RootNode(changeset=self)
366 node = RootNode(changeset=self)
367 else:
367 else:
368 node = DirNode(path, changeset=self)
368 node = DirNode(path, changeset=self)
369 else:
369 else:
370 raise NodeDoesNotExistError("There is no file nor directory "
370 raise NodeDoesNotExistError("There is no file nor directory "
371 "at the given path: '%s' at revision %s"
371 "at the given path: '%s' at revision %s"
372 % (path, self.short_id))
372 % (path, self.short_id))
373 # cache node
373 # cache node
374 self.nodes[path] = node
374 self.nodes[path] = node
375 return self.nodes[path]
375 return self.nodes[path]
376
376
377 @LazyProperty
377 @LazyProperty
378 def affected_files(self):
378 def affected_files(self):
379 """
379 """
380 Gets a fast accessible file changes for given changeset
380 Gets a fast accessible file changes for given changeset
381 """
381 """
382 return self._ctx.files()
382 return self._ctx.files()
383
383
384 @property
384 @property
385 def added(self):
385 def added(self):
386 """
386 """
387 Returns list of added ``FileNode`` objects.
387 Returns list of added ``FileNode`` objects.
388 """
388 """
389 return AddedFileNodesGenerator([n for n in self.status.added], self)
389 return AddedFileNodesGenerator([safe_str(n) for n in self.status.added], self)
390
390
391 @property
391 @property
392 def changed(self):
392 def changed(self):
393 """
393 """
394 Returns list of modified ``FileNode`` objects.
394 Returns list of modified ``FileNode`` objects.
395 """
395 """
396 return ChangedFileNodesGenerator([n for n in self.status.modified], self)
396 return ChangedFileNodesGenerator([safe_str(n) for n in self.status.modified], self)
397
397
398 @property
398 @property
399 def removed(self):
399 def removed(self):
400 """
400 """
401 Returns list of removed ``FileNode`` objects.
401 Returns list of removed ``FileNode`` objects.
402 """
402 """
403 return RemovedFileNodesGenerator([n for n in self.status.removed], self)
403 return RemovedFileNodesGenerator([safe_str(n) for n in self.status.removed], self)
404
404
405 @LazyProperty
405 @LazyProperty
406 def extra(self):
406 def extra(self):
407 return self._ctx.extra()
407 return self._ctx.extra()
@@ -1,110 +1,110 b''
1 import datetime
1 import datetime
2
2
3 import mercurial.context
3 import mercurial.context
4 import mercurial.node
4 import mercurial.node
5
5
6 from kallithea.lib.vcs.backends.base import BaseInMemoryChangeset
6 from kallithea.lib.vcs.backends.base import BaseInMemoryChangeset
7 from kallithea.lib.vcs.exceptions import RepositoryError
7 from kallithea.lib.vcs.exceptions import RepositoryError
8 from kallithea.lib.vcs.utils import ascii_str, safe_bytes
8 from kallithea.lib.vcs.utils import ascii_str, safe_bytes, safe_str
9
9
10
10
11 class MercurialInMemoryChangeset(BaseInMemoryChangeset):
11 class MercurialInMemoryChangeset(BaseInMemoryChangeset):
12
12
13 def commit(self, message, author, parents=None, branch=None, date=None,
13 def commit(self, message, author, parents=None, branch=None, date=None,
14 **kwargs):
14 **kwargs):
15 """
15 """
16 Performs in-memory commit (doesn't check workdir in any way) and
16 Performs in-memory commit (doesn't check workdir in any way) and
17 returns newly created ``Changeset``. Updates repository's
17 returns newly created ``Changeset``. Updates repository's
18 ``revisions``.
18 ``revisions``.
19
19
20 :param message: message of the commit
20 :param message: message of the commit
21 :param author: full username, i.e. "Joe Doe <joe.doe@example.com>"
21 :param author: full username, i.e. "Joe Doe <joe.doe@example.com>"
22 :param parents: single parent or sequence of parents from which commit
22 :param parents: single parent or sequence of parents from which commit
23 would be derived
23 would be derived
24 :param date: ``datetime.datetime`` instance. Defaults to
24 :param date: ``datetime.datetime`` instance. Defaults to
25 ``datetime.datetime.now()``.
25 ``datetime.datetime.now()``.
26 :param branch: branch name, as string. If none given, default backend's
26 :param branch: branch name, as string. If none given, default backend's
27 branch would be used.
27 branch would be used.
28
28
29 :raises ``CommitError``: if any error occurs while committing
29 :raises ``CommitError``: if any error occurs while committing
30 """
30 """
31 self.check_integrity(parents)
31 self.check_integrity(parents)
32
32
33 from .repository import MercurialRepository
33 from .repository import MercurialRepository
34 if not isinstance(message, unicode) or not isinstance(author, unicode):
34 if not isinstance(message, unicode) or not isinstance(author, unicode):
35 raise RepositoryError('Given message and author needs to be '
35 raise RepositoryError('Given message and author needs to be '
36 'an <unicode> instance got %r & %r instead'
36 'an <unicode> instance got %r & %r instead'
37 % (type(message), type(author)))
37 % (type(message), type(author)))
38
38
39 if branch is None:
39 if branch is None:
40 branch = MercurialRepository.DEFAULT_BRANCH_NAME
40 branch = MercurialRepository.DEFAULT_BRANCH_NAME
41 kwargs[b'branch'] = safe_bytes(branch)
41 kwargs[b'branch'] = safe_bytes(branch)
42
42
43 def filectxfn(_repo, memctx, bytes_path):
43 def filectxfn(_repo, memctx, bytes_path):
44 """
44 """
45 Callback from Mercurial, returning ctx to commit for the given
45 Callback from Mercurial, returning ctx to commit for the given
46 path.
46 path.
47 """
47 """
48 path = bytes_path # will be different for py3
48 path = safe_str(bytes_path)
49
49
50 # check if this path is removed
50 # check if this path is removed
51 if path in (node.path for node in self.removed):
51 if path in (node.path for node in self.removed):
52 return None
52 return None
53
53
54 # check if this path is added
54 # check if this path is added
55 for node in self.added:
55 for node in self.added:
56 if node.path == path:
56 if node.path == path:
57 return mercurial.context.memfilectx(_repo, memctx, path=bytes_path,
57 return mercurial.context.memfilectx(_repo, memctx, path=bytes_path,
58 data=node.content,
58 data=node.content,
59 islink=False,
59 islink=False,
60 isexec=node.is_executable,
60 isexec=node.is_executable,
61 copysource=False)
61 copysource=False)
62
62
63 # or changed
63 # or changed
64 for node in self.changed:
64 for node in self.changed:
65 if node.path == path:
65 if node.path == path:
66 return mercurial.context.memfilectx(_repo, memctx, path=bytes_path,
66 return mercurial.context.memfilectx(_repo, memctx, path=bytes_path,
67 data=node.content,
67 data=node.content,
68 islink=False,
68 islink=False,
69 isexec=node.is_executable,
69 isexec=node.is_executable,
70 copysource=False)
70 copysource=False)
71
71
72 raise RepositoryError("Given path haven't been marked as added, "
72 raise RepositoryError("Given path haven't been marked as added, "
73 "changed or removed (%s)" % path)
73 "changed or removed (%s)" % path)
74
74
75 parents = [None, None]
75 parents = [None, None]
76 for i, parent in enumerate(self.parents):
76 for i, parent in enumerate(self.parents):
77 if parent is not None:
77 if parent is not None:
78 parents[i] = parent._ctx.node()
78 parents[i] = parent._ctx.node()
79
79
80 if date and isinstance(date, datetime.datetime):
80 if date and isinstance(date, datetime.datetime):
81 date = safe_bytes(date.strftime('%a, %d %b %Y %H:%M:%S'))
81 date = safe_bytes(date.strftime('%a, %d %b %Y %H:%M:%S'))
82
82
83 commit_ctx = mercurial.context.memctx(
83 commit_ctx = mercurial.context.memctx(
84 repo=self.repository._repo,
84 repo=self.repository._repo,
85 parents=parents,
85 parents=parents,
86 text=b'',
86 text=b'',
87 files=[safe_bytes(x) for x in self.get_paths()],
87 files=[safe_bytes(x) for x in self.get_paths()],
88 filectxfn=filectxfn,
88 filectxfn=filectxfn,
89 user=safe_bytes(author),
89 user=safe_bytes(author),
90 date=date,
90 date=date,
91 extra=kwargs)
91 extra=kwargs)
92
92
93 # injecting given _repo params
93 # injecting given _repo params
94 commit_ctx._text = safe_bytes(message)
94 commit_ctx._text = safe_bytes(message)
95 commit_ctx._user = safe_bytes(author)
95 commit_ctx._user = safe_bytes(author)
96 commit_ctx._date = date
96 commit_ctx._date = date
97
97
98 # TODO: Catch exceptions!
98 # TODO: Catch exceptions!
99 n = self.repository._repo.commitctx(commit_ctx)
99 n = self.repository._repo.commitctx(commit_ctx)
100 # Returns mercurial node
100 # Returns mercurial node
101 self._commit_ctx = commit_ctx # For reference
101 self._commit_ctx = commit_ctx # For reference
102 # Update vcs repository object & recreate mercurial _repo
102 # Update vcs repository object & recreate mercurial _repo
103 # new_ctx = self.repository._repo[node]
103 # new_ctx = self.repository._repo[node]
104 # new_tip = ascii_str(self.repository.get_changeset(new_ctx.hex()))
104 # new_tip = ascii_str(self.repository.get_changeset(new_ctx.hex()))
105 self.repository.revisions.append(ascii_str(mercurial.node.hex(n)))
105 self.repository.revisions.append(ascii_str(mercurial.node.hex(n)))
106 self._repo = self.repository._get_repo(create=False)
106 self._repo = self.repository._get_repo(create=False)
107 self.repository.branches = self.repository._get_branches()
107 self.repository.branches = self.repository._get_branches()
108 tip = self.repository.get_changeset()
108 tip = self.repository.get_changeset()
109 self.reset()
109 self.reset()
110 return tip
110 return tip
@@ -1,616 +1,617 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 """
2 """
3 vcs.backends.hg.repository
3 vcs.backends.hg.repository
4 ~~~~~~~~~~~~~~~~~~~~~~~~~~
4 ~~~~~~~~~~~~~~~~~~~~~~~~~~
5
5
6 Mercurial repository implementation.
6 Mercurial repository implementation.
7
7
8 :created_on: Apr 8, 2010
8 :created_on: Apr 8, 2010
9 :copyright: (c) 2010-2011 by Marcin Kuzminski, Lukasz Balcerzak.
9 :copyright: (c) 2010-2011 by Marcin Kuzminski, Lukasz Balcerzak.
10 """
10 """
11
11
12 import datetime
12 import datetime
13 import logging
13 import logging
14 import os
14 import os
15 import time
15 import time
16 import urllib.error
16 import urllib.error
17 import urllib.parse
17 import urllib.parse
18 import urllib.request
18 import urllib.request
19 from collections import OrderedDict
19 from collections import OrderedDict
20
20
21 import mercurial.commands
21 import mercurial.commands
22 import mercurial.error
22 import mercurial.error
23 import mercurial.exchange
23 import mercurial.exchange
24 import mercurial.hg
24 import mercurial.hg
25 import mercurial.hgweb
25 import mercurial.hgweb
26 import mercurial.httppeer
26 import mercurial.httppeer
27 import mercurial.localrepo
27 import mercurial.localrepo
28 import mercurial.match
28 import mercurial.match
29 import mercurial.mdiff
29 import mercurial.mdiff
30 import mercurial.node
30 import mercurial.node
31 import mercurial.patch
31 import mercurial.patch
32 import mercurial.scmutil
32 import mercurial.scmutil
33 import mercurial.sshpeer
33 import mercurial.sshpeer
34 import mercurial.tags
34 import mercurial.tags
35 import mercurial.ui
35 import mercurial.ui
36 import mercurial.url
36 import mercurial.url
37 import mercurial.util
37 import mercurial.util
38
38
39 from kallithea.lib.vcs.backends.base import BaseRepository, CollectionGenerator
39 from kallithea.lib.vcs.backends.base import BaseRepository, CollectionGenerator
40 from kallithea.lib.vcs.exceptions import (
40 from kallithea.lib.vcs.exceptions import (
41 BranchDoesNotExistError, ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError, TagAlreadyExistError, TagDoesNotExistError, VCSError)
41 BranchDoesNotExistError, ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError, TagAlreadyExistError, TagDoesNotExistError, VCSError)
42 from kallithea.lib.vcs.utils import ascii_str, author_email, author_name, date_fromtimestamp, makedate, safe_bytes, safe_str
42 from kallithea.lib.vcs.utils import ascii_str, author_email, author_name, date_fromtimestamp, makedate, safe_bytes, safe_str
43 from kallithea.lib.vcs.utils.lazy import LazyProperty
43 from kallithea.lib.vcs.utils.lazy import LazyProperty
44 from kallithea.lib.vcs.utils.paths import abspath
44 from kallithea.lib.vcs.utils.paths import abspath
45
45
46 from .changeset import MercurialChangeset
46 from .changeset import MercurialChangeset
47 from .inmemory import MercurialInMemoryChangeset
47 from .inmemory import MercurialInMemoryChangeset
48 from .workdir import MercurialWorkdir
48 from .workdir import MercurialWorkdir
49
49
50
50
51 log = logging.getLogger(__name__)
51 log = logging.getLogger(__name__)
52
52
53
53
54 class MercurialRepository(BaseRepository):
54 class MercurialRepository(BaseRepository):
55 """
55 """
56 Mercurial repository backend
56 Mercurial repository backend
57 """
57 """
58 DEFAULT_BRANCH_NAME = 'default'
58 DEFAULT_BRANCH_NAME = 'default'
59 scm = 'hg'
59 scm = 'hg'
60
60
61 def __init__(self, repo_path, create=False, baseui=None, src_url=None,
61 def __init__(self, repo_path, create=False, baseui=None, src_url=None,
62 update_after_clone=False):
62 update_after_clone=False):
63 """
63 """
64 Raises RepositoryError if repository could not be find at the given
64 Raises RepositoryError if repository could not be find at the given
65 ``repo_path``.
65 ``repo_path``.
66
66
67 :param repo_path: local path of the repository
67 :param repo_path: local path of the repository
68 :param create=False: if set to True, would try to create repository if
68 :param create=False: if set to True, would try to create repository if
69 it does not exist rather than raising exception
69 it does not exist rather than raising exception
70 :param baseui=None: user data
70 :param baseui=None: user data
71 :param src_url=None: would try to clone repository from given location
71 :param src_url=None: would try to clone repository from given location
72 :param update_after_clone=False: sets update of working copy after
72 :param update_after_clone=False: sets update of working copy after
73 making a clone
73 making a clone
74 """
74 """
75
75
76 if not isinstance(repo_path, str):
76 if not isinstance(repo_path, str):
77 raise VCSError('Mercurial backend requires repository path to '
77 raise VCSError('Mercurial backend requires repository path to '
78 'be instance of <str> got %s instead' %
78 'be instance of <str> got %s instead' %
79 type(repo_path))
79 type(repo_path))
80 self.path = abspath(repo_path)
80 self.path = abspath(repo_path)
81 self.baseui = baseui or mercurial.ui.ui()
81 self.baseui = baseui or mercurial.ui.ui()
82 # We've set path and ui, now we can set _repo itself
82 # We've set path and ui, now we can set _repo itself
83 self._repo = self._get_repo(create, src_url, update_after_clone)
83 self._repo = self._get_repo(create, src_url, update_after_clone)
84
84
85 @property
85 @property
86 def _empty(self):
86 def _empty(self):
87 """
87 """
88 Checks if repository is empty ie. without any changesets
88 Checks if repository is empty ie. without any changesets
89 """
89 """
90 # TODO: Following raises errors when using InMemoryChangeset...
90 # TODO: Following raises errors when using InMemoryChangeset...
91 # return len(self._repo.changelog) == 0
91 # return len(self._repo.changelog) == 0
92 return len(self.revisions) == 0
92 return len(self.revisions) == 0
93
93
94 @LazyProperty
94 @LazyProperty
95 def revisions(self):
95 def revisions(self):
96 """
96 """
97 Returns list of revisions' ids, in ascending order. Being lazy
97 Returns list of revisions' ids, in ascending order. Being lazy
98 attribute allows external tools to inject shas from cache.
98 attribute allows external tools to inject shas from cache.
99 """
99 """
100 return self._get_all_revisions()
100 return self._get_all_revisions()
101
101
102 @LazyProperty
102 @LazyProperty
103 def name(self):
103 def name(self):
104 return os.path.basename(self.path)
104 return os.path.basename(self.path)
105
105
106 @LazyProperty
106 @LazyProperty
107 def branches(self):
107 def branches(self):
108 return self._get_branches()
108 return self._get_branches()
109
109
110 @LazyProperty
110 @LazyProperty
111 def closed_branches(self):
111 def closed_branches(self):
112 return self._get_branches(normal=False, closed=True)
112 return self._get_branches(normal=False, closed=True)
113
113
114 @LazyProperty
114 @LazyProperty
115 def allbranches(self):
115 def allbranches(self):
116 """
116 """
117 List all branches, including closed branches.
117 List all branches, including closed branches.
118 """
118 """
119 return self._get_branches(closed=True)
119 return self._get_branches(closed=True)
120
120
121 def _get_branches(self, normal=True, closed=False):
121 def _get_branches(self, normal=True, closed=False):
122 """
122 """
123 Gets branches for this repository
123 Gets branches for this repository
124 Returns only not closed branches by default
124 Returns only not closed branches by default
125
125
126 :param closed: return also closed branches for mercurial
126 :param closed: return also closed branches for mercurial
127 :param normal: return also normal branches
127 :param normal: return also normal branches
128 """
128 """
129
129
130 if self._empty:
130 if self._empty:
131 return {}
131 return {}
132
132
133 bt = OrderedDict()
133 bt = OrderedDict()
134 for bn, _heads, node, isclosed in sorted(self._repo.branchmap().iterbranches()):
134 for bn, _heads, node, isclosed in sorted(self._repo.branchmap().iterbranches()):
135 if isclosed:
135 if isclosed:
136 if closed:
136 if closed:
137 bt[safe_str(bn)] = ascii_str(mercurial.node.hex(node))
137 bt[safe_str(bn)] = ascii_str(mercurial.node.hex(node))
138 else:
138 else:
139 if normal:
139 if normal:
140 bt[safe_str(bn)] = ascii_str(mercurial.node.hex(node))
140 bt[safe_str(bn)] = ascii_str(mercurial.node.hex(node))
141 return bt
141 return bt
142
142
143 @LazyProperty
143 @LazyProperty
144 def tags(self):
144 def tags(self):
145 """
145 """
146 Gets tags for this repository
146 Gets tags for this repository
147 """
147 """
148 return self._get_tags()
148 return self._get_tags()
149
149
150 def _get_tags(self):
150 def _get_tags(self):
151 if self._empty:
151 if self._empty:
152 return {}
152 return {}
153
153
154 return OrderedDict(sorted(
154 return OrderedDict(sorted(
155 ((safe_str(n), ascii_str(mercurial.node.hex(h))) for n, h in self._repo.tags().items()),
155 ((safe_str(n), ascii_str(mercurial.node.hex(h))) for n, h in self._repo.tags().items()),
156 reverse=True,
156 reverse=True,
157 key=lambda x: x[0], # sort by name
157 key=lambda x: x[0], # sort by name
158 ))
158 ))
159
159
160 def tag(self, name, user, revision=None, message=None, date=None,
160 def tag(self, name, user, revision=None, message=None, date=None,
161 **kwargs):
161 **kwargs):
162 """
162 """
163 Creates and returns a tag for the given ``revision``.
163 Creates and returns a tag for the given ``revision``.
164
164
165 :param name: name for new tag
165 :param name: name for new tag
166 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
166 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
167 :param revision: changeset id for which new tag would be created
167 :param revision: changeset id for which new tag would be created
168 :param message: message of the tag's commit
168 :param message: message of the tag's commit
169 :param date: date of tag's commit
169 :param date: date of tag's commit
170
170
171 :raises TagAlreadyExistError: if tag with same name already exists
171 :raises TagAlreadyExistError: if tag with same name already exists
172 """
172 """
173 if name in self.tags:
173 if name in self.tags:
174 raise TagAlreadyExistError("Tag %s already exists" % name)
174 raise TagAlreadyExistError("Tag %s already exists" % name)
175 changeset = self.get_changeset(revision)
175 changeset = self.get_changeset(revision)
176 local = kwargs.setdefault('local', False)
176 local = kwargs.setdefault('local', False)
177
177
178 if message is None:
178 if message is None:
179 message = "Added tag %s for changeset %s" % (name,
179 message = "Added tag %s for changeset %s" % (name,
180 changeset.short_id)
180 changeset.short_id)
181
181
182 if date is None:
182 if date is None:
183 date = safe_bytes(datetime.datetime.now().strftime('%a, %d %b %Y %H:%M:%S'))
183 date = safe_bytes(datetime.datetime.now().strftime('%a, %d %b %Y %H:%M:%S'))
184
184
185 try:
185 try:
186 mercurial.tags.tag(self._repo, safe_bytes(name), changeset._ctx.node(), safe_bytes(message), local, safe_bytes(user), date)
186 mercurial.tags.tag(self._repo, safe_bytes(name), changeset._ctx.node(), safe_bytes(message), local, safe_bytes(user), date)
187 except mercurial.error.Abort as e:
187 except mercurial.error.Abort as e:
188 raise RepositoryError(e.args[0])
188 raise RepositoryError(e.args[0])
189
189
190 # Reinitialize tags
190 # Reinitialize tags
191 self.tags = self._get_tags()
191 self.tags = self._get_tags()
192 tag_id = self.tags[name]
192 tag_id = self.tags[name]
193
193
194 return self.get_changeset(revision=tag_id)
194 return self.get_changeset(revision=tag_id)
195
195
196 def remove_tag(self, name, user, message=None, date=None):
196 def remove_tag(self, name, user, message=None, date=None):
197 """
197 """
198 Removes tag with the given ``name``.
198 Removes tag with the given ``name``.
199
199
200 :param name: name of the tag to be removed
200 :param name: name of the tag to be removed
201 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
201 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
202 :param message: message of the tag's removal commit
202 :param message: message of the tag's removal commit
203 :param date: date of tag's removal commit
203 :param date: date of tag's removal commit
204
204
205 :raises TagDoesNotExistError: if tag with given name does not exists
205 :raises TagDoesNotExistError: if tag with given name does not exists
206 """
206 """
207 if name not in self.tags:
207 if name not in self.tags:
208 raise TagDoesNotExistError("Tag %s does not exist" % name)
208 raise TagDoesNotExistError("Tag %s does not exist" % name)
209 if message is None:
209 if message is None:
210 message = "Removed tag %s" % name
210 message = "Removed tag %s" % name
211 if date is None:
211 if date is None:
212 date = safe_bytes(datetime.datetime.now().strftime('%a, %d %b %Y %H:%M:%S'))
212 date = safe_bytes(datetime.datetime.now().strftime('%a, %d %b %Y %H:%M:%S'))
213 local = False
213 local = False
214
214
215 try:
215 try:
216 mercurial.tags.tag(self._repo, safe_bytes(name), mercurial.commands.nullid, safe_bytes(message), local, safe_bytes(user), date)
216 mercurial.tags.tag(self._repo, safe_bytes(name), mercurial.commands.nullid, safe_bytes(message), local, safe_bytes(user), date)
217 self.tags = self._get_tags()
217 self.tags = self._get_tags()
218 except mercurial.error.Abort as e:
218 except mercurial.error.Abort as e:
219 raise RepositoryError(e.args[0])
219 raise RepositoryError(e.args[0])
220
220
221 @LazyProperty
221 @LazyProperty
222 def bookmarks(self):
222 def bookmarks(self):
223 """
223 """
224 Gets bookmarks for this repository
224 Gets bookmarks for this repository
225 """
225 """
226 return self._get_bookmarks()
226 return self._get_bookmarks()
227
227
228 def _get_bookmarks(self):
228 def _get_bookmarks(self):
229 if self._empty:
229 if self._empty:
230 return {}
230 return {}
231
231
232 return OrderedDict(sorted(
232 return OrderedDict(sorted(
233 ((safe_str(n), ascii_str(h)) for n, h in self._repo._bookmarks.items()),
233 ((safe_str(n), ascii_str(h)) for n, h in self._repo._bookmarks.items()),
234 reverse=True,
234 reverse=True,
235 key=lambda x: x[0], # sort by name
235 key=lambda x: x[0], # sort by name
236 ))
236 ))
237
237
238 def _get_all_revisions(self):
238 def _get_all_revisions(self):
239 return [ascii_str(self._repo[x].hex()) for x in self._repo.filtered(b'visible').changelog.revs()]
239 return [ascii_str(self._repo[x].hex()) for x in self._repo.filtered(b'visible').changelog.revs()]
240
240
241 def get_diff(self, rev1, rev2, path='', ignore_whitespace=False,
241 def get_diff(self, rev1, rev2, path='', ignore_whitespace=False,
242 context=3):
242 context=3):
243 """
243 """
244 Returns (git like) *diff*, as plain text. Shows changes introduced by
244 Returns (git like) *diff*, as plain text. Shows changes introduced by
245 ``rev2`` since ``rev1``.
245 ``rev2`` since ``rev1``.
246
246
247 :param rev1: Entry point from which diff is shown. Can be
247 :param rev1: Entry point from which diff is shown. Can be
248 ``self.EMPTY_CHANGESET`` - in this case, patch showing all
248 ``self.EMPTY_CHANGESET`` - in this case, patch showing all
249 the changes since empty state of the repository until ``rev2``
249 the changes since empty state of the repository until ``rev2``
250 :param rev2: Until which revision changes should be shown.
250 :param rev2: Until which revision changes should be shown.
251 :param ignore_whitespace: If set to ``True``, would not show whitespace
251 :param ignore_whitespace: If set to ``True``, would not show whitespace
252 changes. Defaults to ``False``.
252 changes. Defaults to ``False``.
253 :param context: How many lines before/after changed lines should be
253 :param context: How many lines before/after changed lines should be
254 shown. Defaults to ``3``. If negative value is passed-in, it will be
254 shown. Defaults to ``3``. If negative value is passed-in, it will be
255 set to ``0`` instead.
255 set to ``0`` instead.
256 """
256 """
257
257
258 # Negative context values make no sense, and will result in
258 # Negative context values make no sense, and will result in
259 # errors. Ensure this does not happen.
259 # errors. Ensure this does not happen.
260 if context < 0:
260 if context < 0:
261 context = 0
261 context = 0
262
262
263 if hasattr(rev1, 'raw_id'):
263 if hasattr(rev1, 'raw_id'):
264 rev1 = getattr(rev1, 'raw_id')
264 rev1 = getattr(rev1, 'raw_id')
265
265
266 if hasattr(rev2, 'raw_id'):
266 if hasattr(rev2, 'raw_id'):
267 rev2 = getattr(rev2, 'raw_id')
267 rev2 = getattr(rev2, 'raw_id')
268
268
269 # Check if given revisions are present at repository (may raise
269 # Check if given revisions are present at repository (may raise
270 # ChangesetDoesNotExistError)
270 # ChangesetDoesNotExistError)
271 if rev1 != self.EMPTY_CHANGESET:
271 if rev1 != self.EMPTY_CHANGESET:
272 self.get_changeset(rev1)
272 self.get_changeset(rev1)
273 self.get_changeset(rev2)
273 self.get_changeset(rev2)
274 if path:
274 if path:
275 file_filter = mercurial.match.exact(path)
275 file_filter = mercurial.match.exact(path)
276 else:
276 else:
277 file_filter = None
277 file_filter = None
278
278
279 return b''.join(mercurial.patch.diff(self._repo, rev1, rev2, match=file_filter,
279 return b''.join(mercurial.patch.diff(self._repo, rev1, rev2, match=file_filter,
280 opts=mercurial.mdiff.diffopts(git=True,
280 opts=mercurial.mdiff.diffopts(git=True,
281 showfunc=True,
281 showfunc=True,
282 ignorews=ignore_whitespace,
282 ignorews=ignore_whitespace,
283 context=context)))
283 context=context)))
284
284
285 @classmethod
285 @classmethod
286 def _check_url(cls, url, repoui=None):
286 def _check_url(cls, url, repoui=None):
287 """
287 """
288 Function will check given url and try to verify if it's a valid
288 Function will check given url and try to verify if it's a valid
289 link. Sometimes it may happened that mercurial will issue basic
289 link. Sometimes it may happened that mercurial will issue basic
290 auth request that can cause whole API to hang when used from python
290 auth request that can cause whole API to hang when used from python
291 or other external calls.
291 or other external calls.
292
292
293 On failures it'll raise urllib2.HTTPError, exception is also thrown
293 On failures it'll raise urllib2.HTTPError, exception is also thrown
294 when the return code is non 200
294 when the return code is non 200
295 """
295 """
296 # check first if it's not an local url
296 # check first if it's not an local url
297 if os.path.isdir(url) or url.startswith(b'file:'):
297 if os.path.isdir(url) or url.startswith(b'file:'):
298 return True
298 return True
299
299
300 if url.startswith(b'ssh:'):
300 if url.startswith(b'ssh:'):
301 # in case of invalid uri or authentication issues, sshpeer will
301 # in case of invalid uri or authentication issues, sshpeer will
302 # throw an exception.
302 # throw an exception.
303 mercurial.sshpeer.instance(repoui or mercurial.ui.ui(), url, False).lookup(b'tip')
303 mercurial.sshpeer.instance(repoui or mercurial.ui.ui(), url, False).lookup(b'tip')
304 return True
304 return True
305
305
306 url_prefix = None
306 url_prefix = None
307 if b'+' in url[:url.find(b'://')]:
307 if b'+' in url[:url.find(b'://')]:
308 url_prefix, url = url.split(b'+', 1)
308 url_prefix, url = url.split(b'+', 1)
309
309
310 handlers = []
310 handlers = []
311 url_obj = mercurial.util.url(url)
311 url_obj = mercurial.util.url(url)
312 test_uri, authinfo = url_obj.authinfo()
312 test_uri, authinfo = url_obj.authinfo()
313 url_obj.passwd = b'*****'
313 url_obj.passwd = b'*****'
314 cleaned_uri = str(url_obj)
314 cleaned_uri = str(url_obj)
315
315
316 if authinfo:
316 if authinfo:
317 # create a password manager
317 # create a password manager
318 passmgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
318 passmgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
319 passmgr.add_password(*authinfo)
319 passmgr.add_password(*authinfo)
320
320
321 handlers.extend((mercurial.url.httpbasicauthhandler(passmgr),
321 handlers.extend((mercurial.url.httpbasicauthhandler(passmgr),
322 mercurial.url.httpdigestauthhandler(passmgr)))
322 mercurial.url.httpdigestauthhandler(passmgr)))
323
323
324 o = urllib.request.build_opener(*handlers)
324 o = urllib.request.build_opener(*handlers)
325 o.addheaders = [('Content-Type', 'application/mercurial-0.1'),
325 o.addheaders = [('Content-Type', 'application/mercurial-0.1'),
326 ('Accept', 'application/mercurial-0.1')]
326 ('Accept', 'application/mercurial-0.1')]
327
327
328 req = urllib.request.Request(
328 req = urllib.request.Request(
329 "%s?%s" % (
329 "%s?%s" % (
330 test_uri,
330 test_uri,
331 urllib.parse.urlencode({
331 urllib.parse.urlencode({
332 'cmd': 'between',
332 'cmd': 'between',
333 'pairs': "%s-%s" % ('0' * 40, '0' * 40),
333 'pairs': "%s-%s" % ('0' * 40, '0' * 40),
334 })
334 })
335 ))
335 ))
336
336
337 try:
337 try:
338 resp = o.open(req)
338 resp = o.open(req)
339 if resp.code != 200:
339 if resp.code != 200:
340 raise Exception('Return Code is not 200')
340 raise Exception('Return Code is not 200')
341 except Exception as e:
341 except Exception as e:
342 # means it cannot be cloned
342 # means it cannot be cloned
343 raise urllib.error.URLError("[%s] org_exc: %s" % (cleaned_uri, e))
343 raise urllib.error.URLError("[%s] org_exc: %s" % (cleaned_uri, e))
344
344
345 if not url_prefix: # skip svn+http://... (and git+... too)
345 if not url_prefix: # skip svn+http://... (and git+... too)
346 # now check if it's a proper hg repo
346 # now check if it's a proper hg repo
347 try:
347 try:
348 mercurial.httppeer.instance(repoui or mercurial.ui.ui(), url, False).lookup(b'tip')
348 mercurial.httppeer.instance(repoui or mercurial.ui.ui(), url, False).lookup(b'tip')
349 except Exception as e:
349 except Exception as e:
350 raise urllib.error.URLError(
350 raise urllib.error.URLError(
351 "url [%s] does not look like an hg repo org_exc: %s"
351 "url [%s] does not look like an hg repo org_exc: %s"
352 % (cleaned_uri, e))
352 % (cleaned_uri, e))
353
353
354 return True
354 return True
355
355
356 def _get_repo(self, create, src_url=None, update_after_clone=False):
356 def _get_repo(self, create, src_url=None, update_after_clone=False):
357 """
357 """
358 Function will check for mercurial repository in given path and return
358 Function will check for mercurial repository in given path and return
359 a localrepo object. If there is no repository in that path it will
359 a localrepo object. If there is no repository in that path it will
360 raise an exception unless ``create`` parameter is set to True - in
360 raise an exception unless ``create`` parameter is set to True - in
361 that case repository would be created and returned.
361 that case repository would be created and returned.
362 If ``src_url`` is given, would try to clone repository from the
362 If ``src_url`` is given, would try to clone repository from the
363 location at given clone_point. Additionally it'll make update to
363 location at given clone_point. Additionally it'll make update to
364 working copy accordingly to ``update_after_clone`` flag
364 working copy accordingly to ``update_after_clone`` flag
365 """
365 """
366 try:
366 try:
367 if src_url:
367 if src_url:
368 url = safe_bytes(self._get_url(src_url))
368 url = safe_bytes(self._get_url(src_url))
369 opts = {}
369 opts = {}
370 if not update_after_clone:
370 if not update_after_clone:
371 opts.update({'noupdate': True})
371 opts.update({'noupdate': True})
372 MercurialRepository._check_url(url, self.baseui)
372 MercurialRepository._check_url(url, self.baseui)
373 mercurial.commands.clone(self.baseui, url, safe_bytes(self.path), **opts)
373 mercurial.commands.clone(self.baseui, url, safe_bytes(self.path), **opts)
374
374
375 # Don't try to create if we've already cloned repo
375 # Don't try to create if we've already cloned repo
376 create = False
376 create = False
377 return mercurial.localrepo.instance(self.baseui, safe_bytes(self.path), create=create)
377 return mercurial.localrepo.instance(self.baseui, safe_bytes(self.path), create=create)
378 except (mercurial.error.Abort, mercurial.error.RepoError) as err:
378 except (mercurial.error.Abort, mercurial.error.RepoError) as err:
379 if create:
379 if create:
380 msg = "Cannot create repository at %s. Original error was %s" \
380 msg = "Cannot create repository at %s. Original error was %s" \
381 % (self.name, err)
381 % (self.name, err)
382 else:
382 else:
383 msg = "Not valid repository at %s. Original error was %s" \
383 msg = "Not valid repository at %s. Original error was %s" \
384 % (self.name, err)
384 % (self.name, err)
385 raise RepositoryError(msg)
385 raise RepositoryError(msg)
386
386
387 @LazyProperty
387 @LazyProperty
388 def in_memory_changeset(self):
388 def in_memory_changeset(self):
389 return MercurialInMemoryChangeset(self)
389 return MercurialInMemoryChangeset(self)
390
390
391 @LazyProperty
391 @LazyProperty
392 def description(self):
392 def description(self):
393 _desc = self._repo.ui.config(b'web', b'description', None, untrusted=True)
393 _desc = self._repo.ui.config(b'web', b'description', None, untrusted=True)
394 return safe_str(_desc or b'unknown')
394 return safe_str(_desc or b'unknown')
395
395
396 @LazyProperty
396 @LazyProperty
397 def contact(self):
397 def contact(self):
398 return safe_str(mercurial.hgweb.common.get_contact(self._repo.ui.config)
398 return safe_str(mercurial.hgweb.common.get_contact(self._repo.ui.config)
399 or b'Unknown')
399 or b'Unknown')
400
400
401 @LazyProperty
401 @LazyProperty
402 def last_change(self):
402 def last_change(self):
403 """
403 """
404 Returns last change made on this repository as datetime object
404 Returns last change made on this repository as datetime object
405 """
405 """
406 return date_fromtimestamp(self._get_mtime(), makedate()[1])
406 return date_fromtimestamp(self._get_mtime(), makedate()[1])
407
407
408 def _get_mtime(self):
408 def _get_mtime(self):
409 try:
409 try:
410 return time.mktime(self.get_changeset().date.timetuple())
410 return time.mktime(self.get_changeset().date.timetuple())
411 except RepositoryError:
411 except RepositoryError:
412 # fallback to filesystem
412 # fallback to filesystem
413 cl_path = os.path.join(self.path, '.hg', "00changelog.i")
413 cl_path = os.path.join(self.path, '.hg', "00changelog.i")
414 st_path = os.path.join(self.path, '.hg', "store")
414 st_path = os.path.join(self.path, '.hg', "store")
415 if os.path.exists(cl_path):
415 if os.path.exists(cl_path):
416 return os.stat(cl_path).st_mtime
416 return os.stat(cl_path).st_mtime
417 else:
417 else:
418 return os.stat(st_path).st_mtime
418 return os.stat(st_path).st_mtime
419
419
420 def _get_revision(self, revision):
420 def _get_revision(self, revision):
421 """
421 """
422 Given any revision identifier, returns a 40 char string with revision hash.
422 Given any revision identifier, returns a 40 char string with revision hash.
423
423
424 :param revision: str or int or None
424 :param revision: str or int or None
425 """
425 """
426 if self._empty:
426 if self._empty:
427 raise EmptyRepositoryError("There are no changesets yet")
427 raise EmptyRepositoryError("There are no changesets yet")
428
428
429 if revision in [-1, None]:
429 if revision in [-1, None]:
430 revision = b'tip'
430 revision = b'tip'
431 elif isinstance(revision, unicode):
431 elif isinstance(revision, unicode):
432 revision = safe_bytes(revision)
432 revision = safe_bytes(revision)
433
433
434 try:
434 try:
435 if isinstance(revision, int):
435 if isinstance(revision, int):
436 return ascii_str(self._repo[revision].hex())
436 return ascii_str(self._repo[revision].hex())
437 return ascii_str(mercurial.scmutil.revsymbol(self._repo, revision).hex())
437 return ascii_str(mercurial.scmutil.revsymbol(self._repo, revision).hex())
438 except (IndexError, ValueError, mercurial.error.RepoLookupError, TypeError):
438 except (IndexError, ValueError, mercurial.error.RepoLookupError, TypeError):
439 msg = "Revision %r does not exist for %s" % (safe_str(revision), self.name)
439 msg = "Revision %r does not exist for %s" % (safe_str(revision), self.name)
440 raise ChangesetDoesNotExistError(msg)
440 raise ChangesetDoesNotExistError(msg)
441 except (LookupError, ):
441 except (LookupError, ):
442 msg = "Ambiguous identifier `%s` for %s" % (safe_str(revision), self.name)
442 msg = "Ambiguous identifier `%s` for %s" % (safe_str(revision), self.name)
443 raise ChangesetDoesNotExistError(msg)
443 raise ChangesetDoesNotExistError(msg)
444
444
445 def get_ref_revision(self, ref_type, ref_name):
445 def get_ref_revision(self, ref_type, ref_name):
446 """
446 """
447 Returns revision number for the given reference.
447 Returns revision number for the given reference.
448 """
448 """
449 if ref_type == 'rev' and not ref_name.strip('0'):
449 if ref_type == 'rev' and not ref_name.strip('0'):
450 return self.EMPTY_CHANGESET
450 return self.EMPTY_CHANGESET
451 # lookup up the exact node id
451 # lookup up the exact node id
452 _revset_predicates = {
452 _revset_predicates = {
453 'branch': 'branch',
453 'branch': 'branch',
454 'book': 'bookmark',
454 'book': 'bookmark',
455 'tag': 'tag',
455 'tag': 'tag',
456 'rev': 'id',
456 'rev': 'id',
457 }
457 }
458 # avoid expensive branch(x) iteration over whole repo
458 # avoid expensive branch(x) iteration over whole repo
459 rev_spec = "%%s & %s(%%s)" % _revset_predicates[ref_type]
459 rev_spec = "%%s & %s(%%s)" % _revset_predicates[ref_type]
460 try:
460 try:
461 revs = self._repo.revs(rev_spec, ref_name, ref_name)
461 revs = self._repo.revs(rev_spec, ref_name, ref_name)
462 except LookupError:
462 except LookupError:
463 msg = "Ambiguous identifier %s:%s for %s" % (ref_type, ref_name, self.name)
463 msg = "Ambiguous identifier %s:%s for %s" % (ref_type, ref_name, self.name)
464 raise ChangesetDoesNotExistError(msg)
464 raise ChangesetDoesNotExistError(msg)
465 except mercurial.error.RepoLookupError:
465 except mercurial.error.RepoLookupError:
466 msg = "Revision %s:%s does not exist for %s" % (ref_type, ref_name, self.name)
466 msg = "Revision %s:%s does not exist for %s" % (ref_type, ref_name, self.name)
467 raise ChangesetDoesNotExistError(msg)
467 raise ChangesetDoesNotExistError(msg)
468 if revs:
468 if revs:
469 revision = revs.last()
469 revision = revs.last()
470 else:
470 else:
471 # TODO: just report 'not found'?
471 # TODO: just report 'not found'?
472 revision = ref_name
472 revision = ref_name
473
473
474 return self._get_revision(revision)
474 return self._get_revision(revision)
475
475
476 def _get_archives(self, archive_name='tip'):
476 def _get_archives(self, archive_name='tip'):
477 allowed = self.baseui.configlist(b"web", b"allow_archive",
477 allowed = self.baseui.configlist(b"web", b"allow_archive",
478 untrusted=True)
478 untrusted=True)
479 for name, ext in [(b'zip', '.zip'), (b'gz', '.tar.gz'), (b'bz2', '.tar.bz2')]:
479 for name, ext in [(b'zip', '.zip'), (b'gz', '.tar.gz'), (b'bz2', '.tar.bz2')]:
480 if name in allowed or self._repo.ui.configbool(b"web",
480 if name in allowed or self._repo.ui.configbool(b"web",
481 b"allow" + name,
481 b"allow" + name,
482 untrusted=True):
482 untrusted=True):
483 yield {"type": name, "extension": ext, "node": archive_name}
483 yield {"type": safe_str(name), "extension": ext, "node": archive_name}
484
484
485 def _get_url(self, url):
485 def _get_url(self, url):
486 """
486 """
487 Returns normalized url. If schema is not given, fall back to
487 Returns normalized url. If schema is not given, fall back to
488 filesystem (``file:///``) schema.
488 filesystem (``file:///``) schema.
489 """
489 """
490 if url != 'default' and '://' not in url:
490 if url != 'default' and '://' not in url:
491 url = "file:" + urllib.request.pathname2url(url)
491 url = "file:" + urllib.request.pathname2url(url)
492 return url
492 return url
493
493
494 def get_changeset(self, revision=None):
494 def get_changeset(self, revision=None):
495 """
495 """
496 Returns ``MercurialChangeset`` object representing repository's
496 Returns ``MercurialChangeset`` object representing repository's
497 changeset at the given ``revision``.
497 changeset at the given ``revision``.
498 """
498 """
499 return MercurialChangeset(repository=self, revision=self._get_revision(revision))
499 return MercurialChangeset(repository=self, revision=self._get_revision(revision))
500
500
501 def get_changesets(self, start=None, end=None, start_date=None,
501 def get_changesets(self, start=None, end=None, start_date=None,
502 end_date=None, branch_name=None, reverse=False, max_revisions=None):
502 end_date=None, branch_name=None, reverse=False, max_revisions=None):
503 """
503 """
504 Returns iterator of ``MercurialChangeset`` objects from start to end
504 Returns iterator of ``MercurialChangeset`` objects from start to end
505 (both are inclusive)
505 (both are inclusive)
506
506
507 :param start: None, str, int or mercurial lookup format
507 :param start: None, str, int or mercurial lookup format
508 :param end: None, str, int or mercurial lookup format
508 :param end: None, str, int or mercurial lookup format
509 :param start_date:
509 :param start_date:
510 :param end_date:
510 :param end_date:
511 :param branch_name:
511 :param branch_name:
512 :param reversed: return changesets in reversed order
512 :param reversed: return changesets in reversed order
513 """
513 """
514 start_raw_id = self._get_revision(start)
514 start_raw_id = self._get_revision(start)
515 start_pos = None if start is None else self.revisions.index(start_raw_id)
515 start_pos = None if start is None else self.revisions.index(start_raw_id)
516 end_raw_id = self._get_revision(end)
516 end_raw_id = self._get_revision(end)
517 end_pos = None if end is None else self.revisions.index(end_raw_id)
517 end_pos = None if end is None else self.revisions.index(end_raw_id)
518
518
519 if start_pos is not None and end_pos is not None and start_pos > end_pos:
519 if start_pos is not None and end_pos is not None and start_pos > end_pos:
520 raise RepositoryError("Start revision '%s' cannot be "
520 raise RepositoryError("Start revision '%s' cannot be "
521 "after end revision '%s'" % (start, end))
521 "after end revision '%s'" % (start, end))
522
522
523 if branch_name and branch_name not in self.allbranches:
523 if branch_name and branch_name not in self.allbranches:
524 msg = "Branch %r not found in %s" % (branch_name, self.name)
524 msg = "Branch %r not found in %s" % (branch_name, self.name)
525 raise BranchDoesNotExistError(msg)
525 raise BranchDoesNotExistError(msg)
526 if end_pos is not None:
526 if end_pos is not None:
527 end_pos += 1
527 end_pos += 1
528 # filter branches
528 # filter branches
529 filter_ = []
529 filter_ = []
530 if branch_name:
530 if branch_name:
531 filter_.append(b'branch("%s")' % safe_bytes(branch_name))
531 filter_.append(b'branch("%s")' % safe_bytes(branch_name))
532 if start_date:
532 if start_date:
533 filter_.append(b'date(">%s")' % safe_bytes(str(start_date)))
533 filter_.append(b'date(">%s")' % safe_bytes(str(start_date)))
534 if end_date:
534 if end_date:
535 filter_.append(b'date("<%s")' % safe_bytes(str(end_date)))
535 filter_.append(b'date("<%s")' % safe_bytes(str(end_date)))
536 if filter_ or max_revisions:
536 if filter_ or max_revisions:
537 if filter_:
537 if filter_:
538 revspec = b' and '.join(filter_)
538 revspec = b' and '.join(filter_)
539 else:
539 else:
540 revspec = b'all()'
540 revspec = b'all()'
541 if max_revisions:
541 if max_revisions:
542 revspec = b'limit(%s, %d)' % (revspec, max_revisions)
542 revspec = b'limit(%s, %d)' % (revspec, max_revisions)
543 revisions = mercurial.scmutil.revrange(self._repo, [revspec])
543 revisions = mercurial.scmutil.revrange(self._repo, [revspec])
544 else:
544 else:
545 revisions = self.revisions
545 revisions = self.revisions
546
546
547 # this is very much a hack to turn this into a list; a better solution
547 # this is very much a hack to turn this into a list; a better solution
548 # would be to get rid of this function entirely and use revsets
548 # would be to get rid of this function entirely and use revsets
549 revs = list(revisions)[start_pos:end_pos]
549 revs = list(revisions)[start_pos:end_pos]
550 if reverse:
550 if reverse:
551 revs.reverse()
551 revs.reverse()
552
552
553 return CollectionGenerator(self, revs)
553 return CollectionGenerator(self, revs)
554
554
555 def pull(self, url):
555 def pull(self, url):
556 """
556 """
557 Tries to pull changes from external location.
557 Tries to pull changes from external location.
558 """
558 """
559 other = mercurial.hg.peer(self._repo, {}, safe_bytes(self._get_url(url)))
559 other = mercurial.hg.peer(self._repo, {}, safe_bytes(self._get_url(url)))
560 try:
560 try:
561 mercurial.exchange.pull(self._repo, other, heads=None, force=None)
561 mercurial.exchange.pull(self._repo, other, heads=None, force=None)
562 except mercurial.error.Abort as err:
562 except mercurial.error.Abort as err:
563 # Propagate error but with vcs's type
563 # Propagate error but with vcs's type
564 raise RepositoryError(str(err))
564 raise RepositoryError(str(err))
565
565
566 @LazyProperty
566 @LazyProperty
567 def workdir(self):
567 def workdir(self):
568 """
568 """
569 Returns ``Workdir`` instance for this repository.
569 Returns ``Workdir`` instance for this repository.
570 """
570 """
571 return MercurialWorkdir(self)
571 return MercurialWorkdir(self)
572
572
573 def get_config_value(self, section, name=None, config_file=None):
573 def get_config_value(self, section, name=None, config_file=None):
574 """
574 """
575 Returns configuration value for a given [``section``] and ``name``.
575 Returns configuration value for a given [``section``] and ``name``.
576
576
577 :param section: Section we want to retrieve value from
577 :param section: Section we want to retrieve value from
578 :param name: Name of configuration we want to retrieve
578 :param name: Name of configuration we want to retrieve
579 :param config_file: A path to file which should be used to retrieve
579 :param config_file: A path to file which should be used to retrieve
580 configuration from (might also be a list of file paths)
580 configuration from (might also be a list of file paths)
581 """
581 """
582 if config_file is None:
582 if config_file is None:
583 config_file = []
583 config_file = []
584 elif isinstance(config_file, str):
584 elif isinstance(config_file, str):
585 config_file = [config_file]
585 config_file = [config_file]
586
586
587 config = self._repo.ui
587 config = self._repo.ui
588 if config_file:
588 if config_file:
589 config = mercurial.ui.ui()
589 config = mercurial.ui.ui()
590 for path in config_file:
590 for path in config_file:
591 config.readconfig(safe_bytes(path))
591 config.readconfig(safe_bytes(path))
592 return config.config(safe_bytes(section), safe_bytes(name))
592 value = config.config(safe_bytes(section), safe_bytes(name))
593 return value if value is None else safe_str(value)
593
594
594 def get_user_name(self, config_file=None):
595 def get_user_name(self, config_file=None):
595 """
596 """
596 Returns user's name from global configuration file.
597 Returns user's name from global configuration file.
597
598
598 :param config_file: A path to file which should be used to retrieve
599 :param config_file: A path to file which should be used to retrieve
599 configuration from (might also be a list of file paths)
600 configuration from (might also be a list of file paths)
600 """
601 """
601 username = self.get_config_value('ui', 'username', config_file=config_file)
602 username = self.get_config_value('ui', 'username', config_file=config_file)
602 if username:
603 if username:
603 return author_name(username)
604 return author_name(username)
604 return None
605 return None
605
606
606 def get_user_email(self, config_file=None):
607 def get_user_email(self, config_file=None):
607 """
608 """
608 Returns user's email from global configuration file.
609 Returns user's email from global configuration file.
609
610
610 :param config_file: A path to file which should be used to retrieve
611 :param config_file: A path to file which should be used to retrieve
611 configuration from (might also be a list of file paths)
612 configuration from (might also be a list of file paths)
612 """
613 """
613 username = self.get_config_value('ui', 'username', config_file=config_file)
614 username = self.get_config_value('ui', 'username', config_file=config_file)
614 if username:
615 if username:
615 return author_email(username)
616 return author_email(username)
616 return None
617 return None
@@ -1,24 +1,24 b''
1 import mercurial.merge
1 import mercurial.merge
2
2
3 from kallithea.lib.vcs.backends.base import BaseWorkdir
3 from kallithea.lib.vcs.backends.base import BaseWorkdir
4 from kallithea.lib.vcs.exceptions import BranchDoesNotExistError
4 from kallithea.lib.vcs.exceptions import BranchDoesNotExistError
5 from kallithea.lib.vcs.utils import ascii_bytes, ascii_str
5 from kallithea.lib.vcs.utils import ascii_bytes, ascii_str, safe_str
6
6
7
7
8 class MercurialWorkdir(BaseWorkdir):
8 class MercurialWorkdir(BaseWorkdir):
9
9
10 def get_branch(self):
10 def get_branch(self):
11 return self.repository._repo.dirstate.branch()
11 return safe_str(self.repository._repo.dirstate.branch())
12
12
13 def get_changeset(self):
13 def get_changeset(self):
14 wk_dir_id = ascii_str(self.repository._repo[None].parents()[0].hex())
14 wk_dir_id = ascii_str(self.repository._repo[None].parents()[0].hex())
15 return self.repository.get_changeset(wk_dir_id)
15 return self.repository.get_changeset(wk_dir_id)
16
16
17 def checkout_branch(self, branch=None):
17 def checkout_branch(self, branch=None):
18 if branch is None:
18 if branch is None:
19 branch = self.repository.DEFAULT_BRANCH_NAME
19 branch = self.repository.DEFAULT_BRANCH_NAME
20 if branch not in self.repository.branches:
20 if branch not in self.repository.branches:
21 raise BranchDoesNotExistError
21 raise BranchDoesNotExistError
22
22
23 raw_id = self.repository.branches[branch]
23 raw_id = self.repository.branches[branch]
24 mercurial.merge.update(self.repository._repo, ascii_bytes(raw_id), False, False, None)
24 mercurial.merge.update(self.repository._repo, ascii_bytes(raw_id), False, False, None)
General Comments 0
You need to be logged in to leave comments. Login now