Rietveld Code Review Tool
Help | Bug tracker | Discussion group | Source code

Side by Side Diff: eyeo-depup/src/vcs.py

Issue 29599579: OffTopic: DependencyUpdater
Patch Set: Integrating into codingtools Created Nov. 20, 2017, 2:58 p.m.
Left:
Right:
Use n/p to move between diff chunks; N/P to move between comments.
Jump to:
View unified diff | Download patch
« no previous file with comments | « eyeo-depup/src/templates/default.trac ('k') | eyeo-depup/tests/data/0.diff » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 # This file is part of Adblock Plus <https://adblockplus.org/>,
2 # Copyright (C) 2006-present eyeo GmbH
3 #
4 # Adblock Plus is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License version 3 as
6 # published by the Free Software Foundation.
7 #
8 # Adblock Plus is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
12 #
13 # You should have received a copy of the GNU General Public License
14 # along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>.
15
16 """VCS related classes for eyeo-depup."""
17
18 from __future__ import print_function, unicode_literals
19
20 import io
21 import json
22 import logging
23 import os
24 import shutil
25 import subprocess
26 import sys
27 import tempfile
28
29 logging.basicConfig()
30 logger = logging.getLogger('vcs')
31
32
33 class Vcs(object):
34 """Baseclass for Git and Mercurial."""
35
36 JSON_DQUOTES = '__DQ__'
37
38 class VcsException(Exception):
39 """Raised when no distinct VCS for a given repository was found."""
40
41 def __init__(self, location, force_clone=False):
42 """Construct a Vcs object for a given location.
43
44 parameters:
45 location: The repository location, may be a local folder or a remote
46 location.
47
48 When the specified location does not exist locally, Vcs will attempt
49 to create a temporary repository, cloned from the given location.
50
51 """
52 self._source, self._repository = os.path.split(location)
53 if not os.path.exists(location) or force_clone:
54 self._make_temporary(location)
55 self._clean_up = True
56 else:
57 self._cwd = location
58 self._clean_up = False
59
60 def __enter__(self):
61 """Enter the object's context."""
62 return self
63
64 def __exit__(self, exc_type, exc_value, traceback):
65 """Exit the object'c context and delete any temporary data."""
66 if self._clean_up:
67 shutil.rmtree(self._cwd)
68
69 @classmethod
70 def is_vcs_for_repo(cls, path):
71 """Assert if cls is a suitable VCS for the given (repository-) path."""
72 return os.path.exists(os.path.join(path, cls.VCS_REQUIREMENT))
73
74 def run_cmd(self, *args, **kwargs):
75 """Run the vcs with the given commands."""
76 cmd = self.BASE_CMD + args
77 try:
78 return subprocess.check_output(
79 cmd,
80 cwd=os.path.join(self._cwd),
81 stderr=subprocess.STDOUT,
82 ).decode('utf-8')
83 except subprocess.CalledProcessError as e:
84 logger.error(e.output.decode('utf-8'))
85 sys.exit(1)
86
87 def _get_latest(self):
88 self.run_cmd(self.UPDATE_LOCAL_HISTORY)
89
90 def _escape_changes(self, changes):
91 return changes.replace('"', '\\"').replace(self.JSON_DQUOTES, '"')
92
93 def _changes_as_json(self, changes):
94 return json.loads(
95 '[{}]'.format(','.join(
96 self._escape_changes(changes).strip().splitlines()
97 )))
98
99 def merged_diff(self, rev_a, rev_b, n_unified=16):
100 """Invoke the VCS' functionality to create a unified diff.
101
102 Parameters:
103 rev_a: The revision representing the start.
104 rev_b: The revision representing the end. Defaults to
105 Cls.DEFAULT_NEW_REVISION
106 n_unified: The amount of context lines to add to the diff.
107
108 """
109 return self.run_cmd('diff', '--unified=' + str(n_unified),
110 *(self._rev_comb(
111 rev_a,
112 rev_b or self.DEFAULT_NEW_REVISION)))
113
114 def change_list(self, rev_a, rev_b):
115 """Return the repository's history from revisions a to b as JSON.
116
117 Parameters:
118 rev_a: The revision representing the start.
119 rev_b: The revision representing the end. Defaults to
120 Cls.DEFAULT_NEW_REVISION
121
122 """
123 self._get_latest()
124
125 log_format = self._log_format()
126 rev_cmd = self._rev_comb(rev_a, rev_b or self.DEFAULT_NEW_REVISION)
127
128 changes = self.run_cmd(*('log',) + log_format + rev_cmd)
129 return self._changes_as_json(changes)
130
131 def enhance_changes_information(self, changes, dependency_location, fake):
132 """Enhance the change list with matching revisions from a mirror.
133
134 Parameters:
135 changes: The list to enhance, containing dictionaries
136 with the keys "hash", "author", "date" and
137 "message".
138 dependency_location: The (remote or locale) location of the
139 repository, which is supposed to be the mirror
140 for the current repository.
141 fake {True, False}: Causes atual processing of a mirror repository
142 (False) or fakes values (True)
143
144 """
145 self_ex = self.EXECUTABLE
146 mirr_ex = self._other_cls.EXECUTABLE
147
148 if not fake:
149 with self._other_cls(dependency_location) as mirror:
150 mirror._get_latest()
151
152 mirrored_hashes = {
153 change['hash']: mirror.matching_hash(change['author'],
154 change['date'],
155 change['message'])
156 for change in changes
157 }
158 else:
159 mirrored_hashes = {}
160
161 for change in changes:
162 change[self_ex + '_url'] = self.REVISION_URL.format(
163 repository=self._repository, revision=change['hash'])
164 change[self_ex + '_hash'] = change['hash']
165
166 mirrored_hash = mirrored_hashes.get(change['hash'], 'NO MIRROR')
167 del change['hash']
168
169 change[mirr_ex + '_url'] = self._other_cls.REVISION_URL.format(
170 repository=self._repository, revision=mirrored_hash)
171 change[mirr_ex + '_hash'] = mirrored_hash
172
173 @staticmethod
174 def factory(location, force_clone=False):
175 """Get a suiting Vcs instance for the given repository path."""
176 obj = None
177 for cls in [Git, Mercurial]:
178 if cls.is_vcs_for_repo(location):
179 if obj is not None:
180 raise Vcs.VcsException(
181 "Found multiple possible VCS' for " + location)
182 obj = cls(location, force_clone)
183
184 if obj is None:
185 raise Vcs.VcsException('No valid VCS found for ' + location)
186 return obj
187
188
189 class Mercurial(Vcs):
190 """Mercurial specialization of VCS."""
191
192 EXECUTABLE = 'hg'
193 VCS_REQUIREMENT = '.hg'
194 BASE_CMD = (EXECUTABLE, '--config', 'defaults.log=', '--config',
195 'defaults.pull=', '--config', 'defaults.diff=')
196 UPDATE_LOCAL_HISTORY = 'pull'
197 LOG_TEMLATE = ('\\{"hash":"{node|short}","author":"{author|person}",'
198 '"date":"{date|rfc822date}","message":"{desc|strip|'
199 'firstline}"}\n')
200 DEFAULT_NEW_REVISION = 'master'
201
202 REVISION_URL = 'https://hg.adblockplus.org/{repository}/rev/{revision}'
203
204 def __init__(self, *args):
205 """Construct a Mercurial object and specify Git as the mirror class."""
206 self._other_cls = Git
207 super(Mercurial, self).__init__(*args)
208
209 def _rev_comb(self, rev_a, rev_b):
210 # Only take into account those changesets, which are actually affecting
211 # the repository's content. See
212 # https://www.mercurial-scm.org/repo/hg/help/revsets
213 return ('-r', '{}::{}'.format(rev_a, rev_b))
214
215 def _log_format(self):
216 log_format = self.LOG_TEMLATE.replace('"', self.JSON_DQUOTES)
217 return ('--template', log_format)
218
219 def change_list(self, *args):
220 """Apply measures for hg log and call Vcs's change_list."""
221 # Mercurial's command for producing a log between revisions using the
222 # revision set produced by self._rev_comb returns the changesets in a
223 # reversed order. Additionally the current revision is returned.
224 return list(reversed(super(Mercurial, self).change_list(*args)[1:]))
225
226 def matching_hash(self, author, date, message):
227 """Get the responsible commit for the given information.
228
229 A commit must stafisy equailty for author, date and commit message, in
230 order to be recognized as the matching commit.
231
232 """
233 return self.run_cmd('log', '-u', author, '-d', date, '--keyword',
234 message, '--template', '{node|short}')
235
236 def _make_temporary(self, location):
237 self._cwd = tempfile.mkdtemp()
238 os.mkdir(os.path.join(self._cwd, '.hg'))
239
240 with io.open(os.path.join(self._cwd, '.hg', 'hgrc'), 'w') as fp:
241 fp.write('[paths]{}default = {}{}'.format(os.linesep, location,
242 os.linesep))
243
244 def commit_changes(self, msg):
245 """Add any local changes and commit the with <msg>."""
246 self.run_cmd('commit', '-m', msg)
247
248 def undo_changes(self):
249 """Undo all changes in local repsitory and leave no backup."""
250 self.run_cmd('revert', '--all', '--no-backup')
251
252 def repo_is_clean(self):
253 """Check whether the current repository is clean."""
254 buff = self.run_cmd('status')
255 return len(buff) == 0
256
257
258 class Git(Vcs):
259 """Git specialization of Vcs."""
260
261 EXECUTABLE = 'git'
262 VCS_REQUIREMENT = '.git'
263 BASE_CMD = (EXECUTABLE,)
264 UPDATE_LOCAL_HISTORY = 'fetch'
265 LOG_TEMLATE = '{"hash":"%h","author":"%an","date":"%aD","message":"%s"}'
266 DEFAULT_NEW_REVISION = 'origin/master'
267
268 REVISION_URL = ('https://www.github.com/adblockplus/{repository}/commit/'
269 '{revision}')
270
271 def __init__(self, *args):
272 """Construct a Git object and specify Mercurial as the mirror class."""
273 self._other_cls = Mercurial
274 super(Git, self).__init__(*args)
275
276 def _rev_comb(self, rev_a, rev_b):
277 return ('{}..{}'.format(rev_a, rev_b),)
278
279 def _log_format(self):
280 return ('--pretty=format:{}'.format(self.LOG_TEMLATE.replace(
281 '"', self.JSON_DQUOTES)),)
282
283 def matching_hash(self, author, date, message):
284 """Get the responsible commit for the given information.
285
286 A commit must stafisy equailty for author, date and commit message, in
287 order to be recognized as the matching commit.
288
289 """
290 # Git does not implement exact date matching directly. Additionally,
291 # git is only capable of filtering by COMMIT DATE instead of
292 # AUTHOR DATE (which is what we are actually looking for), see
293 # https://stackoverflow.com/q/37311494/
294 # Since naturally the COMMIT DATE allways is later then the AUTHOR
295 # DATE, we are at least able to limit the valid range to after our
296 # given date
297 result = self.run_cmd('log', '--author={}'.format(author),
298 '--grep={}'.format(message),
299 '--after={}'.format(date), '--pretty=format:%h')
300 if len(result.split()) > 1:
301 raise Vcs.VcsException('FATAL: Ambiguous commit filter!')
302 return result
303
304 def _make_temporary(self, location):
305 self._cwd = tempfile.mkdtemp()
306 self.run_cmd('clone', '--bare', location, self._cwd)
307
308 def commit_changes(self, msg):
309 """Add any local changes and commit the with <msg>."""
310 self.run_cmd('add', '.')
311 self.run_cmd('commit', '-m', msg)
312
313 def undo_changes(self):
314 """Undo all changes in local repsitory."""
315 self.run_cmd('checkout', '.')
316
317 def repo_is_clean(self):
318 """Check whether the current repository is clean."""
319 # unstaged changes
320 no_uncommited = len(self.run_cmd('diff-index', 'HEAD', '--')) == 0
321 # untracked changes
322 no_untracked = len(self.run_cmd('ls-files', '-o', '-d',
323 '--exclude-standard')) == 0
324 return no_uncommited and no_untracked
OLDNEW
« no previous file with comments | « eyeo-depup/src/templates/default.trac ('k') | eyeo-depup/tests/data/0.diff » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld