62 # release number. |
62 # release number. |
63 MELANGE_RELEASE_RE = re.compile(r'\d-\d-\d{8}p\d+') |
63 MELANGE_RELEASE_RE = re.compile(r'\d-\d-\d{8}p\d+') |
64 |
64 |
65 |
65 |
66 class Error(error.Error): |
66 class Error(error.Error): |
67 pass |
67 pass |
68 |
68 |
69 |
69 |
70 class AbortedByUser(Error): |
70 class AbortedByUser(Error): |
71 """The operation was aborted by the user.""" |
71 """The operation was aborted by the user.""" |
72 |
72 |
73 |
73 |
74 class ObstructionError(Error): |
74 class ObstructionError(Error): |
75 """An operation was obstructed by existing data.""" |
75 """An operation was obstructed by existing data.""" |
76 |
76 |
77 |
77 |
78 class ExpectationFailed(Error): |
78 class ExpectationFailed(Error): |
79 """An unexpected state was encountered by an automated step.""" |
79 """An unexpected state was encountered by an automated step.""" |
80 |
80 |
81 |
81 |
82 class FileAccessError(Error): |
82 class FileAccessError(Error): |
83 """An error occured while accessing a file.""" |
83 """An error occured while accessing a file.""" |
84 |
84 |
85 |
85 |
86 def confirm(prompt, default=False): |
86 def confirm(prompt, default=False): |
87 """Ask a yes/no question and return the answer. |
87 """Ask a yes/no question and return the answer. |
88 |
88 |
89 Will reprompt the user until one of "yes", "no", "y" or "n" is |
89 Will reprompt the user until one of "yes", "no", "y" or "n" is |
90 entered. The input is case insensitive. |
90 entered. The input is case insensitive. |
91 |
91 |
92 Args: |
92 Args: |
93 prompt: The question to ask the user. |
93 prompt: The question to ask the user. |
94 default: The answer to return if the user just hits enter. |
94 default: The answer to return if the user just hits enter. |
|
95 |
|
96 Returns: |
|
97 True if the user answered affirmatively, False otherwise. |
|
98 """ |
|
99 if default: |
|
100 question = prompt + ' [Yn] ' |
|
101 else: |
|
102 question = prompt + ' [yN] ' |
|
103 while True: |
|
104 try: |
|
105 answer = raw_input(question).strip().lower() |
|
106 except EOFError: |
|
107 raise AbortedByUser('Aborted by ctrl+D') |
|
108 if not answer: |
|
109 return default |
|
110 elif answer in ('y', 'yes'): |
|
111 return True |
|
112 elif answer in ('n', 'no'): |
|
113 return False |
|
114 else: |
|
115 log.error('Please answer yes or no.') |
|
116 |
|
117 |
|
118 def getString(prompt): |
|
119 """Prompt for and return a string.""" |
|
120 try: |
|
121 return raw_input(prompt + ' ').strip() |
|
122 except EOFError: |
|
123 raise AbortedByUser('Aborted by ctrl+D') |
|
124 |
|
125 |
|
126 def getNumber(prompt): |
|
127 """Prompt for and return a number. |
|
128 |
|
129 Will reprompt the user until a number is entered. |
|
130 """ |
|
131 while True: |
|
132 value_str = getString(prompt) |
|
133 try: |
|
134 return int(value_str) |
|
135 except ValueError: |
|
136 log.error('Please enter a number. You entered "%s".' % value_str) |
|
137 |
|
138 |
|
139 def getChoice(intro, prompt, choices, done=None, suggest=None): |
|
140 """Prompt for and return a choice from a menu. |
|
141 |
|
142 Will reprompt the user until a valid menu entry is chosen. |
|
143 |
|
144 Args: |
|
145 intro: Text to print verbatim before the choice menu. |
|
146 prompt: The prompt to print right before accepting input. |
|
147 choices: The list of string choices to display. |
|
148 done: If not None, the list of indices of previously |
|
149 selected/completed choices. |
|
150 suggest: If not None, the index of the choice to highlight as |
|
151 the suggested choice. |
|
152 |
|
153 Returns: |
|
154 The index in the choices list of the selection the user made. |
|
155 """ |
|
156 done = set(done or []) |
|
157 while True: |
|
158 print intro |
|
159 print |
|
160 for i, entry in enumerate(choices): |
|
161 done_text = ' (done)' if i in done else '' |
|
162 indent = '--> ' if i == suggest else ' ' |
|
163 print '%s%2d. %s%s' % (indent, i+1, entry, done_text) |
|
164 print |
|
165 choice = getNumber(prompt) |
|
166 if 0 < choice <= len(choices): |
|
167 return choice-1 |
|
168 log.error('%d is not a valid choice between %d and %d' % |
|
169 (choice, 1, len(choices))) |
|
170 print |
|
171 |
|
172 |
|
173 def fileToLines(path): |
|
174 """Read a file and return it as a list of lines.""" |
|
175 try: |
|
176 with file(path) as f: |
|
177 return f.read().split('\n') |
|
178 except (IOError, OSError), e: |
|
179 raise FileAccessError(str(e)) |
|
180 |
|
181 |
|
182 def linesToFile(path, lines): |
|
183 """Write a list of lines to a file.""" |
|
184 try: |
|
185 with file(path, 'w') as f: |
|
186 f.write('\n'.join(lines)) |
|
187 except (IOError, OSError), e: |
|
188 raise FileAccessError(str(e)) |
|
189 |
|
190 |
|
191 class Subversion(util.Paths): |
|
192 """Wrapper for operations on a Subversion working copy. |
|
193 |
|
194 An instance of this class is bound to a specific working copy |
|
195 directory, and provides an API to perform various Subversion |
|
196 operations on this working copy. |
|
197 |
|
198 Some methods take a 'depth' argument. Depth in Subversion is a |
|
199 feature that allows the creation of arbitrarily shallow or deep |
|
200 working copies on a per-directory basis. Possible values are |
|
201 'none' (no files or directories), 'files' (only files in .), |
|
202 'immediates' (files and directories in ., directories checked out |
|
203 at depth 'none') or 'infinity' (a normal working copy with |
|
204 everything). |
|
205 |
|
206 This class also provides a few static functions that run the 'svn' |
|
207 tool against remote repositories to gather information or retrieve |
|
208 data. |
|
209 |
|
210 Note that this wrapper also doubles as a Paths object, offering an |
|
211 easy way to get or check the existence of paths in the working |
|
212 copy. |
|
213 """ |
|
214 |
|
215 def __init__(self, wc_dir): |
|
216 util.Paths.__init__(self, wc_dir) |
|
217 |
|
218 def _unknownAndMissing(self, path): |
|
219 """Returns lists of unknown and missing files in the working copy. |
|
220 |
|
221 Args: |
|
222 path: The working copy path to scan. |
95 |
223 |
96 Returns: |
224 Returns: |
97 True if the user answered affirmatively, False otherwise. |
225 |
98 """ |
226 Two lists. The first is a list of all unknown paths |
99 if default: |
227 (subversion has no knowledge of them), the second is a list |
100 question = prompt + ' [Yn] ' |
228 of missing paths (subversion knows about them, but can't |
|
229 find them). Paths in either list are relative to the input |
|
230 path. |
|
231 """ |
|
232 assert self.exists() |
|
233 unknown = [] |
|
234 missing = [] |
|
235 for line in self.status(path): |
|
236 if not line.strip(): |
|
237 continue |
|
238 if line[0] == '?': |
|
239 unknown.append(line[7:]) |
|
240 elif line[0] == '!': |
|
241 missing.append(line[7:]) |
|
242 return unknown, missing |
|
243 |
|
244 def checkout(self, url, depth='infinity'): |
|
245 """Check out a working copy from the given URL. |
|
246 |
|
247 Args: |
|
248 url: The Subversion repository URL to check out. |
|
249 depth: The depth of the working copy root. |
|
250 """ |
|
251 assert not self.exists() |
|
252 util.run(['svn', 'checkout', '--depth=' + depth, url, self.path()]) |
|
253 |
|
254 def update(self, path='', depth=None): |
|
255 """Update a working copy path, optionally changing depth. |
|
256 |
|
257 Args: |
|
258 path: The working copy path to update. |
|
259 depth: If set, change the depth of the path before updating. |
|
260 """ |
|
261 assert self.exists() |
|
262 if depth is None: |
|
263 util.run(['svn', 'update', self.path(path)]) |
101 else: |
264 else: |
102 question = prompt + ' [yN] ' |
265 util.run(['svn', 'update', '--set-depth=' + depth, self.path(path)]) |
103 while True: |
266 |
104 try: |
267 def revert(self, path=''): |
105 answer = raw_input(question).strip().lower() |
268 """Recursively revert a working copy path. |
106 except EOFError: |
269 |
107 raise AbortedByUser('Aborted by ctrl+D') |
270 Note that this command is more zealous than the 'svn revert' |
108 if not answer: |
271 command, as it will also delete any files which subversion |
109 return default |
272 does not know about. |
110 elif answer in ('y', 'yes'): |
273 """ |
111 return True |
274 util.run(['svn', 'revert', '-R', self.path(path)]) |
112 elif answer in ('n', 'no'): |
275 |
113 return False |
276 unknown, missing = self._unknownAndMissing(path) |
114 else: |
277 unknown = [os.path.join(self.path(path), p) for p in unknown] |
115 log.error('Please answer yes or no.') |
278 |
116 |
279 if unknown: |
117 |
280 # rm -rf makes me uneasy. Verify that all paths to be deleted |
118 def getString(prompt): |
281 # are within the release working copy. |
119 """Prompt for and return a string.""" |
282 for p in unknown: |
|
283 assert p.startswith(self.path()) |
|
284 |
|
285 util.run(['rm', '-rf', '--'] + unknown) |
|
286 |
|
287 def ls(self, dir=''): |
|
288 """List the contents of a working copy directory. |
|
289 |
|
290 Note that this returns the contents of the directory as seen |
|
291 by the server, not constrained by the depth settings of the |
|
292 local path. |
|
293 """ |
|
294 assert self.exists() |
|
295 return util.run(['svn', 'ls', self.path(dir)], capture=True) |
|
296 |
|
297 def copy(self, src, dest): |
|
298 """Copy a working copy path. |
|
299 |
|
300 The copy is only scheduled for commit, not committed. |
|
301 |
|
302 Args: |
|
303 src: The source working copy path. |
|
304 dst: The destination working copy path. |
|
305 """ |
|
306 assert self.exists() |
|
307 util.run(['svn', 'cp', self.path(src), self.path(dest)]) |
|
308 |
|
309 def propget(self, prop_name, path): |
|
310 """Get the value of a property on a working copy path. |
|
311 |
|
312 Args: |
|
313 prop_name: The property name, eg. 'svn:externals'. |
|
314 path: The working copy path on which the property is set. |
|
315 """ |
|
316 assert self.exists() |
|
317 return util.run(['svn', 'propget', prop_name, self.path(path)], |
|
318 capture=True) |
|
319 |
|
320 def propset(self, prop_name, prop_value, path): |
|
321 """Set the value of a property on a working copy path. |
|
322 |
|
323 The property change is only scheduled for commit, not committed. |
|
324 |
|
325 Args: |
|
326 prop_name: The property name, eg. 'svn:externals'. |
|
327 prop_value: The value that should be set. |
|
328 path: The working copy path on which to set the property. |
|
329 """ |
|
330 assert self.exists() |
|
331 util.run(['svn', 'propset', prop_name, prop_value, self.path(path)]) |
|
332 |
|
333 def add(self, paths): |
|
334 """Schedule working copy paths for addition. |
|
335 |
|
336 The paths are only scheduled for addition, not committed. |
|
337 |
|
338 Args: |
|
339 paths: The list of working copy paths to add. |
|
340 """ |
|
341 assert self.exists() |
|
342 paths = [self.path(p) for p in paths] |
|
343 util.run(['svn', 'add'] + paths) |
|
344 |
|
345 def remove(self, paths): |
|
346 """Schedule working copy paths for deletion. |
|
347 |
|
348 The paths are only scheduled for deletion, not committed. |
|
349 |
|
350 Args: |
|
351 paths: The list of working copy paths to delete. |
|
352 """ |
|
353 assert self.exists() |
|
354 paths = [self.path(p) for p in paths] |
|
355 util.run(['svn', 'rm'] + paths) |
|
356 |
|
357 def status(self, path=''): |
|
358 """Return the status of a working copy path. |
|
359 |
|
360 The status returned is the verbatim output of 'svn status' on |
|
361 the path. |
|
362 |
|
363 Args: |
|
364 path: The path to examine. |
|
365 """ |
|
366 assert self.exists() |
|
367 return util.run(['svn', 'status', self.path(path)], capture=True) |
|
368 |
|
369 def addRemove(self, path=''): |
|
370 """Perform an "addremove" operation a working copy path. |
|
371 |
|
372 An "addremove" runs 'svn status' and schedules all the unknown |
|
373 paths (listed as '?') for addition, and all the missing paths |
|
374 (listed as '!') for deletion. Its main use is to synchronize |
|
375 working copy state after applying a patch in unified diff |
|
376 format. |
|
377 |
|
378 Args: |
|
379 path: The path under which unknown/missing files should be |
|
380 added/removed. |
|
381 """ |
|
382 assert self.exists() |
|
383 unknown, missing = self._unknownAndMissing(path) |
|
384 if unknown: |
|
385 self.add(unknown) |
|
386 if missing: |
|
387 self.remove(missing) |
|
388 |
|
389 def commit(self, message, path=''): |
|
390 """Commit scheduled changes to the source repository. |
|
391 |
|
392 Args: |
|
393 message: The commit message to use. |
|
394 path: The path to commit. |
|
395 """ |
|
396 assert self.exists() |
|
397 util.run(['svn', 'commit', '-m', message, self.path(path)]) |
|
398 |
|
399 @staticmethod |
|
400 def export(url, revision, dest_path): |
|
401 """Export the contents of a repository to a local path. |
|
402 |
|
403 Note that while the underlying 'svn export' only requires a |
|
404 URL, we require that both a URL and a revision be specified, |
|
405 to fully qualify the data to export. |
|
406 |
|
407 Args: |
|
408 url: The repository URL to export. |
|
409 revision: The revision to export. |
|
410 dest_path: The destination directory for the export. Note |
|
411 that this is an absolute path, NOT a working copy |
|
412 relative path. |
|
413 """ |
|
414 assert os.path.isabs(dest_path) |
|
415 if os.path.exists(dest_path): |
|
416 raise ObstructionError('Cannot export to obstructed path %s' % |
|
417 dest_path) |
|
418 util.run(['svn', 'export', '-r', str(revision), url, dest_path]) |
|
419 |
|
420 @staticmethod |
|
421 def find_tag_rev(url): |
|
422 """Return the revision at which a remote tag was created. |
|
423 |
|
424 Since tags are immutable by convention, usually the HEAD of a |
|
425 tag should be the tag creation revision. However, mistakes can |
|
426 happen, so this function will walk the history of the given |
|
427 tag URL, stopping on the first revision that was created by |
|
428 copy. |
|
429 |
|
430 This detection is not foolproof. For example: it will be |
|
431 fooled by a tag that was created, deleted, and recreated by |
|
432 copy at a different revision. It is not clear what the desired |
|
433 behavior in these edge cases are, and no attempt is made to |
|
434 handle them. You should request user confirmation before using |
|
435 the result of this function. |
|
436 |
|
437 Args: |
|
438 url: The repository URL of the tag to examine. |
|
439 """ |
120 try: |
440 try: |
121 return raw_input(prompt + ' ').strip() |
441 output = util.run(['svn', 'log', '-q', '--stop-on-copy', url], |
122 except EOFError: |
442 capture=True) |
123 raise AbortedByUser('Aborted by ctrl+D') |
443 except util.SubprocessFailed: |
124 |
444 raise ExpectationFailed('No tag at URL ' + url) |
125 |
445 first_rev_line = output[-2] |
126 def getNumber(prompt): |
446 first_rev = int(first_rev_line.split()[0][1:]) |
127 """Prompt for and return a number. |
447 return first_rev |
128 |
448 |
129 Will reprompt the user until a number is entered. |
449 @staticmethod |
130 """ |
450 def diff(url, revision): |
131 while True: |
451 """Retrieve a revision from a remote repository as a unified diff. |
132 value_str = getString(prompt) |
452 |
133 try: |
453 Args: |
134 return int(value_str) |
454 url: The repository URL on which to perform the diff. |
135 except ValueError: |
455 revision: The revision to extract at the given url. |
136 log.error('Please enter a number. You entered "%s".' % value_str) |
|
137 |
|
138 |
|
139 def getChoice(intro, prompt, choices, done=None, suggest=None): |
|
140 """Prompt for and return a choice from a menu. |
|
141 |
|
142 Will reprompt the user until a valid menu entry is chosen. |
|
143 |
|
144 Args: |
|
145 intro: Text to print verbatim before the choice menu. |
|
146 prompt: The prompt to print right before accepting input. |
|
147 choices: The list of string choices to display. |
|
148 done: If not None, the list of indices of previously |
|
149 selected/completed choices. |
|
150 suggest: If not None, the index of the choice to highlight as |
|
151 the suggested choice. |
|
152 |
456 |
153 Returns: |
457 Returns: |
154 The index in the choices list of the selection the user made. |
458 A string containing the changes extracted from the remote |
155 """ |
459 repository, in unified diff format suitable for application |
156 done = set(done or []) |
460 using 'patch'. |
157 while True: |
461 """ |
158 print intro |
|
159 print |
|
160 for i, entry in enumerate(choices): |
|
161 done_text = ' (done)' if i in done else '' |
|
162 indent = '--> ' if i == suggest else ' ' |
|
163 print '%s%2d. %s%s' % (indent, i+1, entry, done_text) |
|
164 print |
|
165 choice = getNumber(prompt) |
|
166 if 0 < choice <= len(choices): |
|
167 return choice-1 |
|
168 log.error('%d is not a valid choice between %d and %d' % |
|
169 (choice, 1, len(choices))) |
|
170 print |
|
171 |
|
172 |
|
173 def fileToLines(path): |
|
174 """Read a file and return it as a list of lines.""" |
|
175 try: |
462 try: |
176 with file(path) as f: |
463 return util.run(['svn', 'diff', '-c', str(revision), url], |
177 return f.read().split('\n') |
464 capture=True, split_capture=False) |
178 except (IOError, OSError), e: |
465 except util.SubprocessFailed: |
179 raise FileAccessError(str(e)) |
466 raise ExpectationFailed('Could not get diff for r%d ' |
180 |
467 'from remote repository' % revision) |
181 |
|
182 def linesToFile(path, lines): |
|
183 """Write a list of lines to a file.""" |
|
184 try: |
|
185 with file(path, 'w') as f: |
|
186 f.write('\n'.join(lines)) |
|
187 except (IOError, OSError), e: |
|
188 raise FileAccessError(str(e)) |
|
189 |
|
190 |
|
191 class Subversion(util.Paths): |
|
192 """Wrapper for operations on a Subversion working copy. |
|
193 |
|
194 An instance of this class is bound to a specific working copy |
|
195 directory, and provides an API to perform various Subversion |
|
196 operations on this working copy. |
|
197 |
|
198 Some methods take a 'depth' argument. Depth in Subversion is a |
|
199 feature that allows the creation of arbitrarily shallow or deep |
|
200 working copies on a per-directory basis. Possible values are |
|
201 'none' (no files or directories), 'files' (only files in .), |
|
202 'immediates' (files and directories in ., directories checked out |
|
203 at depth 'none') or 'infinity' (a normal working copy with |
|
204 everything). |
|
205 |
|
206 This class also provides a few static functions that run the 'svn' |
|
207 tool against remote repositories to gather information or retrieve |
|
208 data. |
|
209 |
|
210 Note that this wrapper also doubles as a Paths object, offering an |
|
211 easy way to get or check the existence of paths in the working |
|
212 copy. |
|
213 """ |
|
214 |
|
215 def __init__(self, wc_dir): |
|
216 util.Paths.__init__(self, wc_dir) |
|
217 |
|
218 def _unknownAndMissing(self, path): |
|
219 """Returns lists of unknown and missing files in the working copy. |
|
220 |
|
221 Args: |
|
222 path: The working copy path to scan. |
|
223 |
|
224 Returns: |
|
225 |
|
226 Two lists. The first is a list of all unknown paths |
|
227 (subversion has no knowledge of them), the second is a list |
|
228 of missing paths (subversion knows about them, but can't |
|
229 find them). Paths in either list are relative to the input |
|
230 path. |
|
231 """ |
|
232 assert self.exists() |
|
233 unknown = [] |
|
234 missing = [] |
|
235 for line in self.status(path): |
|
236 if not line.strip(): |
|
237 continue |
|
238 if line[0] == '?': |
|
239 unknown.append(line[7:]) |
|
240 elif line[0] == '!': |
|
241 missing.append(line[7:]) |
|
242 return unknown, missing |
|
243 |
|
244 def checkout(self, url, depth='infinity'): |
|
245 """Check out a working copy from the given URL. |
|
246 |
|
247 Args: |
|
248 url: The Subversion repository URL to check out. |
|
249 depth: The depth of the working copy root. |
|
250 """ |
|
251 assert not self.exists() |
|
252 util.run(['svn', 'checkout', '--depth=' + depth, url, self.path()]) |
|
253 |
|
254 def update(self, path='', depth=None): |
|
255 """Update a working copy path, optionally changing depth. |
|
256 |
|
257 Args: |
|
258 path: The working copy path to update. |
|
259 depth: If set, change the depth of the path before updating. |
|
260 """ |
|
261 assert self.exists() |
|
262 if depth is None: |
|
263 util.run(['svn', 'update', self.path(path)]) |
|
264 else: |
|
265 util.run(['svn', 'update', '--set-depth=' + depth, self.path(path)]) |
|
266 |
|
267 def revert(self, path=''): |
|
268 """Recursively revert a working copy path. |
|
269 |
|
270 Note that this command is more zealous than the 'svn revert' |
|
271 command, as it will also delete any files which subversion |
|
272 does not know about. |
|
273 """ |
|
274 util.run(['svn', 'revert', '-R', self.path(path)]) |
|
275 |
|
276 unknown, missing = self._unknownAndMissing(path) |
|
277 unknown = [os.path.join(self.path(path), p) for p in unknown] |
|
278 |
|
279 if unknown: |
|
280 # rm -rf makes me uneasy. Verify that all paths to be deleted |
|
281 # are within the release working copy. |
|
282 for p in unknown: |
|
283 assert p.startswith(self.path()) |
|
284 |
|
285 util.run(['rm', '-rf', '--'] + unknown) |
|
286 |
|
287 def ls(self, dir=''): |
|
288 """List the contents of a working copy directory. |
|
289 |
|
290 Note that this returns the contents of the directory as seen |
|
291 by the server, not constrained by the depth settings of the |
|
292 local path. |
|
293 """ |
|
294 assert self.exists() |
|
295 return util.run(['svn', 'ls', self.path(dir)], capture=True) |
|
296 |
|
297 def copy(self, src, dest): |
|
298 """Copy a working copy path. |
|
299 |
|
300 The copy is only scheduled for commit, not committed. |
|
301 |
|
302 Args: |
|
303 src: The source working copy path. |
|
304 dst: The destination working copy path. |
|
305 """ |
|
306 assert self.exists() |
|
307 util.run(['svn', 'cp', self.path(src), self.path(dest)]) |
|
308 |
|
309 def propget(self, prop_name, path): |
|
310 """Get the value of a property on a working copy path. |
|
311 |
|
312 Args: |
|
313 prop_name: The property name, eg. 'svn:externals'. |
|
314 path: The working copy path on which the property is set. |
|
315 """ |
|
316 assert self.exists() |
|
317 return util.run(['svn', 'propget', prop_name, self.path(path)], |
|
318 capture=True) |
|
319 |
|
320 def propset(self, prop_name, prop_value, path): |
|
321 """Set the value of a property on a working copy path. |
|
322 |
|
323 The property change is only scheduled for commit, not committed. |
|
324 |
|
325 Args: |
|
326 prop_name: The property name, eg. 'svn:externals'. |
|
327 prop_value: The value that should be set. |
|
328 path: The working copy path on which to set the property. |
|
329 """ |
|
330 assert self.exists() |
|
331 util.run(['svn', 'propset', prop_name, prop_value, self.path(path)]) |
|
332 |
|
333 def add(self, paths): |
|
334 """Schedule working copy paths for addition. |
|
335 |
|
336 The paths are only scheduled for addition, not committed. |
|
337 |
|
338 Args: |
|
339 paths: The list of working copy paths to add. |
|
340 """ |
|
341 assert self.exists() |
|
342 paths = [self.path(p) for p in paths] |
|
343 util.run(['svn', 'add'] + paths) |
|
344 |
|
345 def remove(self, paths): |
|
346 """Schedule working copy paths for deletion. |
|
347 |
|
348 The paths are only scheduled for deletion, not committed. |
|
349 |
|
350 Args: |
|
351 paths: The list of working copy paths to delete. |
|
352 """ |
|
353 assert self.exists() |
|
354 paths = [self.path(p) for p in paths] |
|
355 util.run(['svn', 'rm'] + paths) |
|
356 |
|
357 def status(self, path=''): |
|
358 """Return the status of a working copy path. |
|
359 |
|
360 The status returned is the verbatim output of 'svn status' on |
|
361 the path. |
|
362 |
|
363 Args: |
|
364 path: The path to examine. |
|
365 """ |
|
366 assert self.exists() |
|
367 return util.run(['svn', 'status', self.path(path)], capture=True) |
|
368 |
|
369 def addRemove(self, path=''): |
|
370 """Perform an "addremove" operation a working copy path. |
|
371 |
|
372 An "addremove" runs 'svn status' and schedules all the unknown |
|
373 paths (listed as '?') for addition, and all the missing paths |
|
374 (listed as '!') for deletion. Its main use is to synchronize |
|
375 working copy state after applying a patch in unified diff |
|
376 format. |
|
377 |
|
378 Args: |
|
379 path: The path under which unknown/missing files should be |
|
380 added/removed. |
|
381 """ |
|
382 assert self.exists() |
|
383 unknown, missing = self._unknownAndMissing(path) |
|
384 if unknown: |
|
385 self.add(unknown) |
|
386 if missing: |
|
387 self.remove(missing) |
|
388 |
|
389 def commit(self, message, path=''): |
|
390 """Commit scheduled changes to the source repository. |
|
391 |
|
392 Args: |
|
393 message: The commit message to use. |
|
394 path: The path to commit. |
|
395 """ |
|
396 assert self.exists() |
|
397 util.run(['svn', 'commit', '-m', message, self.path(path)]) |
|
398 |
|
399 @staticmethod |
|
400 def export(url, revision, dest_path): |
|
401 """Export the contents of a repository to a local path. |
|
402 |
|
403 Note that while the underlying 'svn export' only requires a |
|
404 URL, we require that both a URL and a revision be specified, |
|
405 to fully qualify the data to export. |
|
406 |
|
407 Args: |
|
408 url: The repository URL to export. |
|
409 revision: The revision to export. |
|
410 dest_path: The destination directory for the export. Note |
|
411 that this is an absolute path, NOT a working copy |
|
412 relative path. |
|
413 """ |
|
414 assert os.path.isabs(dest_path) |
|
415 if os.path.exists(dest_path): |
|
416 raise ObstructionError('Cannot export to obstructed path %s' % |
|
417 dest_path) |
|
418 util.run(['svn', 'export', '-r', str(revision), url, dest_path]) |
|
419 |
|
420 @staticmethod |
|
421 def find_tag_rev(url): |
|
422 """Return the revision at which a remote tag was created. |
|
423 |
|
424 Since tags are immutable by convention, usually the HEAD of a |
|
425 tag should be the tag creation revision. However, mistakes can |
|
426 happen, so this function will walk the history of the given |
|
427 tag URL, stopping on the first revision that was created by |
|
428 copy. |
|
429 |
|
430 This detection is not foolproof. For example: it will be |
|
431 fooled by a tag that was created, deleted, and recreated by |
|
432 copy at a different revision. It is not clear what the desired |
|
433 behavior in these edge cases are, and no attempt is made to |
|
434 handle them. You should request user confirmation before using |
|
435 the result of this function. |
|
436 |
|
437 Args: |
|
438 url: The repository URL of the tag to examine. |
|
439 """ |
|
440 try: |
|
441 output = util.run(['svn', 'log', '-q', '--stop-on-copy', url], |
|
442 capture=True) |
|
443 except util.SubprocessFailed: |
|
444 raise ExpectationFailed('No tag at URL ' + url) |
|
445 first_rev_line = output[-2] |
|
446 first_rev = int(first_rev_line.split()[0][1:]) |
|
447 return first_rev |
|
448 |
|
449 @staticmethod |
|
450 def diff(url, revision): |
|
451 """Retrieve a revision from a remote repository as a unified diff. |
|
452 |
|
453 Args: |
|
454 url: The repository URL on which to perform the diff. |
|
455 revision: The revision to extract at the given url. |
|
456 |
|
457 Returns: |
|
458 A string containing the changes extracted from the remote |
|
459 repository, in unified diff format suitable for application |
|
460 using 'patch'. |
|
461 """ |
|
462 try: |
|
463 return util.run(['svn', 'diff', '-c', str(revision), url], |
|
464 capture=True, split_capture=False) |
|
465 except util.SubprocessFailed: |
|
466 raise ExpectationFailed('Could not get diff for r%d ' |
|
467 'from remote repository' % revision) |
|
468 |
468 |
469 |
469 |
470 # |
470 # |
471 # Decorators for use in ReleaseEnvironment. |
471 # Decorators for use in ReleaseEnvironment. |
472 # |
472 # |
473 def pristine_wc(f): |
473 def pristine_wc(f): |
474 """A decorator that cleans up the release repository.""" |
474 """A decorator that cleans up the release repository.""" |
475 @functools.wraps(f) |
475 @functools.wraps(f) |
476 def revert_wc(self, *args, **kwargs): |
476 def revert_wc(self, *args, **kwargs): |
477 self.wc.revert() |
477 self.wc.revert() |
478 return f(self, *args, **kwargs) |
478 return f(self, *args, **kwargs) |
479 return revert_wc |
479 return revert_wc |
480 |
480 |
481 |
481 |
482 def requires_branch(f): |
482 def requires_branch(f): |
483 """A decorator that checks that a release branch is active.""" |
483 """A decorator that checks that a release branch is active.""" |
484 @functools.wraps(f) |
484 @functools.wraps(f) |
485 def check_branch(self, *args, **kwargs): |
485 def check_branch(self, *args, **kwargs): |
486 if self.branch is None: |
486 if self.branch is None: |
487 raise ExpectationFailed( |
487 raise ExpectationFailed( |
488 'This operation requires an active release branch') |
488 'This operation requires an active release branch') |
489 return f(self, *args, **kwargs) |
489 return f(self, *args, **kwargs) |
490 return check_branch |
490 return check_branch |
491 |
491 |
492 |
492 |
493 class ReleaseEnvironment(util.Paths): |
493 class ReleaseEnvironment(util.Paths): |
494 """Encapsulates the state of a Melange release rolling environment. |
494 """Encapsulates the state of a Melange release rolling environment. |
495 |
495 |
496 This class contains the actual releasing logic, and makes use of |
496 This class contains the actual releasing logic, and makes use of |
497 the previously defined utility classes to carry out user commands. |
497 the previously defined utility classes to carry out user commands. |
498 |
498 |
499 Attributes: |
499 Attributes: |
|
500 release_repos: The URL to the Google release repository root. |
|
501 upstream_repos: The URL to the Melange upstream repository root. |
|
502 wc: A Subversion object encapsulating a Google SoC working copy. |
|
503 """ |
|
504 |
|
505 BRANCH_FILE = 'BRANCH' |
|
506 |
|
507 def __init__(self, root, release_repos, upstream_repos): |
|
508 """Initializer. |
|
509 |
|
510 Args: |
|
511 root: The root of the release environment. |
500 release_repos: The URL to the Google release repository root. |
512 release_repos: The URL to the Google release repository root. |
501 upstream_repos: The URL to the Melange upstream repository root. |
513 upstream_repos: The URL to the Melange upstream repository root. |
502 wc: A Subversion object encapsulating a Google SoC working copy. |
514 """ |
503 """ |
515 util.Paths.__init__(self, root) |
504 |
516 self.wc = Subversion(self.path('google-soc')) |
505 BRANCH_FILE = 'BRANCH' |
517 self.release_repos = release_repos.strip('/') |
506 |
518 self.upstream_repos = upstream_repos.strip('/') |
507 def __init__(self, root, release_repos, upstream_repos): |
519 |
508 """Initializer. |
520 if not self.wc.exists(): |
509 |
521 self._InitializeWC() |
510 Args: |
522 else: |
511 root: The root of the release environment. |
523 self.wc.revert() |
512 release_repos: The URL to the Google release repository root. |
524 |
513 upstream_repos: The URL to the Melange upstream repository root. |
525 if self.exists(self.BRANCH_FILE): |
514 """ |
526 branch = fileToLines(self.path(self.BRANCH_FILE))[0] |
515 util.Paths.__init__(self, root) |
527 self._switchBranch(branch) |
516 self.wc = Subversion(self.path('google-soc')) |
528 else: |
517 self.release_repos = release_repos.strip('/') |
529 self._switchBranch(None) |
518 self.upstream_repos = upstream_repos.strip('/') |
530 |
519 |
531 def _InitializeWC(self): |
520 if not self.wc.exists(): |
532 """Check out the initial release repository. |
521 self._InitializeWC() |
533 |
522 else: |
534 Will also select the latest release branch, if any, so that |
523 self.wc.revert() |
535 the end state is a fully ready to function release |
524 |
536 environment. |
525 if self.exists(self.BRANCH_FILE): |
537 """ |
526 branch = fileToLines(self.path(self.BRANCH_FILE))[0] |
538 log.info('Checking out the release repository') |
527 self._switchBranch(branch) |
539 |
528 else: |
540 # Check out a sparse view of the relevant repository paths. |
529 self._switchBranch(None) |
541 self.wc.checkout(self.release_repos, depth='immediates') |
530 |
542 self.wc.update('vendor', depth='immediates') |
531 def _InitializeWC(self): |
543 self.wc.update('branches', depth='immediates') |
532 """Check out the initial release repository. |
544 self.wc.update('tags', depth='immediates') |
533 |
545 |
534 Will also select the latest release branch, if any, so that |
546 # Locate the most recent release branch, if any, and switch |
535 the end state is a fully ready to function release |
547 # the release environment to it. |
536 environment. |
548 branches = self._listBranches() |
537 """ |
549 if not branches: |
538 log.info('Checking out the release repository') |
550 self._switchBranch(None) |
539 |
551 else: |
540 # Check out a sparse view of the relevant repository paths. |
552 self._switchBranch(branches[-1]) |
541 self.wc.checkout(self.release_repos, depth='immediates') |
553 |
542 self.wc.update('vendor', depth='immediates') |
554 def _listBranches(self): |
543 self.wc.update('branches', depth='immediates') |
555 """Return a list of available Melange release branches. |
544 self.wc.update('tags', depth='immediates') |
556 |
545 |
557 Branches are returned in sorted order, from least recent to |
546 # Locate the most recent release branch, if any, and switch |
558 most recent in release number ordering. |
547 # the release environment to it. |
559 """ |
548 branches = self._listBranches() |
560 assert self.wc.exists('branches') |
549 if not branches: |
561 branches = self.wc.ls('branches') |
550 self._switchBranch(None) |
562 |
551 else: |
563 # Some early release branches used a different naming scheme |
552 self._switchBranch(branches[-1]) |
564 # that doesn't sort properly with new-style release names. We |
553 |
565 # filter those out here, along with empty lines. |
554 def _listBranches(self): |
566 branches = [b.strip('/') for b in branches |
555 """Return a list of available Melange release branches. |
567 if MELANGE_RELEASE_RE.match(b.strip('/'))] |
556 |
568 |
557 Branches are returned in sorted order, from least recent to |
569 return sorted(branches) |
558 most recent in release number ordering. |
570 |
559 """ |
571 def _switchBranch(self, release): |
560 assert self.wc.exists('branches') |
572 """Activate the branch matching the given release. |
561 branches = self.wc.ls('branches') |
573 |
562 |
574 Once activated, this branch is the target of future release |
563 # Some early release branches used a different naming scheme |
575 operations. |
564 # that doesn't sort properly with new-style release names. We |
576 |
565 # filter those out here, along with empty lines. |
577 None can be passed as the release. The result is that no |
566 branches = [b.strip('/') for b in branches |
578 branch is active, and all operations that require an active |
567 if MELANGE_RELEASE_RE.match(b.strip('/'))] |
579 branch will fail until a branch is activated again. This is |
568 |
580 used only at initialization, when it is detected that there |
569 return sorted(branches) |
581 are no available release branches to activate. |
570 |
582 |
571 def _switchBranch(self, release): |
583 Args: |
572 """Activate the branch matching the given release. |
584 release: The version number of a Melange release already |
573 |
585 imported in the release repository, or None to |
574 Once activated, this branch is the target of future release |
586 activate no branch. |
575 operations. |
587 |
576 |
588 """ |
577 None can be passed as the release. The result is that no |
589 if release is None: |
578 branch is active, and all operations that require an active |
590 self.branch = None |
579 branch will fail until a branch is activated again. This is |
591 self.branch_dir = None |
580 used only at initialization, when it is detected that there |
592 log.info('No release branch available') |
581 are no available release branches to activate. |
593 else: |
582 |
594 self.wc.update() |
583 Args: |
595 assert self.wc.exists('branches/' + release) |
584 release: The version number of a Melange release already |
596 linesToFile(self.path(self.BRANCH_FILE), [release]) |
585 imported in the release repository, or None to |
597 self.branch = release |
586 activate no branch. |
598 self.branch_dir = 'branches/' + release |
587 |
599 self.wc.update(self.branch_dir, depth='infinity') |
588 """ |
600 log.info('Working on branch ' + self.branch) |
589 if release is None: |
601 |
590 self.branch = None |
602 def _branchPath(self, path): |
591 self.branch_dir = None |
603 """Return the given path with the release branch path prepended.""" |
592 log.info('No release branch available') |
604 assert self.branch_dir is not None |
593 else: |
605 return os.path.join(self.branch_dir, path) |
594 self.wc.update() |
606 |
595 assert self.wc.exists('branches/' + release) |
607 # |
596 linesToFile(self.path(self.BRANCH_FILE), [release]) |
608 # Release engineering commands. See further down for their |
597 self.branch = release |
609 # integration into a commandline interface. |
598 self.branch_dir = 'branches/' + release |
610 # |
599 self.wc.update(self.branch_dir, depth='infinity') |
611 @pristine_wc |
600 log.info('Working on branch ' + self.branch) |
612 def update(self): |
601 |
613 """Update and clean the release repository""" |
602 def _branchPath(self, path): |
614 self.wc.update() |
603 """Return the given path with the release branch path prepended.""" |
615 |
604 assert self.branch_dir is not None |
616 @pristine_wc |
605 return os.path.join(self.branch_dir, path) |
617 def switchToBranch(self): |
606 |
618 """Switch to another Melange release branch""" |
607 # |
619 branches = self._listBranches() |
608 # Release engineering commands. See further down for their |
620 if not branches: |
609 # integration into a commandline interface. |
621 raise ExpectationFailed( |
610 # |
622 'No branches available. Please import one.') |
611 @pristine_wc |
623 |
612 def update(self): |
624 choice = getChoice('Available release branches:', |
613 """Update and clean the release repository""" |
625 'Your choice?', |
614 self.wc.update() |
626 branches, |
615 |
627 suggest=len(branches)-1) |
616 @pristine_wc |
628 self._switchBranch(branches[choice]) |
617 def switchToBranch(self): |
629 |
618 """Switch to another Melange release branch""" |
630 def _addAppYaml(self): |
619 branches = self._listBranches() |
631 """Create a Google production app.yaml configuration. |
620 if not branches: |
632 |
621 raise ExpectationFailed( |
633 The file is copied and modified from the upstream |
622 'No branches available. Please import one.') |
634 app.yaml.template, configure for Google's Summer of Code App |
623 |
635 Engine instance, and committed. |
624 choice = getChoice('Available release branches:', |
636 """ |
625 'Your choice?', |
637 if self.wc.exists(self._branchPath('app/app.yaml')): |
626 branches, |
638 raise ObstructionError('app/app.yaml exists already') |
627 suggest=len(branches)-1) |
639 |
628 self._switchBranch(branches[choice]) |
640 yaml_path = self._branchPath('app/app.yaml') |
629 |
641 self.wc.copy(yaml_path + '.template', yaml_path) |
630 def _addAppYaml(self): |
642 |
631 """Create a Google production app.yaml configuration. |
643 yaml = fileToLines(self.wc.path(yaml_path)) |
632 |
644 out = [] |
633 The file is copied and modified from the upstream |
645 for i, line in enumerate(yaml): |
634 app.yaml.template, configure for Google's Summer of Code App |
646 stripped_line = line.strip() |
635 Engine instance, and committed. |
647 if 'TODO' in stripped_line: |
636 """ |
648 continue |
637 if self.wc.exists(self._branchPath('app/app.yaml')): |
649 elif stripped_line == '# application: FIXME': |
638 raise ObstructionError('app/app.yaml exists already') |
650 out.append('application: socghop') |
639 |
651 elif stripped_line.startswith('version:'): |
640 yaml_path = self._branchPath('app/app.yaml') |
652 out.append(line.lstrip() + 'g0') |
641 self.wc.copy(yaml_path + '.template', yaml_path) |
653 out.append('# * initial Google fork of Melange ' + |
642 |
654 self.branch) |
643 yaml = fileToLines(self.wc.path(yaml_path)) |
655 else: |
644 out = [] |
656 out.append(line) |
645 for i, line in enumerate(yaml): |
657 linesToFile(self.wc.path(yaml_path), out) |
646 stripped_line = line.strip() |
658 |
647 if 'TODO' in stripped_line: |
659 self.wc.commit('Create app.yaml with Google patch version g0 ' |
648 continue |
660 'in branch ' + self.branch) |
649 elif stripped_line == '# application: FIXME': |
661 |
650 out.append('application: socghop') |
662 def _applyGooglePatches(self): |
651 elif stripped_line.startswith('version:'): |
663 """Apply Google-specific patches to a vanilla Melange release. |
652 out.append(line.lstrip() + 'g0') |
664 |
653 out.append('# * initial Google fork of Melange ' + |
665 Each patch is applied and committed in turn. |
654 self.branch) |
666 """ |
655 else: |
667 # Edit the base template to point users to the Google fork |
656 out.append(line) |
668 # of the Melange codebase instead of the vanilla release. |
657 linesToFile(self.wc.path(yaml_path), out) |
669 tmpl_file = self.wc.path( |
658 |
670 self._branchPath('app/soc/templates/soc/base.html')) |
659 self.wc.commit('Create app.yaml with Google patch version g0 ' |
671 tmpl = fileToLines(tmpl_file) |
660 'in branch ' + self.branch) |
672 for i, line in enumerate(tmpl): |
661 |
673 if 'http://code.google.com/p/soc/source/browse/tags/' in line: |
662 def _applyGooglePatches(self): |
674 tmpl[i] = line.replace('/p/soc/', '/p/soc-google/') |
663 """Apply Google-specific patches to a vanilla Melange release. |
675 break |
664 |
676 else: |
665 Each patch is applied and committed in turn. |
677 raise ExpectationFailed( |
666 """ |
678 'No source code link found in base.html') |
667 # Edit the base template to point users to the Google fork |
679 linesToFile(tmpl_file, tmpl) |
668 # of the Melange codebase instead of the vanilla release. |
680 |
669 tmpl_file = self.wc.path( |
681 self.wc.commit( |
670 self._branchPath('app/soc/templates/soc/base.html')) |
682 'Customize the Melange release link in the sidebar menu') |
671 tmpl = fileToLines(tmpl_file) |
683 |
672 for i, line in enumerate(tmpl): |
684 @pristine_wc |
673 if 'http://code.google.com/p/soc/source/browse/tags/' in line: |
685 def importTag(self): |
674 tmpl[i] = line.replace('/p/soc/', '/p/soc-google/') |
686 """Import a new Melange release""" |
675 break |
687 release = getString('Enter the Melange release to import:') |
676 else: |
688 if not release: |
677 raise ExpectationFailed( |
689 AbortedByUser('No release provided, import aborted') |
678 'No source code link found in base.html') |
690 |
679 linesToFile(tmpl_file, tmpl) |
691 branch_dir = 'branches/' + release |
680 |
692 if self.wc.exists(branch_dir): |
681 self.wc.commit( |
693 raise ObstructionError('Release %s already imported' % release) |
682 'Customize the Melange release link in the sidebar menu') |
694 |
683 |
695 tag_url = '%s/tags/%s' % (self.upstream_repos, release) |
684 @pristine_wc |
696 release_rev = Subversion.find_tag_rev(tag_url) |
685 def importTag(self): |
697 |
686 """Import a new Melange release""" |
698 if confirm('Confirm import of release %s, tagged at r%d?' % |
687 release = getString('Enter the Melange release to import:') |
699 (release, release_rev)): |
688 if not release: |
700 # Add an entry to the vendor externals for the Melange |
689 AbortedByUser('No release provided, import aborted') |
701 # release. |
690 |
702 externals = self.wc.propget('svn:externals', 'vendor/soc') |
691 branch_dir = 'branches/' + release |
703 externals.append('%s -r %d %s' % (release, release_rev, tag_url)) |
692 if self.wc.exists(branch_dir): |
704 self.wc.propset('svn:externals', '\n'.join(externals), 'vendor/soc') |
693 raise ObstructionError('Release %s already imported' % release) |
705 self.wc.commit('Add svn:externals entry to pull in Melange ' |
694 |
706 'release %s at r%d.' % (release, release_rev)) |
695 tag_url = '%s/tags/%s' % (self.upstream_repos, release) |
707 |
696 release_rev = Subversion.find_tag_rev(tag_url) |
708 # Export the tag into the release repository's branches |
697 |
709 Subversion.export(tag_url, release_rev, self.wc.path(branch_dir)) |
698 if confirm('Confirm import of release %s, tagged at r%d?' % |
710 |
699 (release, release_rev)): |
711 # Add and commit the branch add (very long operation!) |
700 # Add an entry to the vendor externals for the Melange |
712 self.wc.add([branch_dir]) |
701 # release. |
713 self.wc.commit('Branch of Melange release %s' % release, |
702 externals = self.wc.propget('svn:externals', 'vendor/soc') |
714 branch_dir) |
703 externals.append('%s -r %d %s' % (release, release_rev, tag_url)) |
715 self._switchBranch(release) |
704 self.wc.propset('svn:externals', '\n'.join(externals), 'vendor/soc') |
716 |
705 self.wc.commit('Add svn:externals entry to pull in Melange ' |
717 # Commit the production GSoC configuration and |
706 'release %s at r%d.' % (release, release_rev)) |
718 # google-specific patches. |
707 |
719 self._addAppYaml() |
708 # Export the tag into the release repository's branches |
720 self._applyGooglePatches() |
709 Subversion.export(tag_url, release_rev, self.wc.path(branch_dir)) |
721 |
710 |
722 # All done! |
711 # Add and commit the branch add (very long operation!) |
723 log.info('Melange release %s imported and googlified' % self.branch) |
712 self.wc.add([branch_dir]) |
724 |
713 self.wc.commit('Branch of Melange release %s' % release, |
725 @requires_branch |
714 branch_dir) |
726 @pristine_wc |
715 self._switchBranch(release) |
727 def cherryPickChange(self): |
716 |
728 """Cherry-pick a change from the Melange trunk""" |
717 # Commit the production GSoC configuration and |
729 rev = getNumber('Revision number to cherry-pick:') |
718 # google-specific patches. |
730 bug = getNumber('Issue fixed by this change:') |
719 self._addAppYaml() |
731 |
720 self._applyGooglePatches() |
732 diff = self.wc.diff(self.upstream_repos + '/trunk', rev) |
721 |
733 if not diff.strip(): |
722 # All done! |
734 raise ExpectationFailed( |
723 log.info('Melange release %s imported and googlified' % self.branch) |
735 'Retrieved diff is empty. ' |
724 |
736 'Did you accidentally cherry-pick a branch change?') |
725 @requires_branch |
737 util.run(['patch', '-p0'], cwd=self.wc.path(self.branch_dir), |
726 @pristine_wc |
738 stdin=diff) |
727 def cherryPickChange(self): |
739 self.wc.addRemove(self.branch_dir) |
728 """Cherry-pick a change from the Melange trunk""" |
740 |
729 rev = getNumber('Revision number to cherry-pick:') |
741 yaml_path = self.wc.path(self._branchPath('app/app.yaml')) |
730 bug = getNumber('Issue fixed by this change:') |
742 out = [] |
731 |
743 updated_patchlevel = False |
732 diff = self.wc.diff(self.upstream_repos + '/trunk', rev) |
744 for line in fileToLines(yaml_path): |
733 if not diff.strip(): |
745 if line.strip().startswith('version: '): |
734 raise ExpectationFailed( |
746 version = line.strip().split()[-1] |
735 'Retrieved diff is empty. ' |
747 base, patch = line.rsplit('g', 1) |
736 'Did you accidentally cherry-pick a branch change?') |
748 new_version = '%sg%d' % (base, int(patch) + 1) |
737 util.run(['patch', '-p0'], cwd=self.wc.path(self.branch_dir), |
749 message = ('Cherry-picked r%d from /p/soc/ to fix issue %d' % |
738 stdin=diff) |
750 (rev, bug)) |
739 self.wc.addRemove(self.branch_dir) |
751 out.append('version: ' + new_version) |
740 |
752 out.append('# * ' + message) |
741 yaml_path = self.wc.path(self._branchPath('app/app.yaml')) |
753 updated_patchlevel = True |
742 out = [] |
754 else: |
743 updated_patchlevel = False |
755 out.append(line) |
744 for line in fileToLines(yaml_path): |
756 |
745 if line.strip().startswith('version: '): |
757 if not updated_patchlevel: |
746 version = line.strip().split()[-1] |
758 log.error('Failed to update Google patch revision') |
747 base, patch = line.rsplit('g', 1) |
759 log.error('Cherry-picking failed') |
748 new_version = '%sg%d' % (base, int(patch) + 1) |
760 |
749 message = ('Cherry-picked r%d from /p/soc/ to fix issue %d' % |
761 linesToFile(yaml_path, out) |
750 (rev, bug)) |
762 |
751 out.append('version: ' + new_version) |
763 log.info('Check the diff about to be committed with:') |
752 out.append('# * ' + message) |
764 log.info('svn diff ' + self.wc.path(self.branch_dir)) |
753 updated_patchlevel = True |
765 if not confirm('Commit this change?'): |
754 else: |
766 raise AbortedByUser('Cherry-pick aborted') |
755 out.append(line) |
767 self.wc.commit(message) |
756 |
768 log.info('Cherry-picked r%d from the Melange trunk.' % rev) |
757 if not updated_patchlevel: |
769 |
758 log.error('Failed to update Google patch revision') |
770 MENU_ORDER = [ |
759 log.error('Cherry-picking failed') |
771 update, |
760 |
772 switchToBranch, |
761 linesToFile(yaml_path, out) |
773 importTag, |
762 |
774 cherryPickChange, |
763 log.info('Check the diff about to be committed with:') |
775 ] |
764 log.info('svn diff ' + self.wc.path(self.branch_dir)) |
776 |
765 if not confirm('Commit this change?'): |
777 MENU_STRINGS = [d.__doc__ for d in MENU_ORDER] |
766 raise AbortedByUser('Cherry-pick aborted') |
778 |
767 self.wc.commit(message) |
779 MENU_SUGGESTIONS = { |
768 log.info('Cherry-picked r%d from the Melange trunk.' % rev) |
780 None: update, |
769 |
781 update: cherryPickChange, |
770 MENU_ORDER = [ |
782 switchToBranch: cherryPickChange, |
771 update, |
783 importTag: cherryPickChange, |
772 switchToBranch, |
784 cherryPickChange: None, |
773 importTag, |
785 } |
774 cherryPickChange, |
786 |
775 ] |
787 def interactiveMenu(self): |
776 |
788 done = [] |
777 MENU_STRINGS = [d.__doc__ for d in MENU_ORDER] |
789 last_choice = None |
778 |
790 while True: |
779 MENU_SUGGESTIONS = { |
791 # Show the user their previously completed operations and |
780 None: update, |
792 # a suggested next op, to remind them where they are in |
781 update: cherryPickChange, |
793 # the release process (useful after long operations that |
782 switchToBranch: cherryPickChange, |
794 # may have caused lunch or an extended context switch). |
783 importTag: cherryPickChange, |
795 if last_choice is not None: |
784 cherryPickChange: None, |
796 last_command = self.MENU_ORDER[last_choice] |
785 } |
797 else: |
786 |
798 last_command = None |
787 def interactiveMenu(self): |
799 suggested_next = self.MENU_ORDER.index( |
788 done = [] |
800 self.MENU_SUGGESTIONS[last_command]) |
789 last_choice = None |
801 |
790 while True: |
802 try: |
791 # Show the user their previously completed operations and |
803 choice = getChoice('Main menu:', 'Your choice?', |
792 # a suggested next op, to remind them where they are in |
804 self.MENU_STRINGS, done=done, |
793 # the release process (useful after long operations that |
805 suggest=suggested_next) |
794 # may have caused lunch or an extended context switch). |
806 except (KeyboardInterrupt, AbortedByUser): |
795 if last_choice is not None: |
807 log.info('Exiting.') |
796 last_command = self.MENU_ORDER[last_choice] |
808 return |
797 else: |
809 try: |
798 last_command = None |
810 self.MENU_ORDER[choice](self) |
799 suggested_next = self.MENU_ORDER.index( |
811 except Error, e: |
800 self.MENU_SUGGESTIONS[last_command]) |
812 log.error(str(e)) |
801 |
813 else: |
802 try: |
814 done.append(choice) |
803 choice = getChoice('Main menu:', 'Your choice?', |
815 last_choice = choice |
804 self.MENU_STRINGS, done=done, |
|
805 suggest=suggested_next) |
|
806 except (KeyboardInterrupt, AbortedByUser): |
|
807 log.info('Exiting.') |
|
808 return |
|
809 try: |
|
810 self.MENU_ORDER[choice](self) |
|
811 except Error, e: |
|
812 log.error(str(e)) |
|
813 else: |
|
814 done.append(choice) |
|
815 last_choice = choice |
|
816 |
816 |
817 |
817 |
818 def main(argv): |
818 def main(argv): |
819 if not (1 <= len(argv) <= 3): |
819 if not (1 <= len(argv) <= 3): |
820 print ('Usage: gsoc-release.py [release repos root URL] ' |
820 print ('Usage: gsoc-release.py [release repos root URL] ' |
821 '[upstream repos root URL]') |
821 '[upstream repos root URL]') |
822 sys.exit(1) |
822 sys.exit(1) |
823 |
823 |
824 release_repos, upstream_repos = GOOGLE_SOC_REPOS, MELANGE_REPOS |
824 release_repos, upstream_repos = GOOGLE_SOC_REPOS, MELANGE_REPOS |
825 if len(argv) >= 2: |
825 if len(argv) >= 2: |
826 release_repos = argv[1] |
826 release_repos = argv[1] |
827 if len(argv) == 3: |
827 if len(argv) == 3: |
828 upstream_repos = argv[2] |
828 upstream_repos = argv[2] |
829 |
829 |
830 log.init('release.log') |
830 log.init('release.log') |
831 |
831 |
832 log.info('Release repository: ' + release_repos) |
832 log.info('Release repository: ' + release_repos) |
833 log.info('Upstream repository: ' + upstream_repos) |
833 log.info('Upstream repository: ' + upstream_repos) |
834 |
834 |
835 r = ReleaseEnvironment(os.path.abspath('_release_'), |
835 r = ReleaseEnvironment(os.path.abspath('_release_'), |
836 release_repos, |
836 release_repos, |
837 upstream_repos) |
837 upstream_repos) |
838 r.interactiveMenu() |
838 r.interactiveMenu() |
839 |
839 |
840 |
840 |
841 if __name__ == '__main__': |
841 if __name__ == '__main__': |
842 main(sys.argv) |
842 main(sys.argv) |