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