|
1 #!/usr/bin/env python |
|
2 # -*- coding: utf-8 -*- |
|
3 # Copyright (c) 2005, Giovanni Bajo |
|
4 # Copyright (c) 2004-2005, Awarix, Inc. |
|
5 # All rights reserved. |
|
6 # |
|
7 # This program is free software; you can redistribute it and/or |
|
8 # modify it under the terms of the GNU General Public License |
|
9 # as published by the Free Software Foundation; either version 2 |
|
10 # of the License, or (at your option) any later version. |
|
11 # |
|
12 # This program is distributed in the hope that it will be useful, |
|
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
15 # GNU General Public License for more details. |
|
16 # |
|
17 # You should have received a copy of the GNU General Public License |
|
18 # along with this program; if not, write to the Free Software |
|
19 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA |
|
20 # |
|
21 # Author: Archie Cobbs <archie at awarix dot com> |
|
22 # Rewritten in Python by: Giovanni Bajo <rasky at develer dot com> |
|
23 # |
|
24 # Acknowledgments: |
|
25 # John Belmonte <john at neggie dot net> - metadata and usability |
|
26 # improvements |
|
27 # Blair Zajac <blair at orcaware dot com> - random improvements |
|
28 # Raman Gupta <rocketraman at fastmail dot fm> - bidirectional and transitive |
|
29 # merging support |
|
30 # |
|
31 # $HeadURL$ |
|
32 # $LastChangedDate$ |
|
33 # $LastChangedBy$ |
|
34 # $LastChangedRevision$ |
|
35 # |
|
36 # Requisites: |
|
37 # svnmerge.py has been tested with all SVN major versions since 1.1 (both |
|
38 # client and server). It is unknown if it works with previous versions. |
|
39 # |
|
40 # Differences from svnmerge.sh: |
|
41 # - More portable: tested as working in FreeBSD and OS/2. |
|
42 # - Add double-verbose mode, which shows every svn command executed (-v -v). |
|
43 # - "svnmerge avail" now only shows commits in source, not also commits in |
|
44 # other parts of the repository. |
|
45 # - Add "svnmerge block" to flag some revisions as blocked, so that |
|
46 # they will not show up anymore in the available list. Added also |
|
47 # the complementary "svnmerge unblock". |
|
48 # - "svnmerge avail" has grown two new options: |
|
49 # -B to display a list of the blocked revisions |
|
50 # -A to display both the blocked and the available revisions. |
|
51 # - Improved generated commit message to make it machine parsable even when |
|
52 # merging commits which are themselves merges. |
|
53 # - Add --force option to skip working copy check |
|
54 # - Add --record-only option to "svnmerge merge" to avoid performing |
|
55 # an actual merge, yet record that a merge happened. |
|
56 # |
|
57 # TODO: |
|
58 # - Add "svnmerge avail -R": show logs in reverse order |
|
59 # |
|
60 # Information for Hackers: |
|
61 # |
|
62 # Identifiers for branches: |
|
63 # A branch is identified in three ways within this source: |
|
64 # - as a working copy (variable name usually includes 'dir') |
|
65 # - as a fully qualified URL |
|
66 # - as a path identifier (an opaque string indicating a particular path |
|
67 # in a particular repository; variable name includes 'pathid') |
|
68 # A "target" is generally user-specified, and may be a working copy or |
|
69 # a URL. |
|
70 |
|
71 import sys, os, getopt, re, types, tempfile, time, popen2, locale |
|
72 from bisect import bisect |
|
73 from xml.dom import pulldom |
|
74 |
|
75 NAME = "svnmerge" |
|
76 if not hasattr(sys, "version_info") or sys.version_info < (2, 0): |
|
77 error("requires Python 2.0 or newer") |
|
78 |
|
79 # Set up the separator used to separate individual log messages from |
|
80 # each revision merged into the target location. Also, create a |
|
81 # regular expression that will find this same separator in already |
|
82 # committed log messages, so that the separator used for this run of |
|
83 # svnmerge.py will have one more LOG_SEPARATOR appended to the longest |
|
84 # separator found in all the commits. |
|
85 LOG_SEPARATOR = 8 * '.' |
|
86 LOG_SEPARATOR_RE = re.compile('^((%s)+)' % re.escape(LOG_SEPARATOR), |
|
87 re.MULTILINE) |
|
88 |
|
89 # Each line of the embedded log messages will be prefixed by LOG_LINE_PREFIX. |
|
90 LOG_LINE_PREFIX = 2 * ' ' |
|
91 |
|
92 # Set python to the default locale as per environment settings, same as svn |
|
93 # TODO we should really parse config and if log-encoding is specified, set |
|
94 # the locale to match that encoding |
|
95 locale.setlocale(locale.LC_ALL, '') |
|
96 |
|
97 # We want the svn output (such as svn info) to be non-localized |
|
98 # Using LC_MESSAGES should not affect localized output of svn log, for example |
|
99 if os.environ.has_key("LC_ALL"): |
|
100 del os.environ["LC_ALL"] |
|
101 os.environ["LC_MESSAGES"] = "C" |
|
102 |
|
103 ############################################################################### |
|
104 # Support for older Python versions |
|
105 ############################################################################### |
|
106 |
|
107 # True/False constants are Python 2.2+ |
|
108 try: |
|
109 True, False |
|
110 except NameError: |
|
111 True, False = 1, 0 |
|
112 |
|
113 def lstrip(s, ch): |
|
114 """Replacement for str.lstrip (support for arbitrary chars to strip was |
|
115 added in Python 2.2.2).""" |
|
116 i = 0 |
|
117 try: |
|
118 while s[i] == ch: |
|
119 i = i+1 |
|
120 return s[i:] |
|
121 except IndexError: |
|
122 return "" |
|
123 |
|
124 def rstrip(s, ch): |
|
125 """Replacement for str.rstrip (support for arbitrary chars to strip was |
|
126 added in Python 2.2.2).""" |
|
127 try: |
|
128 if s[-1] != ch: |
|
129 return s |
|
130 i = -2 |
|
131 while s[i] == ch: |
|
132 i = i-1 |
|
133 return s[:i+1] |
|
134 except IndexError: |
|
135 return "" |
|
136 |
|
137 def strip(s, ch): |
|
138 """Replacement for str.strip (support for arbitrary chars to strip was |
|
139 added in Python 2.2.2).""" |
|
140 return lstrip(rstrip(s, ch), ch) |
|
141 |
|
142 def rsplit(s, sep, maxsplits=0): |
|
143 """Like str.rsplit, which is Python 2.4+ only.""" |
|
144 L = s.split(sep) |
|
145 if not 0 < maxsplits <= len(L): |
|
146 return L |
|
147 return [sep.join(L[0:-maxsplits])] + L[-maxsplits:] |
|
148 |
|
149 ############################################################################### |
|
150 |
|
151 def kwextract(s): |
|
152 """Extract info from a svn keyword string.""" |
|
153 try: |
|
154 return strip(s, "$").strip().split(": ")[1] |
|
155 except IndexError: |
|
156 return "<unknown>" |
|
157 |
|
158 __revision__ = kwextract('$Rev$') |
|
159 __date__ = kwextract('$Date$') |
|
160 |
|
161 # Additional options, not (yet?) mapped to command line flags |
|
162 default_opts = { |
|
163 "svn": "svn", |
|
164 "prop": NAME + "-integrated", |
|
165 "block-prop": NAME + "-blocked", |
|
166 "commit-verbose": True, |
|
167 } |
|
168 logs = {} |
|
169 |
|
170 def console_width(): |
|
171 """Get the width of the console screen (if any).""" |
|
172 try: |
|
173 return int(os.environ["COLUMNS"]) |
|
174 except (KeyError, ValueError): |
|
175 pass |
|
176 |
|
177 try: |
|
178 # Call the Windows API (requires ctypes library) |
|
179 from ctypes import windll, create_string_buffer |
|
180 h = windll.kernel32.GetStdHandle(-11) |
|
181 csbi = create_string_buffer(22) |
|
182 res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi) |
|
183 if res: |
|
184 import struct |
|
185 (bufx, bufy, |
|
186 curx, cury, wattr, |
|
187 left, top, right, bottom, |
|
188 maxx, maxy) = struct.unpack("hhhhHhhhhhh", csbi.raw) |
|
189 return right - left + 1 |
|
190 except ImportError: |
|
191 pass |
|
192 |
|
193 # Parse the output of stty -a |
|
194 out = os.popen("stty -a").read() |
|
195 m = re.search(r"columns (\d+);", out) |
|
196 if m: |
|
197 return int(m.group(1)) |
|
198 |
|
199 # sensible default |
|
200 return 80 |
|
201 |
|
202 def error(s): |
|
203 """Subroutine to output an error and bail.""" |
|
204 print >> sys.stderr, "%s: %s" % (NAME, s) |
|
205 sys.exit(1) |
|
206 |
|
207 def report(s): |
|
208 """Subroutine to output progress message, unless in quiet mode.""" |
|
209 if opts["verbose"]: |
|
210 print "%s: %s" % (NAME, s) |
|
211 |
|
212 def prefix_lines(prefix, lines): |
|
213 """Given a string representing one or more lines of text, insert the |
|
214 specified prefix at the beginning of each line, and return the result. |
|
215 The input must be terminated by a newline.""" |
|
216 assert lines[-1] == "\n" |
|
217 return prefix + lines[:-1].replace("\n", "\n"+prefix) + "\n" |
|
218 |
|
219 def recode_stdout_to_file(s): |
|
220 if locale.getdefaultlocale()[1] is None or not hasattr(sys.stdout, "encoding") \ |
|
221 or sys.stdout.encoding is None: |
|
222 return s |
|
223 u = s.decode(sys.stdout.encoding) |
|
224 return u.encode(locale.getdefaultlocale()[1]) |
|
225 |
|
226 class LaunchError(Exception): |
|
227 """Signal a failure in execution of an external command. Parameters are the |
|
228 exit code of the process, the original command line, and the output of the |
|
229 command.""" |
|
230 |
|
231 try: |
|
232 """Launch a sub-process. Return its output (both stdout and stderr), |
|
233 optionally split by lines (if split_lines is True). Raise a LaunchError |
|
234 exception if the exit code of the process is non-zero (failure). |
|
235 |
|
236 This function has two implementations, one based on subprocess (preferred), |
|
237 and one based on popen (for compatibility). |
|
238 """ |
|
239 import subprocess |
|
240 import shlex |
|
241 |
|
242 def launch(cmd, split_lines=True): |
|
243 # Requiring python 2.4 or higher, on some platforms we get |
|
244 # much faster performance from the subprocess module (where python |
|
245 # doesn't try to close an exhorbitant number of file descriptors) |
|
246 stdout = "" |
|
247 stderr = "" |
|
248 try: |
|
249 if os.name == 'nt': |
|
250 p = subprocess.Popen(cmd, stdout=subprocess.PIPE, \ |
|
251 close_fds=False, stderr=subprocess.PIPE) |
|
252 else: |
|
253 # Use shlex to break up the parameters intelligently, |
|
254 # respecting quotes. shlex can't handle unicode. |
|
255 args = shlex.split(cmd.encode('ascii')) |
|
256 p = subprocess.Popen(args, stdout=subprocess.PIPE, \ |
|
257 close_fds=False, stderr=subprocess.PIPE) |
|
258 stdoutAndErr = p.communicate() |
|
259 stdout = stdoutAndErr[0] |
|
260 stderr = stdoutAndErr[1] |
|
261 except OSError, inst: |
|
262 # Using 1 as failure code; should get actual number somehow? For |
|
263 # examples see svnmerge_test.py's TestCase_launch.test_failure and |
|
264 # TestCase_launch.test_failurecode. |
|
265 raise LaunchError(1, cmd, stdout + " " + stderr + ": " + str(inst)) |
|
266 |
|
267 if p.returncode == 0: |
|
268 if split_lines: |
|
269 # Setting keepends=True for compatibility with previous logic |
|
270 # (where file.readlines() preserves newlines) |
|
271 return stdout.splitlines(True) |
|
272 else: |
|
273 return stdout |
|
274 else: |
|
275 raise LaunchError(p.returncode, cmd, stdout + stderr) |
|
276 except ImportError: |
|
277 # support versions of python before 2.4 (slower on some systems) |
|
278 def launch(cmd, split_lines=True): |
|
279 if os.name not in ['nt', 'os2']: |
|
280 p = popen2.Popen4(cmd) |
|
281 p.tochild.close() |
|
282 if split_lines: |
|
283 out = p.fromchild.readlines() |
|
284 else: |
|
285 out = p.fromchild.read() |
|
286 ret = p.wait() |
|
287 if ret == 0: |
|
288 ret = None |
|
289 else: |
|
290 ret >>= 8 |
|
291 else: |
|
292 i,k = os.popen4(cmd) |
|
293 i.close() |
|
294 if split_lines: |
|
295 out = k.readlines() |
|
296 else: |
|
297 out = k.read() |
|
298 ret = k.close() |
|
299 |
|
300 if ret is None: |
|
301 return out |
|
302 raise LaunchError(ret, cmd, out) |
|
303 |
|
304 def launchsvn(s, show=False, pretend=False, **kwargs): |
|
305 """Launch SVN and grab its output.""" |
|
306 username = opts.get("username", None) |
|
307 password = opts.get("password", None) |
|
308 if username: |
|
309 username = " --username=" + username |
|
310 else: |
|
311 username = "" |
|
312 if password: |
|
313 password = " --password=" + password |
|
314 else: |
|
315 password = "" |
|
316 cmd = opts["svn"] + " --non-interactive" + username + password + " " + s |
|
317 if show or opts["verbose"] >= 2: |
|
318 print cmd |
|
319 if pretend: |
|
320 return None |
|
321 return launch(cmd, **kwargs) |
|
322 |
|
323 def svn_command(s): |
|
324 """Do (or pretend to do) an SVN command.""" |
|
325 out = launchsvn(s, show=opts["show-changes"] or opts["dry-run"], |
|
326 pretend=opts["dry-run"], |
|
327 split_lines=False) |
|
328 if not opts["dry-run"]: |
|
329 print out |
|
330 |
|
331 def check_dir_clean(dir): |
|
332 """Check the current status of dir for local mods.""" |
|
333 if opts["force"]: |
|
334 report('skipping status check because of --force') |
|
335 return |
|
336 report('checking status of "%s"' % dir) |
|
337 |
|
338 # Checking with -q does not show unversioned files or external |
|
339 # directories. Though it displays a debug message for external |
|
340 # directories, after a blank line. So, practically, the first line |
|
341 # matters: if it's non-empty there is a modification. |
|
342 out = launchsvn("status -q %s" % dir) |
|
343 if out and out[0].strip(): |
|
344 error('"%s" has local modifications; it must be clean' % dir) |
|
345 |
|
346 class RevisionLog: |
|
347 """ |
|
348 A log of the revisions which affected a given URL between two |
|
349 revisions. |
|
350 """ |
|
351 |
|
352 def __init__(self, url, begin, end, find_propchanges=False): |
|
353 """ |
|
354 Create a new RevisionLog object, which stores, in self.revs, a list |
|
355 of the revisions which affected the specified URL between begin and |
|
356 end. If find_propchanges is True, self.propchange_revs will contain a |
|
357 list of the revisions which changed properties directly on the |
|
358 specified URL. URL must be the URL for a directory in the repository. |
|
359 """ |
|
360 self.url = url |
|
361 |
|
362 # Setup the log options (--quiet, so we don't show log messages) |
|
363 log_opts = '--xml --quiet -r%s:%s "%s"' % (begin, end, url) |
|
364 if find_propchanges: |
|
365 # The --verbose flag lets us grab merge tracking information |
|
366 # by looking at propchanges |
|
367 log_opts = "--verbose " + log_opts |
|
368 |
|
369 # Read the log to look for revision numbers and merge-tracking info |
|
370 self.revs = [] |
|
371 self.propchange_revs = [] |
|
372 repos_pathid = target_to_pathid(url) |
|
373 for chg in SvnLogParser(launchsvn("log %s" % log_opts, |
|
374 split_lines=False)): |
|
375 self.revs.append(chg.revision()) |
|
376 for p in chg.paths(): |
|
377 if p.action() == 'M' and p.pathid() == repos_pathid: |
|
378 self.propchange_revs.append(chg.revision()) |
|
379 |
|
380 # Save the range of the log |
|
381 self.begin = int(begin) |
|
382 if end == "HEAD": |
|
383 # If end is not provided, we do not know which is the latest |
|
384 # revision in the repository. So we set 'end' to the latest |
|
385 # known revision. |
|
386 self.end = self.revs[-1] |
|
387 else: |
|
388 self.end = int(end) |
|
389 |
|
390 self._merges = None |
|
391 self._blocks = None |
|
392 |
|
393 def merge_metadata(self): |
|
394 """ |
|
395 Return a VersionedProperty object, with a cached view of the merge |
|
396 metadata in the range of this log. |
|
397 """ |
|
398 |
|
399 # Load merge metadata if necessary |
|
400 if not self._merges: |
|
401 self._merges = VersionedProperty(self.url, opts["prop"]) |
|
402 self._merges.load(self) |
|
403 |
|
404 return self._merges |
|
405 |
|
406 def block_metadata(self): |
|
407 if not self._blocks: |
|
408 self._blocks = VersionedProperty(self.url, opts["block-prop"]) |
|
409 self._blocks.load(self) |
|
410 |
|
411 return self._blocks |
|
412 |
|
413 |
|
414 class VersionedProperty: |
|
415 """ |
|
416 A read-only, cached view of a versioned property. |
|
417 |
|
418 self.revs contains a list of the revisions in which the property changes. |
|
419 self.values stores the new values at each corresponding revision. If the |
|
420 value of the property is unknown, it is set to None. |
|
421 |
|
422 Initially, we set self.revs to [0] and self.values to [None]. This |
|
423 indicates that, as of revision zero, we know nothing about the value of |
|
424 the property. |
|
425 |
|
426 Later, if you run self.load(log), we cache the value of this property over |
|
427 the entire range of the log by noting each revision in which the property |
|
428 was changed. At the end of the range of the log, we invalidate our cache |
|
429 by adding the value "None" to our cache for any revisions which fall out |
|
430 of the range of our log. |
|
431 |
|
432 Once self.revs and self.values are filled, we can find the value of the |
|
433 property at any arbitrary revision using a binary search on self.revs. |
|
434 Once we find the last revision during which the property was changed, |
|
435 we can lookup the associated value in self.values. (If the associated |
|
436 value is None, the associated value was not cached and we have to do |
|
437 a full propget.) |
|
438 |
|
439 An example: We know that the 'svnmerge' property was added in r10, and |
|
440 changed in r21. We gathered log info up until r40. |
|
441 |
|
442 revs = [0, 10, 21, 40] |
|
443 values = [None, "val1", "val2", None] |
|
444 |
|
445 What these values say: |
|
446 - From r0 to r9, we know nothing about the property. |
|
447 - In r10, the property was set to "val1". This property stayed the same |
|
448 until r21, when it was changed to "val2". |
|
449 - We don't know what happened after r40. |
|
450 """ |
|
451 |
|
452 def __init__(self, url, name): |
|
453 """View the history of a versioned property at URL with name""" |
|
454 self.url = url |
|
455 self.name = name |
|
456 |
|
457 # We know nothing about the value of the property. Setup revs |
|
458 # and values to indicate as such. |
|
459 self.revs = [0] |
|
460 self.values = [None] |
|
461 |
|
462 # We don't have any revisions cached |
|
463 self._initial_value = None |
|
464 self._changed_revs = [] |
|
465 self._changed_values = [] |
|
466 |
|
467 def load(self, log): |
|
468 """ |
|
469 Load the history of property changes from the specified |
|
470 RevisionLog object. |
|
471 """ |
|
472 |
|
473 # Get the property value before the range of the log |
|
474 if log.begin > 1: |
|
475 self.revs.append(log.begin-1) |
|
476 try: |
|
477 self._initial_value = self.raw_get(log.begin-1) |
|
478 except LaunchError: |
|
479 # The specified URL might not exist before the |
|
480 # range of the log. If so, we can safely assume |
|
481 # that the property was empty at that time. |
|
482 self._initial_value = { } |
|
483 self.values.append(self._initial_value) |
|
484 else: |
|
485 self._initial_value = { } |
|
486 self.values[0] = self._initial_value |
|
487 |
|
488 # Cache the property values in the log range |
|
489 old_value = self._initial_value |
|
490 for rev in log.propchange_revs: |
|
491 new_value = self.raw_get(rev) |
|
492 if new_value != old_value: |
|
493 self._changed_revs.append(rev) |
|
494 self._changed_values.append(new_value) |
|
495 self.revs.append(rev) |
|
496 self.values.append(new_value) |
|
497 old_value = new_value |
|
498 |
|
499 # Indicate that we know nothing about the value of the property |
|
500 # after the range of the log. |
|
501 if log.revs: |
|
502 self.revs.append(log.end+1) |
|
503 self.values.append(None) |
|
504 |
|
505 def raw_get(self, rev=None): |
|
506 """ |
|
507 Get the property at revision REV. If rev is not specified, get |
|
508 the property at revision HEAD. |
|
509 """ |
|
510 return get_revlist_prop(self.url, self.name, rev) |
|
511 |
|
512 def get(self, rev=None): |
|
513 """ |
|
514 Get the property at revision REV. If rev is not specified, get |
|
515 the property at revision HEAD. |
|
516 """ |
|
517 |
|
518 if rev is not None: |
|
519 |
|
520 # Find the index using a binary search |
|
521 i = bisect(self.revs, rev) - 1 |
|
522 |
|
523 # Return the value of the property, if it was cached |
|
524 if self.values[i] is not None: |
|
525 return self.values[i] |
|
526 |
|
527 # Get the current value of the property |
|
528 return self.raw_get(rev) |
|
529 |
|
530 def changed_revs(self, key=None): |
|
531 """ |
|
532 Get a list of the revisions in which the specified dictionary |
|
533 key was changed in this property. If key is not specified, |
|
534 return a list of revisions in which any key was changed. |
|
535 """ |
|
536 if key is None: |
|
537 return self._changed_revs |
|
538 else: |
|
539 changed_revs = [] |
|
540 old_val = self._initial_value |
|
541 for rev, val in zip(self._changed_revs, self._changed_values): |
|
542 if val.get(key) != old_val.get(key): |
|
543 changed_revs.append(rev) |
|
544 old_val = val |
|
545 return changed_revs |
|
546 |
|
547 def initialized_revs(self): |
|
548 """ |
|
549 Get a list of the revisions in which keys were added or |
|
550 removed in this property. |
|
551 """ |
|
552 initialized_revs = [] |
|
553 old_len = len(self._initial_value) |
|
554 for rev, val in zip(self._changed_revs, self._changed_values): |
|
555 if len(val) != old_len: |
|
556 initialized_revs.append(rev) |
|
557 old_len = len(val) |
|
558 return initialized_revs |
|
559 |
|
560 class RevisionSet: |
|
561 """ |
|
562 A set of revisions, held in dictionary form for easy manipulation. If we |
|
563 were to rewrite this script for Python 2.3+, we would subclass this from |
|
564 set (or UserSet). As this class does not include branch |
|
565 information, it's assumed that one instance will be used per |
|
566 branch. |
|
567 """ |
|
568 def __init__(self, parm): |
|
569 """Constructs a RevisionSet from a string in property form, or from |
|
570 a dictionary whose keys are the revisions. Raises ValueError if the |
|
571 input string is invalid.""" |
|
572 |
|
573 self._revs = {} |
|
574 |
|
575 revision_range_split_re = re.compile('[-:]') |
|
576 |
|
577 if isinstance(parm, types.DictType): |
|
578 self._revs = parm.copy() |
|
579 elif isinstance(parm, types.ListType): |
|
580 for R in parm: |
|
581 self._revs[int(R)] = 1 |
|
582 else: |
|
583 parm = parm.strip() |
|
584 if parm: |
|
585 for R in parm.split(","): |
|
586 rev_or_revs = re.split(revision_range_split_re, R) |
|
587 if len(rev_or_revs) == 1: |
|
588 self._revs[int(rev_or_revs[0])] = 1 |
|
589 elif len(rev_or_revs) == 2: |
|
590 for rev in range(int(rev_or_revs[0]), |
|
591 int(rev_or_revs[1])+1): |
|
592 self._revs[rev] = 1 |
|
593 else: |
|
594 raise ValueError, 'Ill formatted revision range: ' + R |
|
595 |
|
596 def sorted(self): |
|
597 revnums = self._revs.keys() |
|
598 revnums.sort() |
|
599 return revnums |
|
600 |
|
601 def normalized(self): |
|
602 """Returns a normalized version of the revision set, which is an |
|
603 ordered list of couples (start,end), with the minimum number of |
|
604 intervals.""" |
|
605 revnums = self.sorted() |
|
606 revnums.reverse() |
|
607 ret = [] |
|
608 while revnums: |
|
609 s = e = revnums.pop() |
|
610 while revnums and revnums[-1] in (e, e+1): |
|
611 e = revnums.pop() |
|
612 ret.append((s, e)) |
|
613 return ret |
|
614 |
|
615 def __str__(self): |
|
616 """Convert the revision set to a string, using its normalized form.""" |
|
617 L = [] |
|
618 for s,e in self.normalized(): |
|
619 if s == e: |
|
620 L.append(str(s)) |
|
621 else: |
|
622 L.append(str(s) + "-" + str(e)) |
|
623 return ",".join(L) |
|
624 |
|
625 def __contains__(self, rev): |
|
626 return self._revs.has_key(rev) |
|
627 |
|
628 def __sub__(self, rs): |
|
629 """Compute subtraction as in sets.""" |
|
630 revs = {} |
|
631 for r in self._revs.keys(): |
|
632 if r not in rs: |
|
633 revs[r] = 1 |
|
634 return RevisionSet(revs) |
|
635 |
|
636 def __and__(self, rs): |
|
637 """Compute intersections as in sets.""" |
|
638 revs = {} |
|
639 for r in self._revs.keys(): |
|
640 if r in rs: |
|
641 revs[r] = 1 |
|
642 return RevisionSet(revs) |
|
643 |
|
644 def __nonzero__(self): |
|
645 return len(self._revs) != 0 |
|
646 |
|
647 def __len__(self): |
|
648 """Return the number of revisions in the set.""" |
|
649 return len(self._revs) |
|
650 |
|
651 def __iter__(self): |
|
652 return iter(self.sorted()) |
|
653 |
|
654 def __or__(self, rs): |
|
655 """Compute set union.""" |
|
656 revs = self._revs.copy() |
|
657 revs.update(rs._revs) |
|
658 return RevisionSet(revs) |
|
659 |
|
660 def merge_props_to_revision_set(merge_props, pathid): |
|
661 """A converter which returns a RevisionSet instance containing the |
|
662 revisions from PATH as known to BRANCH_PROPS. BRANCH_PROPS is a |
|
663 dictionary of pathid -> revision set branch integration information |
|
664 (as returned by get_merge_props()).""" |
|
665 if not merge_props.has_key(pathid): |
|
666 error('no integration info available for path "%s"' % pathid) |
|
667 return RevisionSet(merge_props[pathid]) |
|
668 |
|
669 def dict_from_revlist_prop(propvalue): |
|
670 """Given a property value as a string containing per-source revision |
|
671 lists, return a dictionary whose key is a source path identifier |
|
672 and whose value is the revisions for that source.""" |
|
673 prop = {} |
|
674 |
|
675 # Multiple sources are separated by any whitespace. |
|
676 for L in propvalue.split(): |
|
677 # We use rsplit to play safe and allow colons in pathids. |
|
678 source, revs = rsplit(L.strip(), ":", 1) |
|
679 prop[source] = revs |
|
680 return prop |
|
681 |
|
682 def get_revlist_prop(url_or_dir, propname, rev=None): |
|
683 """Given a repository URL or working copy path and a property |
|
684 name, extract the values of the property which store per-source |
|
685 revision lists and return a dictionary whose key is a source path |
|
686 identifier, and whose value is the revisions for that source.""" |
|
687 |
|
688 # Note that propget does not return an error if the property does |
|
689 # not exist, it simply does not output anything. So we do not need |
|
690 # to check for LaunchError here. |
|
691 args = '--strict "%s" "%s"' % (propname, url_or_dir) |
|
692 if rev: |
|
693 args = '-r %s %s' % (rev, args) |
|
694 out = launchsvn('propget %s' % args, split_lines=False) |
|
695 |
|
696 return dict_from_revlist_prop(out) |
|
697 |
|
698 def get_merge_props(dir): |
|
699 """Extract the merged revisions.""" |
|
700 return get_revlist_prop(dir, opts["prop"]) |
|
701 |
|
702 def get_block_props(dir): |
|
703 """Extract the blocked revisions.""" |
|
704 return get_revlist_prop(dir, opts["block-prop"]) |
|
705 |
|
706 def get_blocked_revs(dir, source_pathid): |
|
707 p = get_block_props(dir) |
|
708 if p.has_key(source_pathid): |
|
709 return RevisionSet(p[source_pathid]) |
|
710 return RevisionSet("") |
|
711 |
|
712 def format_merge_props(props, sep=" "): |
|
713 """Formats the hash PROPS as a string suitable for use as a |
|
714 Subversion property value.""" |
|
715 assert sep in ["\t", "\n", " "] # must be a whitespace |
|
716 props = props.items() |
|
717 props.sort() |
|
718 L = [] |
|
719 for h, r in props: |
|
720 L.append(h + ":" + r) |
|
721 return sep.join(L) |
|
722 |
|
723 def _run_propset(dir, prop, value): |
|
724 """Set the property 'prop' of directory 'dir' to value 'value'. We go |
|
725 through a temporary file to not run into command line length limits.""" |
|
726 try: |
|
727 fd, fname = tempfile.mkstemp() |
|
728 f = os.fdopen(fd, "wb") |
|
729 except AttributeError: |
|
730 # Fallback for Python <= 2.3 which does not have mkstemp (mktemp |
|
731 # suffers from race conditions. Not that we care...) |
|
732 fname = tempfile.mktemp() |
|
733 f = open(fname, "wb") |
|
734 |
|
735 try: |
|
736 f.write(value) |
|
737 f.close() |
|
738 report("property data written to temp file: %s" % value) |
|
739 svn_command('propset "%s" -F "%s" "%s"' % (prop, fname, dir)) |
|
740 finally: |
|
741 os.remove(fname) |
|
742 |
|
743 def set_props(dir, name, props): |
|
744 props = format_merge_props(props) |
|
745 if props: |
|
746 _run_propset(dir, name, props) |
|
747 else: |
|
748 svn_command('propdel "%s" "%s"' % (name, dir)) |
|
749 |
|
750 def set_merge_props(dir, props): |
|
751 set_props(dir, opts["prop"], props) |
|
752 |
|
753 def set_block_props(dir, props): |
|
754 set_props(dir, opts["block-prop"], props) |
|
755 |
|
756 def set_blocked_revs(dir, source_pathid, revs): |
|
757 props = get_block_props(dir) |
|
758 if revs: |
|
759 props[source_pathid] = str(revs) |
|
760 elif props.has_key(source_pathid): |
|
761 del props[source_pathid] |
|
762 set_block_props(dir, props) |
|
763 |
|
764 def is_url(url): |
|
765 """Check if url is a valid url.""" |
|
766 return re.search(r"^[a-zA-Z][-+\.\w]*://[^\s]+$", url) is not None |
|
767 |
|
768 def is_wc(dir): |
|
769 """Check if a directory is a working copy.""" |
|
770 return os.path.isdir(os.path.join(dir, ".svn")) or \ |
|
771 os.path.isdir(os.path.join(dir, "_svn")) |
|
772 |
|
773 _cache_svninfo = {} |
|
774 def get_svninfo(target): |
|
775 """Extract the subversion information for a target (through 'svn info'). |
|
776 This function uses an internal cache to let clients query information |
|
777 many times.""" |
|
778 if _cache_svninfo.has_key(target): |
|
779 return _cache_svninfo[target] |
|
780 info = {} |
|
781 for L in launchsvn('info "%s"' % target): |
|
782 L = L.strip() |
|
783 if not L: |
|
784 continue |
|
785 key, value = L.split(": ", 1) |
|
786 info[key] = value.strip() |
|
787 _cache_svninfo[target] = info |
|
788 return info |
|
789 |
|
790 def target_to_url(target): |
|
791 """Convert working copy path or repos URL to a repos URL.""" |
|
792 if is_wc(target): |
|
793 info = get_svninfo(target) |
|
794 return info["URL"] |
|
795 return target |
|
796 |
|
797 _cache_reporoot = {} |
|
798 def get_repo_root(target): |
|
799 """Compute the root repos URL given a working-copy path, or a URL.""" |
|
800 # Try using "svn info WCDIR". This works only on SVN clients >= 1.3 |
|
801 if not is_url(target): |
|
802 try: |
|
803 info = get_svninfo(target) |
|
804 root = info["Repository Root"] |
|
805 _cache_reporoot[root] = None |
|
806 return root |
|
807 except KeyError: |
|
808 pass |
|
809 url = target_to_url(target) |
|
810 assert url[-1] != '/' |
|
811 else: |
|
812 url = target |
|
813 |
|
814 # Go through the cache of the repository roots. This avoids extra |
|
815 # server round-trips if we are asking the root of different URLs |
|
816 # in the same repository (the cache in get_svninfo() cannot detect |
|
817 # that of course and would issue a remote command). |
|
818 assert is_url(url) |
|
819 for r in _cache_reporoot: |
|
820 if url.startswith(r): |
|
821 return r |
|
822 |
|
823 # Try using "svn info URL". This works only on SVN clients >= 1.2 |
|
824 try: |
|
825 info = get_svninfo(url) |
|
826 root = info["Repository Root"] |
|
827 _cache_reporoot[root] = None |
|
828 return root |
|
829 except LaunchError: |
|
830 pass |
|
831 |
|
832 # Constrained to older svn clients, we are stuck with this ugly |
|
833 # trial-and-error implementation. It could be made faster with a |
|
834 # binary search. |
|
835 while url: |
|
836 temp = os.path.dirname(url) |
|
837 try: |
|
838 launchsvn('proplist "%s"' % temp) |
|
839 except LaunchError: |
|
840 _cache_reporoot[url] = None |
|
841 return url |
|
842 url = temp |
|
843 |
|
844 assert False, "svn repos root not found" |
|
845 |
|
846 def target_to_pathid(target): |
|
847 """Convert a target (either a working copy path or an URL) into a |
|
848 path identifier.""" |
|
849 root = get_repo_root(target) |
|
850 url = target_to_url(target) |
|
851 assert root[-1] != "/" |
|
852 assert url[:len(root)] == root, "url=%r, root=%r" % (url, root) |
|
853 return url[len(root):] |
|
854 |
|
855 class SvnLogParser: |
|
856 """ |
|
857 Parse the "svn log", going through the XML output and using pulldom (which |
|
858 would even allow streaming the command output). |
|
859 """ |
|
860 def __init__(self, xml): |
|
861 self._events = pulldom.parseString(xml) |
|
862 def __getitem__(self, idx): |
|
863 for event, node in self._events: |
|
864 if event == pulldom.START_ELEMENT and node.tagName == "logentry": |
|
865 self._events.expandNode(node) |
|
866 return self.SvnLogRevision(node) |
|
867 raise IndexError, "Could not find 'logentry' tag in xml" |
|
868 |
|
869 class SvnLogRevision: |
|
870 def __init__(self, xmlnode): |
|
871 self.n = xmlnode |
|
872 def revision(self): |
|
873 return int(self.n.getAttribute("revision")) |
|
874 def author(self): |
|
875 return self.n.getElementsByTagName("author")[0].firstChild.data |
|
876 def paths(self): |
|
877 return [self.SvnLogPath(n) |
|
878 for n in self.n.getElementsByTagName("path")] |
|
879 |
|
880 class SvnLogPath: |
|
881 def __init__(self, xmlnode): |
|
882 self.n = xmlnode |
|
883 def action(self): |
|
884 return self.n.getAttribute("action") |
|
885 def pathid(self): |
|
886 return self.n.firstChild.data |
|
887 def copyfrom_rev(self): |
|
888 try: return self.n.getAttribute("copyfrom-rev") |
|
889 except KeyError: return None |
|
890 def copyfrom_pathid(self): |
|
891 try: return self.n.getAttribute("copyfrom-path") |
|
892 except KeyError: return None |
|
893 |
|
894 def get_copyfrom(target): |
|
895 """Get copyfrom info for a given target (it represents the directory from |
|
896 where it was branched). NOTE: repos root has no copyfrom info. In this case |
|
897 None is returned. |
|
898 |
|
899 Returns the: |
|
900 - source file or directory from which the copy was made |
|
901 - revision from which that source was copied |
|
902 - revision in which the copy was committed |
|
903 """ |
|
904 repos_path = target_to_pathid(target) |
|
905 for chg in SvnLogParser(launchsvn('log -v --xml --stop-on-copy "%s"' |
|
906 % target, split_lines=False)): |
|
907 for p in chg.paths(): |
|
908 if p.action() == 'A' and p.pathid() == repos_path: |
|
909 # These values will be None if the corresponding elements are |
|
910 # not found in the log. |
|
911 return p.copyfrom_pathid(), p.copyfrom_rev(), chg.revision() |
|
912 return None,None,None |
|
913 |
|
914 def get_latest_rev(url): |
|
915 """Get the latest revision of the repository of which URL is part.""" |
|
916 try: |
|
917 return get_svninfo(url)["Revision"] |
|
918 except LaunchError: |
|
919 # Alternative method for latest revision checking (for svn < 1.2) |
|
920 report('checking latest revision of "%s"' % url) |
|
921 L = launchsvn('proplist --revprop -r HEAD "%s"' % opts["source-url"])[0] |
|
922 rev = re.search("revision (\d+)", L).group(1) |
|
923 report('latest revision of "%s" is %s' % (url, rev)) |
|
924 return rev |
|
925 |
|
926 def get_created_rev(url): |
|
927 """Lookup the revision at which the path identified by the |
|
928 provided URL was first created.""" |
|
929 oldest_rev = -1 |
|
930 report('determining oldest revision for URL "%s"' % url) |
|
931 ### TODO: Refactor this to use a modified RevisionLog class. |
|
932 lines = None |
|
933 cmd = "log -r1:HEAD --stop-on-copy -q " + url |
|
934 try: |
|
935 lines = launchsvn(cmd + " --limit=1") |
|
936 except LaunchError: |
|
937 # Assume that --limit isn't supported by the installed 'svn'. |
|
938 lines = launchsvn(cmd) |
|
939 if lines and len(lines) > 1: |
|
940 i = lines[1].find(" ") |
|
941 if i != -1: |
|
942 oldest_rev = int(lines[1][1:i]) |
|
943 if oldest_rev == -1: |
|
944 error('unable to determine oldest revision for URL "%s"' % url) |
|
945 return oldest_rev |
|
946 |
|
947 def get_commit_log(url, revnum): |
|
948 """Return the log message for a specific integer revision |
|
949 number.""" |
|
950 out = launchsvn("log --incremental -r%d %s" % (revnum, url)) |
|
951 return recode_stdout_to_file("".join(out[1:])) |
|
952 |
|
953 def construct_merged_log_message(url, revnums): |
|
954 """Return a commit log message containing all the commit messages |
|
955 in the specified revisions at the given URL. The separator used |
|
956 in this log message is determined by searching for the longest |
|
957 svnmerge separator existing in the commit log messages and |
|
958 extending it by one more separator. This results in a new commit |
|
959 log message that is clearer in describing merges that contain |
|
960 other merges. Trailing newlines are removed from the embedded |
|
961 log messages.""" |
|
962 messages = [''] |
|
963 longest_sep = '' |
|
964 for r in revnums.sorted(): |
|
965 message = get_commit_log(url, r) |
|
966 if message: |
|
967 message = re.sub(r'(\r\n|\r|\n)', "\n", message) |
|
968 message = rstrip(message, "\n") + "\n" |
|
969 messages.append(prefix_lines(LOG_LINE_PREFIX, message)) |
|
970 for match in LOG_SEPARATOR_RE.findall(message): |
|
971 sep = match[1] |
|
972 if len(sep) > len(longest_sep): |
|
973 longest_sep = sep |
|
974 |
|
975 longest_sep += LOG_SEPARATOR + "\n" |
|
976 messages.append('') |
|
977 return longest_sep.join(messages) |
|
978 |
|
979 def get_default_source(branch_target, branch_props): |
|
980 """Return the default source for branch_target (given its branch_props). |
|
981 Error out if there is ambiguity.""" |
|
982 if not branch_props: |
|
983 error("no integration info available") |
|
984 |
|
985 props = branch_props.copy() |
|
986 pathid = target_to_pathid(branch_target) |
|
987 |
|
988 # To make bidirectional merges easier, find the target's |
|
989 # repository local path so it can be removed from the list of |
|
990 # possible integration sources. |
|
991 if props.has_key(pathid): |
|
992 del props[pathid] |
|
993 |
|
994 if len(props) > 1: |
|
995 err_msg = "multiple sources found. " |
|
996 err_msg += "Explicit source argument (-S/--source) required.\n" |
|
997 err_msg += "The merge sources available are:" |
|
998 for prop in props: |
|
999 err_msg += "\n " + prop |
|
1000 error(err_msg) |
|
1001 |
|
1002 return props.keys()[0] |
|
1003 |
|
1004 def check_old_prop_version(branch_target, branch_props): |
|
1005 """Check if branch_props (of branch_target) are svnmerge properties in |
|
1006 old format, and emit an error if so.""" |
|
1007 |
|
1008 # Previous svnmerge versions allowed trailing /'s in the repository |
|
1009 # local path. Newer versions of svnmerge will trim trailing /'s |
|
1010 # appearing in the command line, so if there are any properties with |
|
1011 # trailing /'s, they will not be properly matched later on, so require |
|
1012 # the user to change them now. |
|
1013 fixed = {} |
|
1014 changed = False |
|
1015 for source, revs in branch_props.items(): |
|
1016 src = rstrip(source, "/") |
|
1017 fixed[src] = revs |
|
1018 if src != source: |
|
1019 changed = True |
|
1020 |
|
1021 if changed: |
|
1022 err_msg = "old property values detected; an upgrade is required.\n\n" |
|
1023 err_msg += "Please execute and commit these changes to upgrade:\n\n" |
|
1024 err_msg += 'svn propset "%s" "%s" "%s"' % \ |
|
1025 (opts["prop"], format_merge_props(fixed), branch_target) |
|
1026 error(err_msg) |
|
1027 |
|
1028 def should_find_reflected(branch_dir): |
|
1029 should_find_reflected = opts["bidirectional"] |
|
1030 |
|
1031 # If the source has integration info for the target, set find_reflected |
|
1032 # even if --bidirectional wasn't specified |
|
1033 if not should_find_reflected: |
|
1034 source_props = get_merge_props(opts["source-url"]) |
|
1035 should_find_reflected = source_props.has_key(target_to_pathid(branch_dir)) |
|
1036 |
|
1037 return should_find_reflected |
|
1038 |
|
1039 def analyze_revs(target_pathid, url, begin=1, end=None, |
|
1040 find_reflected=False): |
|
1041 """For the source of the merges in the source URL being merged into |
|
1042 target_pathid, analyze the revisions in the interval begin-end (which |
|
1043 defaults to 1-HEAD), to find out which revisions are changes in |
|
1044 the url, which are changes elsewhere (so-called 'phantom' |
|
1045 revisions), optionally which are reflected changes (to avoid |
|
1046 conflicts that can occur when doing bidirectional merging between |
|
1047 branches), and which revisions initialize merge tracking against other |
|
1048 branches. Return a tuple of four RevisionSet's: |
|
1049 (real_revs, phantom_revs, reflected_revs, initialized_revs). |
|
1050 |
|
1051 NOTE: To maximize speed, if "end" is not provided, the function is |
|
1052 not able to find phantom revisions following the last real |
|
1053 revision in the URL. |
|
1054 """ |
|
1055 |
|
1056 begin = str(begin) |
|
1057 if end is None: |
|
1058 end = "HEAD" |
|
1059 else: |
|
1060 end = str(end) |
|
1061 if long(begin) > long(end): |
|
1062 return RevisionSet(""), RevisionSet(""), \ |
|
1063 RevisionSet(""), RevisionSet("") |
|
1064 |
|
1065 logs[url] = RevisionLog(url, begin, end, find_reflected) |
|
1066 revs = RevisionSet(logs[url].revs) |
|
1067 |
|
1068 if end == "HEAD": |
|
1069 # If end is not provided, we do not know which is the latest revision |
|
1070 # in the repository. So return the phantom revision set only up to |
|
1071 # the latest known revision. |
|
1072 end = str(list(revs)[-1]) |
|
1073 |
|
1074 phantom_revs = RevisionSet("%s-%s" % (begin, end)) - revs |
|
1075 |
|
1076 if find_reflected: |
|
1077 reflected_revs = logs[url].merge_metadata().changed_revs(target_pathid) |
|
1078 reflected_revs += logs[url].block_metadata().changed_revs(target_pathid) |
|
1079 else: |
|
1080 reflected_revs = [] |
|
1081 |
|
1082 initialized_revs = RevisionSet(logs[url].merge_metadata().initialized_revs()) |
|
1083 reflected_revs = RevisionSet(reflected_revs) |
|
1084 |
|
1085 return revs, phantom_revs, reflected_revs, initialized_revs |
|
1086 |
|
1087 def analyze_source_revs(branch_target, source_url, **kwargs): |
|
1088 """For the given branch and source, extract the real and phantom |
|
1089 source revisions.""" |
|
1090 branch_url = target_to_url(branch_target) |
|
1091 branch_pathid = target_to_pathid(branch_target) |
|
1092 |
|
1093 # Extract the latest repository revision from the URL of the branch |
|
1094 # directory (which is already cached at this point). |
|
1095 end_rev = get_latest_rev(source_url) |
|
1096 |
|
1097 # Calculate the base of analysis. If there is a "1-XX" interval in the |
|
1098 # merged_revs, we do not need to check those. |
|
1099 base = 1 |
|
1100 r = opts["merged-revs"].normalized() |
|
1101 if r and r[0][0] == 1: |
|
1102 base = r[0][1] + 1 |
|
1103 |
|
1104 # See if the user filtered the revision set. If so, we are not |
|
1105 # interested in something outside that range. |
|
1106 if opts["revision"]: |
|
1107 revs = RevisionSet(opts["revision"]).sorted() |
|
1108 if base < revs[0]: |
|
1109 base = revs[0] |
|
1110 if end_rev > revs[-1]: |
|
1111 end_rev = revs[-1] |
|
1112 |
|
1113 return analyze_revs(branch_pathid, source_url, base, end_rev, **kwargs) |
|
1114 |
|
1115 def minimal_merge_intervals(revs, phantom_revs): |
|
1116 """Produce the smallest number of intervals suitable for merging. revs |
|
1117 is the RevisionSet which we want to merge, and phantom_revs are phantom |
|
1118 revisions which can be used to concatenate intervals, thus minimizing the |
|
1119 number of operations.""" |
|
1120 revnums = revs.normalized() |
|
1121 ret = [] |
|
1122 |
|
1123 cur = revnums.pop() |
|
1124 while revnums: |
|
1125 next = revnums.pop() |
|
1126 assert next[1] < cur[0] # otherwise it is not ordered |
|
1127 assert cur[0] - next[1] > 1 # otherwise it is not normalized |
|
1128 for i in range(next[1]+1, cur[0]): |
|
1129 if i not in phantom_revs: |
|
1130 ret.append(cur) |
|
1131 cur = next |
|
1132 break |
|
1133 else: |
|
1134 cur = (next[0], cur[1]) |
|
1135 |
|
1136 ret.append(cur) |
|
1137 ret.reverse() |
|
1138 return ret |
|
1139 |
|
1140 def display_revisions(revs, display_style, revisions_msg, source_url): |
|
1141 """Show REVS as dictated by DISPLAY_STYLE, either numerically, in |
|
1142 log format, or as diffs. When displaying revisions numerically, |
|
1143 prefix output with REVISIONS_MSG when in verbose mode. Otherwise, |
|
1144 request logs or diffs using SOURCE_URL.""" |
|
1145 if display_style == "revisions": |
|
1146 if revs: |
|
1147 report(revisions_msg) |
|
1148 print revs |
|
1149 elif display_style == "logs": |
|
1150 for start,end in revs.normalized(): |
|
1151 svn_command('log --incremental -v -r %d:%d %s' % \ |
|
1152 (start, end, source_url)) |
|
1153 elif display_style in ("diffs", "summarize"): |
|
1154 if display_style == 'summarize': |
|
1155 summarize = '--summarize ' |
|
1156 else: |
|
1157 summarize = '' |
|
1158 |
|
1159 for start, end in revs.normalized(): |
|
1160 print |
|
1161 if start == end: |
|
1162 print "%s: changes in revision %d follow" % (NAME, start) |
|
1163 else: |
|
1164 print "%s: changes in revisions %d-%d follow" % (NAME, |
|
1165 start, end) |
|
1166 print |
|
1167 |
|
1168 # Note: the starting revision number to 'svn diff' is |
|
1169 # NOT inclusive so we have to subtract one from ${START}. |
|
1170 svn_command("diff -r %d:%d %s %s" % (start - 1, end, summarize, |
|
1171 source_url)) |
|
1172 else: |
|
1173 assert False, "unhandled display style: %s" % display_style |
|
1174 |
|
1175 def action_init(target_dir, target_props): |
|
1176 """Initialize for merges.""" |
|
1177 # Check that directory is ready for being modified |
|
1178 check_dir_clean(target_dir) |
|
1179 |
|
1180 # If the user hasn't specified the revisions to use, see if the |
|
1181 # "source" is a copy from the current tree and if so, we can use |
|
1182 # the version data obtained from it. |
|
1183 revision_range = opts["revision"] |
|
1184 if not revision_range: |
|
1185 # Determining a default endpoint for the revision range that "init" |
|
1186 # will use, since none was provided by the user. |
|
1187 cf_source, cf_rev, copy_committed_in_rev = \ |
|
1188 get_copyfrom(opts["source-url"]) |
|
1189 target_path = target_to_pathid(target_dir) |
|
1190 |
|
1191 if target_path == cf_source: |
|
1192 # If source was originally copyied from target, and we are merging |
|
1193 # changes from source to target (the copy target is the merge |
|
1194 # source, and the copy source is the merge target), then we want to |
|
1195 # mark as integrated up to the rev in which the copy was committed |
|
1196 # which created the merge source: |
|
1197 report('the source "%s" is a branch of "%s"' % |
|
1198 (opts["source-url"], target_dir)) |
|
1199 revision_range = "1-" + str(copy_committed_in_rev) |
|
1200 else: |
|
1201 # If the copy source is the merge source, and |
|
1202 # the copy target is the merge target, then we want to |
|
1203 # mark as integrated up to the specific rev of the merge |
|
1204 # target from which the merge source was copied. Longer |
|
1205 # discussion here: |
|
1206 # http://subversion.tigris.org/issues/show_bug.cgi?id=2810 |
|
1207 target_url = target_to_url(target_dir) |
|
1208 source_path = target_to_pathid(opts["source-url"]) |
|
1209 cf_source_path, cf_rev, copy_committed_in_rev = get_copyfrom(target_url) |
|
1210 if source_path == cf_source_path: |
|
1211 report('the merge source "%s" is the copy source of "%s"' % |
|
1212 (opts["source-url"], target_dir)) |
|
1213 revision_range = "1-" + cf_rev |
|
1214 |
|
1215 # When neither the merge source nor target is a copy of the other, and |
|
1216 # the user did not specify a revision range, then choose a default which is |
|
1217 # the current revision; saying, in effect, "everything has been merged, so |
|
1218 # mark as integrated up to the latest rev on source url). |
|
1219 revs = revision_range or "1-" + get_latest_rev(opts["source-url"]) |
|
1220 revs = RevisionSet(revs) |
|
1221 |
|
1222 report('marking "%s" as already containing revisions "%s" of "%s"' % |
|
1223 (target_dir, revs, opts["source-url"])) |
|
1224 |
|
1225 revs = str(revs) |
|
1226 # If the local svnmerge-integrated property already has an entry |
|
1227 # for the source-pathid, simply error out. |
|
1228 if not opts["force"] and target_props.has_key(opts["source-pathid"]): |
|
1229 error('Repository-relative path %s has already been initialized at %s\n' |
|
1230 'Use --force to re-initialize' |
|
1231 % (opts["source-pathid"], target_dir)) |
|
1232 target_props[opts["source-pathid"]] = revs |
|
1233 |
|
1234 # Set property |
|
1235 set_merge_props(target_dir, target_props) |
|
1236 |
|
1237 # Write out commit message if desired |
|
1238 if opts["commit-file"]: |
|
1239 f = open(opts["commit-file"], "w") |
|
1240 print >>f, 'Initialized merge tracking via "%s" with revisions "%s" from ' \ |
|
1241 % (NAME, revs) |
|
1242 print >>f, '%s' % opts["source-url"] |
|
1243 f.close() |
|
1244 report('wrote commit message to "%s"' % opts["commit-file"]) |
|
1245 |
|
1246 def action_avail(branch_dir, branch_props): |
|
1247 """Show commits available for merges.""" |
|
1248 source_revs, phantom_revs, reflected_revs, initialized_revs = \ |
|
1249 analyze_source_revs(branch_dir, opts["source-url"], |
|
1250 find_reflected= |
|
1251 should_find_reflected(branch_dir)) |
|
1252 report('skipping phantom revisions: %s' % phantom_revs) |
|
1253 if reflected_revs: |
|
1254 report('skipping reflected revisions: %s' % reflected_revs) |
|
1255 report('skipping initialized revisions: %s' % initialized_revs) |
|
1256 |
|
1257 blocked_revs = get_blocked_revs(branch_dir, opts["source-pathid"]) |
|
1258 avail_revs = source_revs - opts["merged-revs"] - blocked_revs - \ |
|
1259 reflected_revs - initialized_revs |
|
1260 |
|
1261 # Compose the set of revisions to show |
|
1262 revs = RevisionSet("") |
|
1263 report_msg = "revisions available to be merged are:" |
|
1264 if "avail" in opts["avail-showwhat"]: |
|
1265 revs |= avail_revs |
|
1266 if "blocked" in opts["avail-showwhat"]: |
|
1267 revs |= blocked_revs |
|
1268 report_msg = "revisions blocked are:" |
|
1269 |
|
1270 # Limit to revisions specified by -r (if any) |
|
1271 if opts["revision"]: |
|
1272 revs = revs & RevisionSet(opts["revision"]) |
|
1273 |
|
1274 display_revisions(revs, opts["avail-display"], |
|
1275 report_msg, |
|
1276 opts["source-url"]) |
|
1277 |
|
1278 def action_integrated(branch_dir, branch_props): |
|
1279 """Show change sets already merged. This set of revisions is |
|
1280 calculated from taking svnmerge-integrated property from the |
|
1281 branch, and subtracting any revision older than the branch |
|
1282 creation revision.""" |
|
1283 # Extract the integration info for the branch_dir |
|
1284 branch_props = get_merge_props(branch_dir) |
|
1285 check_old_prop_version(branch_dir, branch_props) |
|
1286 revs = merge_props_to_revision_set(branch_props, opts["source-pathid"]) |
|
1287 |
|
1288 # Lookup the oldest revision on the branch path. |
|
1289 oldest_src_rev = get_created_rev(opts["source-url"]) |
|
1290 |
|
1291 # Subtract any revisions which pre-date the branch. |
|
1292 report("subtracting revisions which pre-date the source URL (%d)" % |
|
1293 oldest_src_rev) |
|
1294 revs = revs - RevisionSet(range(1, oldest_src_rev)) |
|
1295 |
|
1296 # Limit to revisions specified by -r (if any) |
|
1297 if opts["revision"]: |
|
1298 revs = revs & RevisionSet(opts["revision"]) |
|
1299 |
|
1300 display_revisions(revs, opts["integrated-display"], |
|
1301 "revisions already integrated are:", opts["source-url"]) |
|
1302 |
|
1303 def action_merge(branch_dir, branch_props): |
|
1304 """Record merge meta data, and do the actual merge (if not |
|
1305 requested otherwise via --record-only).""" |
|
1306 # Check branch directory is ready for being modified |
|
1307 check_dir_clean(branch_dir) |
|
1308 |
|
1309 source_revs, phantom_revs, reflected_revs, initialized_revs = \ |
|
1310 analyze_source_revs(branch_dir, opts["source-url"], |
|
1311 find_reflected= |
|
1312 should_find_reflected(branch_dir)) |
|
1313 |
|
1314 if opts["revision"]: |
|
1315 revs = RevisionSet(opts["revision"]) |
|
1316 else: |
|
1317 revs = source_revs |
|
1318 |
|
1319 blocked_revs = get_blocked_revs(branch_dir, opts["source-pathid"]) |
|
1320 merged_revs = opts["merged-revs"] |
|
1321 |
|
1322 # Show what we're doing |
|
1323 if opts["verbose"]: # just to avoid useless calculations |
|
1324 if merged_revs & revs: |
|
1325 report('"%s" already contains revisions %s' % (branch_dir, |
|
1326 merged_revs & revs)) |
|
1327 if phantom_revs: |
|
1328 report('memorizing phantom revision(s): %s' % phantom_revs) |
|
1329 if reflected_revs: |
|
1330 report('memorizing reflected revision(s): %s' % reflected_revs) |
|
1331 if blocked_revs & revs: |
|
1332 report('skipping blocked revisions(s): %s' % (blocked_revs & revs)) |
|
1333 if initialized_revs: |
|
1334 report('skipping initialized revision(s): %s' % initialized_revs) |
|
1335 |
|
1336 # Compute final merge set. |
|
1337 revs = revs - merged_revs - blocked_revs - reflected_revs - \ |
|
1338 phantom_revs - initialized_revs |
|
1339 if not revs: |
|
1340 report('no revisions to merge, exiting') |
|
1341 return |
|
1342 |
|
1343 # When manually marking revisions as merged, we only update the |
|
1344 # integration meta data, and don't perform an actual merge. |
|
1345 record_only = opts["record-only"] |
|
1346 |
|
1347 if record_only: |
|
1348 report('recording merge of revision(s) %s from "%s"' % |
|
1349 (revs, opts["source-url"])) |
|
1350 else: |
|
1351 report('merging in revision(s) %s from "%s"' % |
|
1352 (revs, opts["source-url"])) |
|
1353 |
|
1354 # Do the merge(s). Note: the starting revision number to 'svn merge' |
|
1355 # is NOT inclusive so we have to subtract one from start. |
|
1356 # We try to keep the number of merge operations as low as possible, |
|
1357 # because it is faster and reduces the number of conflicts. |
|
1358 old_block_props = get_block_props(branch_dir) |
|
1359 merge_metadata = logs[opts["source-url"]].merge_metadata() |
|
1360 block_metadata = logs[opts["source-url"]].block_metadata() |
|
1361 for start,end in minimal_merge_intervals(revs, phantom_revs): |
|
1362 if not record_only: |
|
1363 # Preset merge/blocked properties to the source value at |
|
1364 # the start rev to avoid spurious property conflicts |
|
1365 set_merge_props(branch_dir, merge_metadata.get(start - 1)) |
|
1366 set_block_props(branch_dir, block_metadata.get(start - 1)) |
|
1367 # Do the merge |
|
1368 svn_command("merge --force -r %d:%d %s %s" % \ |
|
1369 (start - 1, end, opts["source-url"], branch_dir)) |
|
1370 # TODO: to support graph merging, add logic to merge the property |
|
1371 # meta-data manually |
|
1372 |
|
1373 # Update the set of merged revisions. |
|
1374 merged_revs = merged_revs | revs | reflected_revs | phantom_revs | initialized_revs |
|
1375 branch_props[opts["source-pathid"]] = str(merged_revs) |
|
1376 set_merge_props(branch_dir, branch_props) |
|
1377 # Reset the blocked revs |
|
1378 set_block_props(branch_dir, old_block_props) |
|
1379 |
|
1380 # Write out commit message if desired |
|
1381 if opts["commit-file"]: |
|
1382 f = open(opts["commit-file"], "w") |
|
1383 if record_only: |
|
1384 print >>f, 'Recorded merge of revisions %s via %s from ' % \ |
|
1385 (revs, NAME) |
|
1386 else: |
|
1387 print >>f, 'Merged revisions %s via %s from ' % \ |
|
1388 (revs, NAME) |
|
1389 print >>f, '%s' % opts["source-url"] |
|
1390 if opts["commit-verbose"]: |
|
1391 print >>f |
|
1392 print >>f, construct_merged_log_message(opts["source-url"], revs), |
|
1393 |
|
1394 f.close() |
|
1395 report('wrote commit message to "%s"' % opts["commit-file"]) |
|
1396 |
|
1397 def action_block(branch_dir, branch_props): |
|
1398 """Block revisions.""" |
|
1399 # Check branch directory is ready for being modified |
|
1400 check_dir_clean(branch_dir) |
|
1401 |
|
1402 source_revs, phantom_revs, reflected_revs, initialized_revs = \ |
|
1403 analyze_source_revs(branch_dir, opts["source-url"]) |
|
1404 revs_to_block = source_revs - opts["merged-revs"] |
|
1405 |
|
1406 # Limit to revisions specified by -r (if any) |
|
1407 if opts["revision"]: |
|
1408 revs_to_block = RevisionSet(opts["revision"]) & revs_to_block |
|
1409 |
|
1410 if not revs_to_block: |
|
1411 error('no available revisions to block') |
|
1412 |
|
1413 # Change blocked information |
|
1414 blocked_revs = get_blocked_revs(branch_dir, opts["source-pathid"]) |
|
1415 blocked_revs = blocked_revs | revs_to_block |
|
1416 set_blocked_revs(branch_dir, opts["source-pathid"], blocked_revs) |
|
1417 |
|
1418 # Write out commit message if desired |
|
1419 if opts["commit-file"]: |
|
1420 f = open(opts["commit-file"], "w") |
|
1421 print >>f, 'Blocked revisions %s via %s' % (revs_to_block, NAME) |
|
1422 if opts["commit-verbose"]: |
|
1423 print >>f |
|
1424 print >>f, construct_merged_log_message(opts["source-url"], |
|
1425 revs_to_block), |
|
1426 |
|
1427 f.close() |
|
1428 report('wrote commit message to "%s"' % opts["commit-file"]) |
|
1429 |
|
1430 def action_unblock(branch_dir, branch_props): |
|
1431 """Unblock revisions.""" |
|
1432 # Check branch directory is ready for being modified |
|
1433 check_dir_clean(branch_dir) |
|
1434 |
|
1435 blocked_revs = get_blocked_revs(branch_dir, opts["source-pathid"]) |
|
1436 revs_to_unblock = blocked_revs |
|
1437 |
|
1438 # Limit to revisions specified by -r (if any) |
|
1439 if opts["revision"]: |
|
1440 revs_to_unblock = revs_to_unblock & RevisionSet(opts["revision"]) |
|
1441 |
|
1442 if not revs_to_unblock: |
|
1443 error('no available revisions to unblock') |
|
1444 |
|
1445 # Change blocked information |
|
1446 blocked_revs = blocked_revs - revs_to_unblock |
|
1447 set_blocked_revs(branch_dir, opts["source-pathid"], blocked_revs) |
|
1448 |
|
1449 # Write out commit message if desired |
|
1450 if opts["commit-file"]: |
|
1451 f = open(opts["commit-file"], "w") |
|
1452 print >>f, 'Unblocked revisions %s via %s' % (revs_to_unblock, NAME) |
|
1453 if opts["commit-verbose"]: |
|
1454 print >>f |
|
1455 print >>f, construct_merged_log_message(opts["source-url"], |
|
1456 revs_to_unblock), |
|
1457 f.close() |
|
1458 report('wrote commit message to "%s"' % opts["commit-file"]) |
|
1459 |
|
1460 def action_rollback(branch_dir, branch_props): |
|
1461 """Rollback previously integrated revisions.""" |
|
1462 |
|
1463 # Make sure the revision arguments are present |
|
1464 if not opts["revision"]: |
|
1465 error("The '-r' option is mandatory for rollback") |
|
1466 |
|
1467 # Check branch directory is ready for being modified |
|
1468 check_dir_clean(branch_dir) |
|
1469 |
|
1470 # Extract the integration info for the branch_dir |
|
1471 branch_props = get_merge_props(branch_dir) |
|
1472 check_old_prop_version(branch_dir, branch_props) |
|
1473 # Get the list of all revisions already merged into this source-pathid. |
|
1474 merged_revs = merge_props_to_revision_set(branch_props, |
|
1475 opts["source-pathid"]) |
|
1476 |
|
1477 # At which revision was the src created? |
|
1478 oldest_src_rev = get_created_rev(opts["source-url"]) |
|
1479 src_pre_exist_range = RevisionSet("1-%d" % oldest_src_rev) |
|
1480 |
|
1481 # Limit to revisions specified by -r (if any) |
|
1482 revs = merged_revs & RevisionSet(opts["revision"]) |
|
1483 |
|
1484 # make sure there's some revision to rollback |
|
1485 if not revs: |
|
1486 report("Nothing to rollback in revision range r%s" % opts["revision"]) |
|
1487 return |
|
1488 |
|
1489 # If even one specified revision lies outside the lifetime of the |
|
1490 # merge source, error out. |
|
1491 if revs & src_pre_exist_range: |
|
1492 err_str = "Specified revision range falls out of the rollback range.\n" |
|
1493 err_str += "%s was created at r%d" % (opts["source-pathid"], |
|
1494 oldest_src_rev) |
|
1495 error(err_str) |
|
1496 |
|
1497 record_only = opts["record-only"] |
|
1498 |
|
1499 if record_only: |
|
1500 report('recording rollback of revision(s) %s from "%s"' % |
|
1501 (revs, opts["source-url"])) |
|
1502 else: |
|
1503 report('rollback of revision(s) %s from "%s"' % |
|
1504 (revs, opts["source-url"])) |
|
1505 |
|
1506 # Do the reverse merge(s). Note: the starting revision number |
|
1507 # to 'svn merge' is NOT inclusive so we have to subtract one from start. |
|
1508 # We try to keep the number of merge operations as low as possible, |
|
1509 # because it is faster and reduces the number of conflicts. |
|
1510 rollback_intervals = minimal_merge_intervals(revs, []) |
|
1511 # rollback in the reverse order of merge |
|
1512 rollback_intervals.reverse() |
|
1513 for start, end in rollback_intervals: |
|
1514 if not record_only: |
|
1515 # Do the merge |
|
1516 svn_command("merge --force -r %d:%d %s %s" % \ |
|
1517 (end, start - 1, opts["source-url"], branch_dir)) |
|
1518 |
|
1519 # Write out commit message if desired |
|
1520 # calculate the phantom revs first |
|
1521 if opts["commit-file"]: |
|
1522 f = open(opts["commit-file"], "w") |
|
1523 if record_only: |
|
1524 print >>f, 'Recorded rollback of revisions %s via %s from ' % \ |
|
1525 (revs , NAME) |
|
1526 else: |
|
1527 print >>f, 'Rolled back revisions %s via %s from ' % \ |
|
1528 (revs , NAME) |
|
1529 print >>f, '%s' % opts["source-url"] |
|
1530 |
|
1531 f.close() |
|
1532 report('wrote commit message to "%s"' % opts["commit-file"]) |
|
1533 |
|
1534 # Update the set of merged revisions. |
|
1535 merged_revs = merged_revs - revs |
|
1536 branch_props[opts["source-pathid"]] = str(merged_revs) |
|
1537 set_merge_props(branch_dir, branch_props) |
|
1538 |
|
1539 def action_uninit(branch_dir, branch_props): |
|
1540 """Uninit SOURCE URL.""" |
|
1541 # Check branch directory is ready for being modified |
|
1542 check_dir_clean(branch_dir) |
|
1543 |
|
1544 # If the source-pathid does not have an entry in the svnmerge-integrated |
|
1545 # property, simply error out. |
|
1546 if not branch_props.has_key(opts["source-pathid"]): |
|
1547 error('Repository-relative path "%s" does not contain merge ' |
|
1548 'tracking information for "%s"' \ |
|
1549 % (opts["source-pathid"], branch_dir)) |
|
1550 |
|
1551 del branch_props[opts["source-pathid"]] |
|
1552 |
|
1553 # Set merge property with the selected source deleted |
|
1554 set_merge_props(branch_dir, branch_props) |
|
1555 |
|
1556 # Set blocked revisions for the selected source to None |
|
1557 set_blocked_revs(branch_dir, opts["source-pathid"], None) |
|
1558 |
|
1559 # Write out commit message if desired |
|
1560 if opts["commit-file"]: |
|
1561 f = open(opts["commit-file"], "w") |
|
1562 print >>f, 'Removed merge tracking for "%s" for ' % NAME |
|
1563 print >>f, '%s' % opts["source-url"] |
|
1564 f.close() |
|
1565 report('wrote commit message to "%s"' % opts["commit-file"]) |
|
1566 |
|
1567 ############################################################################### |
|
1568 # Command line parsing -- options and commands management |
|
1569 ############################################################################### |
|
1570 |
|
1571 class OptBase: |
|
1572 def __init__(self, *args, **kwargs): |
|
1573 self.help = kwargs["help"] |
|
1574 del kwargs["help"] |
|
1575 self.lflags = [] |
|
1576 self.sflags = [] |
|
1577 for a in args: |
|
1578 if a.startswith("--"): self.lflags.append(a) |
|
1579 elif a.startswith("-"): self.sflags.append(a) |
|
1580 else: |
|
1581 raise TypeError, "invalid flag name: %s" % a |
|
1582 if kwargs.has_key("dest"): |
|
1583 self.dest = kwargs["dest"] |
|
1584 del kwargs["dest"] |
|
1585 else: |
|
1586 if not self.lflags: |
|
1587 raise TypeError, "cannot deduce dest name without long options" |
|
1588 self.dest = self.lflags[0][2:] |
|
1589 if kwargs: |
|
1590 raise TypeError, "invalid keyword arguments: %r" % kwargs.keys() |
|
1591 def repr_flags(self): |
|
1592 f = self.sflags + self.lflags |
|
1593 r = f[0] |
|
1594 for fl in f[1:]: |
|
1595 r += " [%s]" % fl |
|
1596 return r |
|
1597 |
|
1598 class Option(OptBase): |
|
1599 def __init__(self, *args, **kwargs): |
|
1600 self.default = kwargs.setdefault("default", 0) |
|
1601 del kwargs["default"] |
|
1602 self.value = kwargs.setdefault("value", None) |
|
1603 del kwargs["value"] |
|
1604 OptBase.__init__(self, *args, **kwargs) |
|
1605 def apply(self, state, value): |
|
1606 assert value == "" |
|
1607 if self.value is not None: |
|
1608 state[self.dest] = self.value |
|
1609 else: |
|
1610 state[self.dest] += 1 |
|
1611 |
|
1612 class OptionArg(OptBase): |
|
1613 def __init__(self, *args, **kwargs): |
|
1614 self.default = kwargs["default"] |
|
1615 del kwargs["default"] |
|
1616 self.metavar = kwargs.setdefault("metavar", None) |
|
1617 del kwargs["metavar"] |
|
1618 OptBase.__init__(self, *args, **kwargs) |
|
1619 |
|
1620 if self.metavar is None: |
|
1621 if self.dest is not None: |
|
1622 self.metavar = self.dest.upper() |
|
1623 else: |
|
1624 self.metavar = "arg" |
|
1625 if self.default: |
|
1626 self.help += " (default: %s)" % self.default |
|
1627 def apply(self, state, value): |
|
1628 assert value is not None |
|
1629 state[self.dest] = value |
|
1630 def repr_flags(self): |
|
1631 r = OptBase.repr_flags(self) |
|
1632 return r + " " + self.metavar |
|
1633 |
|
1634 class CommandOpts: |
|
1635 class Cmd: |
|
1636 def __init__(self, *args): |
|
1637 self.name, self.func, self.usage, self.help, self.opts = args |
|
1638 def short_help(self): |
|
1639 return self.help.split(".")[0] |
|
1640 def __str__(self): |
|
1641 return self.name |
|
1642 def __call__(self, *args, **kwargs): |
|
1643 return self.func(*args, **kwargs) |
|
1644 |
|
1645 def __init__(self, global_opts, common_opts, command_table, version=None): |
|
1646 self.progname = NAME |
|
1647 self.version = version.replace("%prog", self.progname) |
|
1648 self.cwidth = console_width() - 2 |
|
1649 self.ctable = command_table.copy() |
|
1650 self.gopts = global_opts[:] |
|
1651 self.copts = common_opts[:] |
|
1652 self._add_builtins() |
|
1653 for k in self.ctable.keys(): |
|
1654 cmd = self.Cmd(k, *self.ctable[k]) |
|
1655 opts = [] |
|
1656 for o in cmd.opts: |
|
1657 if isinstance(o, types.StringType) or \ |
|
1658 isinstance(o, types.UnicodeType): |
|
1659 o = self._find_common(o) |
|
1660 opts.append(o) |
|
1661 cmd.opts = opts |
|
1662 self.ctable[k] = cmd |
|
1663 |
|
1664 def _add_builtins(self): |
|
1665 self.gopts.append( |
|
1666 Option("-h", "--help", help="show help for this command and exit")) |
|
1667 if self.version is not None: |
|
1668 self.gopts.append( |
|
1669 Option("-V", "--version", help="show version info and exit")) |
|
1670 self.ctable["help"] = (self._cmd_help, |
|
1671 "help [COMMAND]", |
|
1672 "Display help for a specific command. If COMMAND is omitted, " |
|
1673 "display brief command description.", |
|
1674 []) |
|
1675 |
|
1676 def _cmd_help(self, cmd=None, *args): |
|
1677 if args: |
|
1678 self.error("wrong number of arguments", "help") |
|
1679 if cmd is not None: |
|
1680 cmd = self._command(cmd) |
|
1681 self.print_command_help(cmd) |
|
1682 else: |
|
1683 self.print_command_list() |
|
1684 |
|
1685 def _paragraph(self, text, width=78): |
|
1686 chunks = re.split("\s+", text.strip()) |
|
1687 chunks.reverse() |
|
1688 lines = [] |
|
1689 while chunks: |
|
1690 L = chunks.pop() |
|
1691 while chunks and len(L) + len(chunks[-1]) + 1 <= width: |
|
1692 L += " " + chunks.pop() |
|
1693 lines.append(L) |
|
1694 return lines |
|
1695 |
|
1696 def _paragraphs(self, text, *args, **kwargs): |
|
1697 pars = text.split("\n\n") |
|
1698 lines = self._paragraph(pars[0], *args, **kwargs) |
|
1699 for p in pars[1:]: |
|
1700 lines.append("") |
|
1701 lines.extend(self._paragraph(p, *args, **kwargs)) |
|
1702 return lines |
|
1703 |
|
1704 def _print_wrapped(self, text, indent=0): |
|
1705 text = self._paragraphs(text, self.cwidth - indent) |
|
1706 print text.pop(0) |
|
1707 for t in text: |
|
1708 print " " * indent + t |
|
1709 |
|
1710 def _find_common(self, fl): |
|
1711 for o in self.copts: |
|
1712 if fl in o.lflags+o.sflags: |
|
1713 return o |
|
1714 assert False, fl |
|
1715 |
|
1716 def _compute_flags(self, opts, check_conflicts=True): |
|
1717 back = {} |
|
1718 sfl = "" |
|
1719 lfl = [] |
|
1720 for o in opts: |
|
1721 sapp = lapp = "" |
|
1722 if isinstance(o, OptionArg): |
|
1723 sapp, lapp = ":", "=" |
|
1724 for s in o.sflags: |
|
1725 if check_conflicts and back.has_key(s): |
|
1726 raise RuntimeError, "option conflict: %s" % s |
|
1727 back[s] = o |
|
1728 sfl += s[1:] + sapp |
|
1729 for l in o.lflags: |
|
1730 if check_conflicts and back.has_key(l): |
|
1731 raise RuntimeError, "option conflict: %s" % l |
|
1732 back[l] = o |
|
1733 lfl.append(l[2:] + lapp) |
|
1734 return sfl, lfl, back |
|
1735 |
|
1736 def _extract_command(self, args): |
|
1737 """ |
|
1738 Try to extract the command name from the argument list. This is |
|
1739 non-trivial because we want to allow command-specific options even |
|
1740 before the command itself. |
|
1741 """ |
|
1742 opts = self.gopts[:] |
|
1743 for cmd in self.ctable.values(): |
|
1744 opts.extend(cmd.opts) |
|
1745 sfl, lfl, _ = self._compute_flags(opts, check_conflicts=False) |
|
1746 |
|
1747 lopts,largs = getopt.getopt(args, sfl, lfl) |
|
1748 if not largs: |
|
1749 return None |
|
1750 return self._command(largs[0]) |
|
1751 |
|
1752 def _fancy_getopt(self, args, opts, state=None): |
|
1753 if state is None: |
|
1754 state= {} |
|
1755 for o in opts: |
|
1756 if not state.has_key(o.dest): |
|
1757 state[o.dest] = o.default |
|
1758 |
|
1759 sfl, lfl, back = self._compute_flags(opts) |
|
1760 try: |
|
1761 lopts,args = getopt.gnu_getopt(args, sfl, lfl) |
|
1762 except AttributeError: |
|
1763 # Before Python 2.3, there was no gnu_getopt support. |
|
1764 # So we can't parse intermixed positional arguments |
|
1765 # and options. |
|
1766 lopts,args = getopt.getopt(args, sfl, lfl) |
|
1767 |
|
1768 for o,v in lopts: |
|
1769 back[o].apply(state, v) |
|
1770 return state, args |
|
1771 |
|
1772 def _command(self, cmd): |
|
1773 if not self.ctable.has_key(cmd): |
|
1774 self.error("unknown command: '%s'" % cmd) |
|
1775 return self.ctable[cmd] |
|
1776 |
|
1777 def parse(self, args): |
|
1778 if not args: |
|
1779 self.print_small_help() |
|
1780 sys.exit(0) |
|
1781 |
|
1782 cmd = None |
|
1783 try: |
|
1784 cmd = self._extract_command(args) |
|
1785 opts = self.gopts[:] |
|
1786 if cmd: |
|
1787 opts.extend(cmd.opts) |
|
1788 args.remove(cmd.name) |
|
1789 state, args = self._fancy_getopt(args, opts) |
|
1790 except getopt.GetoptError, e: |
|
1791 self.error(e, cmd) |
|
1792 |
|
1793 # Handle builtins |
|
1794 if self.version is not None and state["version"]: |
|
1795 self.print_version() |
|
1796 sys.exit(0) |
|
1797 if state["help"]: # special case for --help |
|
1798 if cmd: |
|
1799 self.print_command_help(cmd) |
|
1800 sys.exit(0) |
|
1801 cmd = self.ctable["help"] |
|
1802 else: |
|
1803 if cmd is None: |
|
1804 self.error("command argument required") |
|
1805 if str(cmd) == "help": |
|
1806 cmd(*args) |
|
1807 sys.exit(0) |
|
1808 return cmd, args, state |
|
1809 |
|
1810 def error(self, s, cmd=None): |
|
1811 print >>sys.stderr, "%s: %s" % (self.progname, s) |
|
1812 if cmd is not None: |
|
1813 self.print_command_help(cmd) |
|
1814 else: |
|
1815 self.print_small_help() |
|
1816 sys.exit(1) |
|
1817 def print_small_help(self): |
|
1818 print "Type '%s help' for usage" % self.progname |
|
1819 def print_usage_line(self): |
|
1820 print "usage: %s <subcommand> [options...] [args...]\n" % self.progname |
|
1821 def print_command_list(self): |
|
1822 print "Available commands (use '%s help COMMAND' for more details):\n" \ |
|
1823 % self.progname |
|
1824 cmds = self.ctable.keys() |
|
1825 cmds.sort() |
|
1826 indent = max(map(len, cmds)) |
|
1827 for c in cmds: |
|
1828 h = self.ctable[c].short_help() |
|
1829 print " %-*s " % (indent, c), |
|
1830 self._print_wrapped(h, indent+6) |
|
1831 def print_command_help(self, cmd): |
|
1832 cmd = self.ctable[str(cmd)] |
|
1833 print 'usage: %s %s\n' % (self.progname, cmd.usage) |
|
1834 self._print_wrapped(cmd.help) |
|
1835 def print_opts(opts, self=self): |
|
1836 if not opts: return |
|
1837 flags = [o.repr_flags() for o in opts] |
|
1838 indent = max(map(len, flags)) |
|
1839 for f,o in zip(flags, opts): |
|
1840 print " %-*s :" % (indent, f), |
|
1841 self._print_wrapped(o.help, indent+5) |
|
1842 print '\nCommand options:' |
|
1843 print_opts(cmd.opts) |
|
1844 print '\nGlobal options:' |
|
1845 print_opts(self.gopts) |
|
1846 |
|
1847 def print_version(self): |
|
1848 print self.version |
|
1849 |
|
1850 ############################################################################### |
|
1851 # Options and Commands description |
|
1852 ############################################################################### |
|
1853 |
|
1854 global_opts = [ |
|
1855 Option("-F", "--force", |
|
1856 help="force operation even if the working copy is not clean, or " |
|
1857 "there are pending updates"), |
|
1858 Option("-n", "--dry-run", |
|
1859 help="don't actually change anything, just pretend; " |
|
1860 "implies --show-changes"), |
|
1861 Option("-s", "--show-changes", |
|
1862 help="show subversion commands that make changes"), |
|
1863 Option("-v", "--verbose", |
|
1864 help="verbose mode: output more information about progress"), |
|
1865 OptionArg("-u", "--username", |
|
1866 default=None, |
|
1867 help="invoke subversion commands with the supplied username"), |
|
1868 OptionArg("-p", "--password", |
|
1869 default=None, |
|
1870 help="invoke subversion commands with the supplied password"), |
|
1871 ] |
|
1872 |
|
1873 common_opts = [ |
|
1874 Option("-b", "--bidirectional", |
|
1875 value=True, |
|
1876 default=False, |
|
1877 help="remove reflected and initialized revisions from merge candidates. " |
|
1878 "Not required but may be specified to speed things up slightly"), |
|
1879 OptionArg("-f", "--commit-file", metavar="FILE", |
|
1880 default="svnmerge-commit-message.txt", |
|
1881 help="set the name of the file where the suggested log message " |
|
1882 "is written to"), |
|
1883 Option("-M", "--record-only", |
|
1884 value=True, |
|
1885 default=False, |
|
1886 help="do not perform an actual merge of the changes, yet record " |
|
1887 "that a merge happened"), |
|
1888 OptionArg("-r", "--revision", |
|
1889 metavar="REVLIST", |
|
1890 default="", |
|
1891 help="specify a revision list, consisting of revision numbers " |
|
1892 'and ranges separated by commas, e.g., "534,537-539,540"'), |
|
1893 OptionArg("-S", "--source", "--head", |
|
1894 default=None, |
|
1895 help="specify a merge source for this branch. It can be either " |
|
1896 "a path, a full URL, or an unambiguous substring of one " |
|
1897 "of the paths for which merge tracking was already " |
|
1898 "initialized. Needed only to disambiguate in case of " |
|
1899 "multiple merge sources"), |
|
1900 ] |
|
1901 |
|
1902 command_table = { |
|
1903 "init": (action_init, |
|
1904 "init [OPTION...] [SOURCE]", |
|
1905 """Initialize merge tracking from SOURCE on the current working |
|
1906 directory. |
|
1907 |
|
1908 If SOURCE is specified, all the revisions in SOURCE are marked as already |
|
1909 merged; if this is not correct, you can use --revision to specify the |
|
1910 exact list of already-merged revisions. |
|
1911 |
|
1912 If SOURCE is omitted, then it is computed from the "svn cp" history of the |
|
1913 current working directory (searching back for the branch point); in this |
|
1914 case, %s assumes that no revision has been integrated yet since |
|
1915 the branch point (unless you teach it with --revision).""" % NAME, |
|
1916 [ |
|
1917 "-f", "-r", # import common opts |
|
1918 ]), |
|
1919 |
|
1920 "avail": (action_avail, |
|
1921 "avail [OPTION...] [PATH]", |
|
1922 """Show unmerged revisions available for PATH as a revision list. |
|
1923 If --revision is given, the revisions shown will be limited to those |
|
1924 also specified in the option. |
|
1925 |
|
1926 When svnmerge is used to bidirectionally merge changes between a |
|
1927 branch and its source, it is necessary to not merge the same changes |
|
1928 forth and back: e.g., if you committed a merge of a certain |
|
1929 revision of the branch into the source, you do not want that commit |
|
1930 to appear as available to merged into the branch (as the code |
|
1931 originated in the branch itself!). svnmerge will automatically |
|
1932 exclude these so-called "reflected" revisions.""", |
|
1933 [ |
|
1934 Option("-A", "--all", |
|
1935 dest="avail-showwhat", |
|
1936 value=["blocked", "avail"], |
|
1937 default=["avail"], |
|
1938 help="show both available and blocked revisions (aka ignore " |
|
1939 "blocked revisions)"), |
|
1940 "-b", |
|
1941 Option("-B", "--blocked", |
|
1942 dest="avail-showwhat", |
|
1943 value=["blocked"], |
|
1944 help="show the blocked revision list (see '%s block')" % NAME), |
|
1945 Option("-d", "--diff", |
|
1946 dest="avail-display", |
|
1947 value="diffs", |
|
1948 default="revisions", |
|
1949 help="show corresponding diff instead of revision list"), |
|
1950 Option("--summarize", |
|
1951 dest="avail-display", |
|
1952 value="summarize", |
|
1953 help="show summarized diff instead of revision list"), |
|
1954 Option("-l", "--log", |
|
1955 dest="avail-display", |
|
1956 value="logs", |
|
1957 help="show corresponding log history instead of revision list"), |
|
1958 "-r", |
|
1959 "-S", |
|
1960 ]), |
|
1961 |
|
1962 "integrated": (action_integrated, |
|
1963 "integrated [OPTION...] [PATH]", |
|
1964 """Show merged revisions available for PATH as a revision list. |
|
1965 If --revision is given, the revisions shown will be limited to |
|
1966 those also specified in the option.""", |
|
1967 [ |
|
1968 Option("-d", "--diff", |
|
1969 dest="integrated-display", |
|
1970 value="diffs", |
|
1971 default="revisions", |
|
1972 help="show corresponding diff instead of revision list"), |
|
1973 Option("-l", "--log", |
|
1974 dest="integrated-display", |
|
1975 value="logs", |
|
1976 help="show corresponding log history instead of revision list"), |
|
1977 "-r", |
|
1978 "-S", |
|
1979 ]), |
|
1980 |
|
1981 "rollback": (action_rollback, |
|
1982 "rollback [OPTION...] [PATH]", |
|
1983 """Rollback previously merged in revisions from PATH. The |
|
1984 --revision option is mandatory, and specifies which revisions |
|
1985 will be rolled back. Only the previously integrated merges |
|
1986 will be rolled back. |
|
1987 |
|
1988 When manually rolling back changes, --record-only can be used to |
|
1989 instruct %s that a manual rollback of a certain revision |
|
1990 already happened, so that it can record it and offer that |
|
1991 revision for merge henceforth.""" % (NAME), |
|
1992 [ |
|
1993 "-f", "-r", "-S", "-M", # import common opts |
|
1994 ]), |
|
1995 |
|
1996 "merge": (action_merge, |
|
1997 "merge [OPTION...] [PATH]", |
|
1998 """Merge in revisions into PATH from its source. If --revision is omitted, |
|
1999 all the available revisions will be merged. In any case, already merged-in |
|
2000 revisions will NOT be merged again. |
|
2001 |
|
2002 When svnmerge is used to bidirectionally merge changes between a |
|
2003 branch and its source, it is necessary to not merge the same changes |
|
2004 forth and back: e.g., if you committed a merge of a certain |
|
2005 revision of the branch into the source, you do not want that commit |
|
2006 to appear as available to merged into the branch (as the code |
|
2007 originated in the branch itself!). svnmerge will automatically |
|
2008 exclude these so-called "reflected" revisions. |
|
2009 |
|
2010 When manually merging changes across branches, --record-only can |
|
2011 be used to instruct %s that a manual merge of a certain revision |
|
2012 already happened, so that it can record it and not offer that |
|
2013 revision for merge anymore. Conversely, when there are revisions |
|
2014 which should not be merged, use '%s block'.""" % (NAME, NAME), |
|
2015 [ |
|
2016 "-b", "-f", "-r", "-S", "-M", # import common opts |
|
2017 ]), |
|
2018 |
|
2019 "block": (action_block, |
|
2020 "block [OPTION...] [PATH]", |
|
2021 """Block revisions within PATH so that they disappear from the available |
|
2022 list. This is useful to hide revisions which will not be integrated. |
|
2023 If --revision is omitted, it defaults to all the available revisions. |
|
2024 |
|
2025 Do not use this option to hide revisions that were manually merged |
|
2026 into the branch. Instead, use '%s merge --record-only', which |
|
2027 records that a merge happened (as opposed to a merge which should |
|
2028 not happen).""" % NAME, |
|
2029 [ |
|
2030 "-f", "-r", "-S", # import common opts |
|
2031 ]), |
|
2032 |
|
2033 "unblock": (action_unblock, |
|
2034 "unblock [OPTION...] [PATH]", |
|
2035 """Revert the effect of '%s block'. If --revision is omitted, all the |
|
2036 blocked revisions are unblocked""" % NAME, |
|
2037 [ |
|
2038 "-f", "-r", "-S", # import common opts |
|
2039 ]), |
|
2040 |
|
2041 "uninit": (action_uninit, |
|
2042 "uninit [OPTION...] [PATH]", |
|
2043 """Remove merge tracking information from PATH. It cleans any kind of merge |
|
2044 tracking information (including the list of blocked revisions). If there |
|
2045 are multiple sources, use --source to indicate which source you want to |
|
2046 forget about.""", |
|
2047 [ |
|
2048 "-f", "-S", # import common opts |
|
2049 ]), |
|
2050 } |
|
2051 |
|
2052 |
|
2053 def main(args): |
|
2054 global opts |
|
2055 |
|
2056 # Initialize default options |
|
2057 opts = default_opts.copy() |
|
2058 logs.clear() |
|
2059 |
|
2060 optsparser = CommandOpts(global_opts, common_opts, command_table, |
|
2061 version="%%prog r%s\n modified: %s\n\n" |
|
2062 "Copyright (C) 2004,2005 Awarix Inc.\n" |
|
2063 "Copyright (C) 2005, Giovanni Bajo" |
|
2064 % (__revision__, __date__)) |
|
2065 |
|
2066 cmd, args, state = optsparser.parse(args) |
|
2067 opts.update(state) |
|
2068 |
|
2069 source = opts.get("source", None) |
|
2070 branch_dir = "." |
|
2071 |
|
2072 if str(cmd) == "init": |
|
2073 if len(args) == 1: |
|
2074 source = args[0] |
|
2075 elif len(args) > 1: |
|
2076 optsparser.error("wrong number of parameters", cmd) |
|
2077 elif str(cmd) in command_table.keys(): |
|
2078 if len(args) == 1: |
|
2079 branch_dir = args[0] |
|
2080 elif len(args) > 1: |
|
2081 optsparser.error("wrong number of parameters", cmd) |
|
2082 else: |
|
2083 assert False, "command not handled: %s" % cmd |
|
2084 |
|
2085 # Validate branch_dir |
|
2086 if not is_wc(branch_dir): |
|
2087 error('"%s" is not a subversion working directory' % branch_dir) |
|
2088 |
|
2089 # Extract the integration info for the branch_dir |
|
2090 branch_props = get_merge_props(branch_dir) |
|
2091 check_old_prop_version(branch_dir, branch_props) |
|
2092 |
|
2093 # Calculate source_url and source_path |
|
2094 report("calculate source path for the branch") |
|
2095 if not source: |
|
2096 if str(cmd) == "init": |
|
2097 cf_source, cf_rev, copy_committed_in_rev = get_copyfrom(branch_dir) |
|
2098 if not cf_source: |
|
2099 error('no copyfrom info available. ' |
|
2100 'Explicit source argument (-S/--source) required.') |
|
2101 opts["source-pathid"] = cf_source |
|
2102 if not opts["revision"]: |
|
2103 opts["revision"] = "1-" + cf_rev |
|
2104 else: |
|
2105 opts["source-pathid"] = get_default_source(branch_dir, branch_props) |
|
2106 |
|
2107 # (assumes pathid is a repository-relative-path) |
|
2108 assert opts["source-pathid"][0] == '/' |
|
2109 opts["source-url"] = get_repo_root(branch_dir) + opts["source-pathid"] |
|
2110 else: |
|
2111 # The source was given as a command line argument and is stored in |
|
2112 # SOURCE. Ensure that the specified source does not end in a /, |
|
2113 # otherwise it's easy to have the same source path listed more |
|
2114 # than once in the integrated version properties, with and without |
|
2115 # trailing /'s. |
|
2116 source = rstrip(source, "/") |
|
2117 if not is_wc(source) and not is_url(source): |
|
2118 # Check if it is a substring of a pathid recorded |
|
2119 # within the branch properties. |
|
2120 found = [] |
|
2121 for pathid in branch_props.keys(): |
|
2122 if pathid.find(source) > 0: |
|
2123 found.append(pathid) |
|
2124 if len(found) == 1: |
|
2125 # (assumes pathid is a repository-relative-path) |
|
2126 source = get_repo_root(branch_dir) + found[0] |
|
2127 else: |
|
2128 error('"%s" is neither a valid URL, nor an unambiguous ' |
|
2129 'substring of a repository path, nor a working directory' |
|
2130 % source) |
|
2131 |
|
2132 source_pathid = target_to_pathid(source) |
|
2133 if str(cmd) == "init" and \ |
|
2134 source_pathid == target_to_pathid("."): |
|
2135 error("cannot init integration source path '%s'\n" |
|
2136 "Its repository-relative path must differ from the " |
|
2137 "repository-relative path of the current directory." |
|
2138 % source_pathid) |
|
2139 opts["source-pathid"] = source_pathid |
|
2140 opts["source-url"] = target_to_url(source) |
|
2141 |
|
2142 # Sanity check source_url |
|
2143 assert is_url(opts["source-url"]) |
|
2144 # SVN does not support non-normalized URL (and we should not |
|
2145 # have created them) |
|
2146 assert opts["source-url"].find("/..") < 0 |
|
2147 |
|
2148 report('source is "%s"' % opts["source-url"]) |
|
2149 |
|
2150 # Get previously merged revisions (except when command is init) |
|
2151 if str(cmd) != "init": |
|
2152 opts["merged-revs"] = merge_props_to_revision_set(branch_props, |
|
2153 opts["source-pathid"]) |
|
2154 |
|
2155 # Perform the action |
|
2156 cmd(branch_dir, branch_props) |
|
2157 |
|
2158 |
|
2159 if __name__ == "__main__": |
|
2160 try: |
|
2161 main(sys.argv[1:]) |
|
2162 except LaunchError, (ret, cmd, out): |
|
2163 err_msg = "command execution failed (exit code: %d)\n" % ret |
|
2164 err_msg += cmd + "\n" |
|
2165 err_msg += "".join(out) |
|
2166 error(err_msg) |
|
2167 except KeyboardInterrupt: |
|
2168 # Avoid traceback on CTRL+C |
|
2169 print "aborted by user" |
|
2170 sys.exit(1) |