1 # Copyright (C) 2005-2008 Jelmer Vernooij <jelmer@samba.org>
3 # This program is free software; you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation; either version 3 of the License, or
6 # (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 """Maps between Subversion and Bazaar semantics."""
18 from bzrlib import osutils, registry
19 from bzrlib.errors import InvalidRevisionId
20 from bzrlib.trace import mutter
22 from bzrlib.plugins.svn import version_info, errors
30 SVN_PROP_BZR_PREFIX = 'bzr:'
31 SVN_PROP_BZR_ANCESTRY = 'bzr:ancestry:v%d-' % MAPPING_VERSION
32 SVN_PROP_BZR_FILEIDS = 'bzr:file-ids'
33 SVN_PROP_BZR_MERGE = 'bzr:merge'
34 SVN_PROP_BZR_REVISION_INFO = 'bzr:revision-info'
35 SVN_PROP_BZR_REVISION_ID = 'bzr:revision-id:v%d-' % MAPPING_VERSION
37 SVN_REVPROP_BZR_COMMITTER = 'bzr:committer'
38 SVN_REVPROP_BZR_FILEIDS = 'bzr:file-ids'
39 SVN_REVPROP_BZR_MAPPING_VERSION = 'bzr:mapping-version'
40 SVN_REVPROP_BZR_MERGE = 'bzr:merge'
41 SVN_REVPROP_BZR_REVISION_ID = 'bzr:revision-id'
42 SVN_REVPROP_BZR_REVNO = 'bzr:revno'
43 SVN_REVPROP_BZR_REVPROP_PREFIX = 'bzr:revprop:'
44 SVN_REVPROP_BZR_ROOT = 'bzr:root'
45 SVN_REVPROP_BZR_SIGNATURE = 'bzr:gpg-signature'
46 SVN_REVPROP_BZR_TIMESTAMP = 'bzr:timestamp'
47 SVN_REVPROP_BZR_LOG = 'bzr:log'
50 def escape_svn_path(x):
51 """Escape a Subversion path for use in a revision identifier.
56 assert isinstance(x, str)
57 return urllib.quote(x, "")
58 unescape_svn_path = urllib.unquote
61 # The following two functions don't use day names (which can vary by
62 # locale) unlike the alternatives in bzrlib.timestamp
64 def format_highres_date(t, offset=0):
65 """Format a date, such that it includes higher precision in the
68 :param t: The local time in fractional seconds since the epoch
70 :param offset: The timezone offset in integer seconds
73 assert isinstance(t, float)
75 # This has to be formatted for "original" date, so that the
76 # revision XML entry will be reproduced faithfully.
79 tt = time.gmtime(t + offset)
81 return (time.strftime("%Y-%m-%d %H:%M:%S", tt)
82 # Get the high-res seconds, but ignore the 0
83 + ('%.9f' % (t - int(t)))[1:]
84 + ' %+03d%02d' % (offset / 3600, (offset / 60) % 60))
87 def unpack_highres_date(date):
88 """This takes the high-resolution date stamp, and
89 converts it back into the tuple (timestamp, timezone)
90 Where timestamp is in real UTC since epoch seconds, and timezone is an
91 integer number of seconds offset.
93 :param date: A date formated by format_highres_date
96 # skip day if applicable
97 if not date[0].isdigit():
98 space_loc = date.find(' ')
100 raise ValueError("No valid date: %r" % date)
101 date = date[space_loc+1:]
102 # Up until the first period is a datestamp that is generated
103 # as normal from time.strftime, so use time.strptime to
105 dot_loc = date.find('.')
108 'Date string does not contain high-precision seconds: %r' % date)
109 base_time = time.strptime(date[:dot_loc], "%Y-%m-%d %H:%M:%S")
110 fract_seconds, offset = date[dot_loc:].split()
111 fract_seconds = float(fract_seconds)
115 hours = int(offset / 100)
116 minutes = (offset % 100)
117 seconds_offset = (hours * 3600) + (minutes * 60)
119 # time.mktime returns localtime, but calendar.timegm returns UTC time
120 timestamp = calendar.timegm(base_time)
121 timestamp -= seconds_offset
122 # Add back in the fractional seconds
123 timestamp += fract_seconds
124 return (timestamp, seconds_offset)
127 def parse_merge_property(line):
128 """Parse a bzr:merge property value.
130 :param line: Line to parse
131 :return: List of revisions merged
134 mutter('invalid revision id %r in merged property, skipping' % line)
137 return tuple(filter(lambda x: x != "", line.split("\t")))
139 def parse_svn_revprops(svn_revprops, rev):
140 if svn_revprops.has_key(svn.core.SVN_PROP_REVISION_AUTHOR):
141 rev.committer = svn_revprops[svn.core.SVN_PROP_REVISION_AUTHOR]
145 rev.message = svn_revprops.get(svn.core.SVN_PROP_REVISION_LOG)
149 rev.message = rev.message.decode("utf-8")
150 except UnicodeDecodeError:
153 if svn_revprops.has_key(svn.core.SVN_PROP_REVISION_DATE):
154 rev.timestamp = 1.0 * svn.core.secs_from_timestr(svn_revprops[svn.core.SVN_PROP_REVISION_DATE], None)
156 rev.timestamp = 0.0 # FIXME: Obtain repository creation time
161 def parse_revision_metadata(text, rev):
162 """Parse a revision info text (as set in bzr:revision-info).
164 :param text: text to parse
165 :param rev: Revision object to apply read parameters to
167 in_properties = False
168 for l in text.splitlines():
170 key, value = l.split(": ", 2)
172 raise errors.InvalidPropertyValue(SVN_PROP_BZR_REVISION_INFO,
173 "Missing : in revision metadata")
174 if key == "committer":
175 rev.committer = value.decode("utf-8")
176 elif key == "timestamp":
177 (rev.timestamp, rev.timezone) = unpack_highres_date(value)
178 elif key == "properties":
180 elif key[0] == "\t" and in_properties:
181 rev.properties[str(key[1:])] = value.decode("utf-8")
183 raise errors.InvalidPropertyValue(SVN_PROP_BZR_REVISION_INFO,
184 "Invalid key %r" % key)
187 def parse_revid_property(line):
188 """Parse a (revnum, revid) tuple as set in revision id properties.
189 :param line: line to parse
190 :return: tuple with (bzr_revno, revid)
193 raise errors.InvalidPropertyValue(SVN_PROP_BZR_REVISION_ID,
194 "newline in revision id property line")
196 (revno, revid) = line.split(' ', 1)
198 raise errors.InvalidPropertyValue(SVN_PROP_BZR_REVISION_ID,
201 raise errors.InvalidPropertyValue(SVN_PROP_BZR_REVISION_ID,
203 return (int(revno), revid)
206 def generate_revision_metadata(timestamp, timezone, committer, revprops):
207 """Generate revision metadata text for the specified revision
210 :param timestamp: timestamp of the revision, in seconds since epoch
211 :param timezone: timezone, specified by offset from GMT in seconds
212 :param committer: name/email of the committer
213 :param revprops: dictionary with custom revision properties
214 :return: text with data to set bzr:revision-info to.
216 assert timestamp is None or isinstance(timestamp, float)
218 if timestamp is not None:
219 text += "timestamp: %s\n" % format_highres_date(timestamp, timezone)
220 if committer is not None:
221 text += "committer: %s\n" % committer
222 if revprops is not None and revprops != {}:
223 text += "properties: \n"
224 for k, v in sorted(revprops.items()):
225 text += "\t%s: %s\n" % (k, v)
229 def parse_bzr_svn_revprops(props, rev):
230 """Update a Revision object from a set of Subversion revision properties.
232 :param props: Dictionary with Subversion revision properties.
233 :param rev: Revision object
235 if props.has_key(SVN_REVPROP_BZR_TIMESTAMP):
236 (rev.timestamp, rev.timezone) = unpack_highres_date(props[SVN_REVPROP_BZR_TIMESTAMP])
238 if props.has_key(SVN_REVPROP_BZR_COMMITTER):
239 rev.committer = props[SVN_REVPROP_BZR_COMMITTER].decode("utf-8")
241 if props.has_key(SVN_REVPROP_BZR_LOG):
242 rev.message = props[SVN_REVPROP_BZR_LOG]
244 for name, value in props.items():
245 if name.startswith(SVN_REVPROP_BZR_REVPROP_PREFIX):
246 rev.properties[name[len(SVN_REVPROP_BZR_REVPROP_PREFIX):]] = value
249 class BzrSvnMapping(object):
250 """Class that maps between Subversion and Bazaar semantics."""
252 _warned_experimental = False
255 if (version_info[3] == 'exp' or self.experimental) and not BzrSvnMapping._warned_experimental:
256 from bzrlib.trace import warning
257 warning("using experimental bzr-svn mappings; output may change between revisions")
258 BzrSvnMapping._warned_experimental = True
261 def from_repository(cls, repository, _hinted_branch_path=None):
265 def supports_roundtripping(cls):
266 """Whether this mapping supports roundtripping.
271 def supports_custom_revprops(cls):
272 """Whether this mapping can be used with custom revision properties."""
275 def is_bzr_revision(self, revprops, fileprops):
276 """Whether this is a revision that was pushed by Bazaar."""
280 def supports_custom_fileprops(cls):
281 """Whether this mapping can be used with custom file properties."""
284 def get_mandated_layout(self, repository):
285 """Return the repository layout if any is mandated by this mapping,
289 def parse_revision_id(self, revid):
290 """Parse an existing Subversion-based revision id.
292 :param revid: The revision id.
293 :raises: InvalidRevisionId
294 :return: Tuple with uuid, branch path, revision number and scheme.
296 raise NotImplementedError(self.parse_revision_id)
298 def generate_revision_id(self, uuid, revnum, path):
299 """Generate a unambiguous revision id.
301 :param uuid: UUID of the repository.
302 :param revnum: Subversion revision number.
303 :param path: Branch path.
305 :return: New revision id.
307 raise NotImplementedError(self.generate_revision_id)
309 def is_branch(self, branch_path):
310 raise NotImplementedError(self.is_branch)
312 def is_tag(self, tag_path):
313 raise NotImplementedError(self.is_tag)
316 def generate_file_id(uuid, revnum, branch, inv_path):
317 """Create a file id identifying a Subversion file.
319 :param uuid: UUID of the repository
320 :param revnum: Revision number at which the file was introduced.
321 :param branch: Branch path of the branch in which the file was introduced.
322 :param inv_path: Original path of the file within the inventory
324 raise NotImplementedError(self.generate_file_id)
326 def import_revision(self, revprops, fileprops, rev):
327 """Update a Revision object from Subversion revision and branch
330 :param revprops: Dictionary with Subversion revision properties.
331 :param fileprops: Dictionary with Subversion file properties on the
333 :param rev: Revision object to import data into.
335 raise NotImplementedError(self.import_revision)
337 def get_rhs_parents(self, branch_path, revprops, fileprops):
338 """Obtain the right-hand side parents for a revision.
341 raise NotImplementedError(self.get_rhs_parents)
343 def get_rhs_ancestors(self, branch_path, revprops, fileprops):
344 """Obtain the right-hand side ancestors for a revision.
347 raise NotImplementedError(self.get_rhs_ancestors)
349 def import_fileid_map(self, revprops, fileprops):
350 """Obtain the file id map for a revision from the properties.
353 raise NotImplementedError(self.import_fileid_map)
355 def export_fileid_map(self, fileids, revprops, fileprops):
356 """Adjust the properties for a file id map.
358 :param fileids: Dictionary
359 :param revprops: Subversion revision properties
360 :param fileprops: File properties
362 raise NotImplementedError(self.export_fileid_map)
364 def export_revision(self, branch_root, timestamp, timezone, committer, revprops,
365 revision_id, revno, merges, fileprops):
366 """Determines the revision properties and branch root file
369 raise NotImplementedError(self.export_revision)
371 def export_message(self, log, revprops, fileprops):
372 raise NotImplementedError(self.export_message)
374 def get_revision_id(self, branch_path, revprops, fileprops):
375 raise NotImplementedError(self.get_revision_id)
377 def unprefix(self, branch_path, repos_path):
378 raise NotImplementedError(self.unprefix)
381 class BzrSvnMappingv1(BzrSvnMapping):
382 """This was the initial version of the mappings as used by bzr-svn
385 It does not support pushing revisions to Subversion as-is, but only
389 def parse_revision_id(cls, revid):
390 if not revid.startswith("svn-v1:"):
391 raise InvalidRevisionId(revid, "")
392 revid = revid[len("svn-v1:"):]
393 at = revid.index("@")
394 fash = revid.rindex("-")
395 uuid = revid[at+1:fash]
396 branch_path = unescape_svn_path(revid[fash+1:])
397 revnum = int(revid[0:at])
399 return (uuid, branch_path, revnum, cls())
401 def generate_revision_id(self, uuid, revnum, path):
402 return "svn-v1:%d@%s-%s" % (revnum, uuid, escape_svn_path(path))
404 def __eq__(self, other):
405 return type(self) == type(other)
408 class BzrSvnMappingv2(BzrSvnMapping):
409 """The second version of the mappings as used in the 0.3.x series.
413 def parse_revision_id(cls, revid):
414 if not revid.startswith("svn-v2:"):
415 raise InvalidRevisionId(revid, "")
416 revid = revid[len("svn-v2:"):]
417 at = revid.index("@")
418 fash = revid.rindex("-")
419 uuid = revid[at+1:fash]
420 branch_path = unescape_svn_path(revid[fash+1:])
421 revnum = int(revid[0:at])
423 return (uuid, branch_path, revnum, cls())
425 def generate_revision_id(self, uuid, revnum, path):
426 return "svn-v2:%d@%s-%s" % (revnum, uuid, escape_svn_path(path))
428 def __eq__(self, other):
429 return type(self) == type(other)
432 def parse_fileid_property(text):
434 for line in text.splitlines():
435 (path, key) = line.split("\t", 2)
436 ret[urllib.unquote(path)] = osutils.safe_file_id(key)
440 def generate_fileid_property(fileids):
441 """Marshall a dictionary with file ids."""
442 return "".join(["%s\t%s\n" % (urllib.quote(path.encode("utf-8")), fileids[path]) for path in sorted(fileids.keys())])
445 class BzrSvnMappingFileProps(object):
447 def supports_custom_fileprops(cls):
448 """Whether this mapping can be used with custom file properties."""
451 def import_revision(self, svn_revprops, fileprops, rev):
452 parse_svn_revprops(svn_revprops, rev)
453 metadata = fileprops.get(SVN_PROP_BZR_REVISION_INFO)
454 if metadata is not None:
455 parse_revision_metadata(metadata, rev)
457 def get_rhs_parents(self, branch_path, revprops, fileprops):
458 bzr_merges = fileprops.get(SVN_PROP_BZR_ANCESTRY+str(self.scheme), None)
459 if bzr_merges is not None:
460 return parse_merge_property(bzr_merges.splitlines()[-1])
464 def get_rhs_ancestors(self, branch_path, revprops, fileprops):
466 for l in fileprops.get(SVN_PROP_BZR_ANCESTRY+str(self.scheme), "").splitlines():
467 ancestry.extend(l.split("\n"))
470 def import_fileid_map(self, svn_revprops, fileprops):
471 fileids = fileprops.get(SVN_PROP_BZR_FILEIDS, None)
474 return parse_fileid_property(fileids)
476 def _record_merges(self, merges, fileprops):
477 """Store the extra merges (non-LHS parents) in a file property.
479 :param merges: List of parents.
482 old = fileprops.get(SVN_PROP_BZR_ANCESTRY+str(self.scheme), "")
483 svnprops = { SVN_PROP_BZR_ANCESTRY+str(self.scheme): old + "\t".join(merges) + "\n" }
487 def export_revision(self, branch_root, timestamp, timezone, committer, revprops, revision_id, revno, merges, old_fileprops):
489 # Keep track of what Subversion properties to set later on
491 fileprops[SVN_PROP_BZR_REVISION_INFO] = generate_revision_metadata(
492 timestamp, timezone, committer, revprops)
495 fileprops.update(self._record_merges(merges, old_fileprops))
497 # Set appropriate property if revision id was specified by
499 if revision_id is not None:
500 old = old_fileprops.get(SVN_PROP_BZR_REVISION_ID+str(self.scheme), "")
501 fileprops[SVN_PROP_BZR_REVISION_ID+str(self.scheme)] = old + "%d %s\n" % (revno, revision_id)
503 return ({}, fileprops)
505 def is_bzr_revision(self, revprops, fileprops):
506 return fileprops.has_key(SVN_PROP_BZR_REVISION_ID+str(self.scheme))
508 def get_revision_id(self, branch_path, revprops, fileprops):
509 # Lookup the revision from the bzr:revision-id-vX property
510 text = fileprops.get(SVN_PROP_BZR_REVISION_ID+str(self.scheme), None)
514 lines = text.splitlines()
519 return parse_revid_property(lines[-1])
520 except errors.InvalidPropertyValue, e:
524 def export_fileid_map(self, fileids, revprops, fileprops):
526 file_id_text = generate_fileid_property(fileids)
527 fileprops[SVN_PROP_BZR_FILEIDS] = file_id_text
529 fileprops[SVN_PROP_BZR_FILEIDS] = ""
531 class BzrSvnMappingRevProps(object):
533 def supports_custom_revprops(cls):
534 """Whether this mapping can be used with custom revision properties."""
537 def import_revision(self, svn_revprops, fileprops, rev):
538 parse_svn_revprops(svn_revprops, rev)
539 parse_bzr_svn_revprops(svn_revprops, rev)
541 def import_fileid_map(self, svn_revprops, fileprops):
542 if not svn_revprops.has_key(SVN_REVPROP_BZR_FILEIDS):
544 return parse_fileid_property(svn_revprops[SVN_REVPROP_BZR_FILEIDS])
546 def get_rhs_parents(self, branch_path, svn_revprops,
548 if svn_revprops[SVN_REVPROP_BZR_ROOT] != branch:
550 return svn_revprops.get(SVN_REVPROP_BZR_MERGE, "").splitlines()
552 def is_bzr_revision(self, revprops, fileprops):
553 return revprops.has_key(SVN_REVPROP_BZR_MAPPING_VERSION)
555 def get_revision_id(self, branch_path, revprops, fileprops):
556 if not self.is_bzr_revision(revprops, fileprops):
558 if revprops[SVN_REVPROP_BZR_ROOT] == branch_path:
559 revid = revprops[SVN_REVPROP_BZR_REVISION_ID]
560 revno = int(revprops[SVN_REVPROP_BZR_REVNO])
561 return (revno, revid)
564 def export_message(self, message, revprops, fileprops):
565 revprops[SVN_REVPROP_BZR_LOG] = message.encode("utf-8")
567 def export_revision(self, branch_root, timestamp, timezone, committer,
568 revprops, revision_id, revno, merges,
570 svn_revprops = {SVN_REVPROP_BZR_MAPPING_VERSION: str(MAPPING_VERSION)}
572 if timestamp is not None:
573 svn_revprops[SVN_REVPROP_BZR_TIMESTAMP] = format_highres_date(timestamp, timezone)
575 if committer is not None:
576 svn_revprops[SVN_REVPROP_BZR_COMMITTER] = committer.encode("utf-8")
578 if revprops is not None:
579 for name, value in revprops.items():
580 svn_revprops[SVN_REVPROP_BZR_REVPROP_PREFIX+name] = value
582 svn_revprops[SVN_REVPROP_BZR_ROOT] = branch_root
584 if revision_id is not None:
585 svn_revprops[SVN_REVPROP_BZR_REVISION_ID] = revision_id
588 svn_revprops[SVN_REVPROP_BZR_MERGE] = "".join([x+"\n" for x in merges])
589 svn_revprops[SVN_REVPROP_BZR_REVNO] = str(revno)
591 return (svn_revprops, {})
593 def export_fileid_map(self, fileids, revprops, fileprops):
594 revprops[SVN_REVPROP_BZR_FILEIDS] = generate_fileid_property(fileids)
596 def get_rhs_ancestors(self, branch_path, revprops, fileprops):
597 raise NotImplementedError(self.get_rhs_ancestors)
600 class BzrSvnMappingv4(BzrSvnMappingRevProps):
601 revid_prefix = "svn-v4"
605 def supports_roundtripping():
609 def parse_revision_id(cls, revid):
610 assert isinstance(revid, str)
612 if not revid.startswith(cls.revid_prefix):
613 raise InvalidRevisionId(revid, "")
616 (version, uuid, branch_path, srevnum) = revid.split(":")
618 raise InvalidRevisionId(revid, "")
620 branch_path = unescape_svn_path(branch_path)
622 return (uuid, branch_path, int(srevnum), cls())
624 def generate_revision_id(self, uuid, revnum, path):
625 return "svn-v4:%s:%s:%d" % (uuid, path, revnum)
627 def generate_file_id(self, uuid, revnum, branch, inv_path):
628 return "%d@%s:%s/%s" % (revnum, uuid, branch, inv_path.encode("utf-8"))
630 def is_branch(self, branch_path):
633 def is_tag(self, tag_path):
636 def __eq__(self, other):
637 return type(self) == type(other)
640 class BzrSvnMappingRegistry(registry.Registry):
641 """Registry for the various Bzr<->Svn mappings."""
642 def register(self, key, factory, help):
643 """Register a mapping between Bazaar and Subversion semantics.
645 The factory must be a callable that takes one parameter: the key.
646 It must produce an instance of BzrSvnMapping when called.
648 registry.Registry.register(self, key, factory, help)
650 def set_default(self, key):
651 """Set the 'default' key to be a clone of the supplied key.
653 This method must be called once and only once.
655 self._set_default_key(key)
657 def get_default(self):
658 """Convenience function for obtaining the default mapping to use."""
659 return self.get(self._get_default_key())
661 mapping_registry = BzrSvnMappingRegistry()
662 mapping_registry.register('v1', BzrSvnMappingv1,
663 'Original bzr-svn mapping format')
664 mapping_registry.register('v2', BzrSvnMappingv2,
666 mapping_registry.register_lazy('v3-revprops', 'bzrlib.plugins.svn.mapping3',
667 'BzrSvnMappingv3RevProps',
668 'Third format with revision properties')
669 mapping_registry.register_lazy('v3-fileprops', 'bzrlib.plugins.svn.mapping3',
670 'BzrSvnMappingv3FileProps',
671 'Third format with file properties')
672 mapping_registry.register_lazy('v3-hybrid', 'bzrlib.plugins.svn.mapping3',
673 'BzrSvnMappingv3Hybrid', 'Hybrid third format')
674 mapping_registry.register_lazy('v3', 'bzrlib.plugins.svn.mapping3',
675 'BzrSvnMappingv3FileProps',
676 'Default third format')
677 mapping_registry.register('v4', BzrSvnMappingv4,
679 mapping_registry.set_default('v3-fileprops')
681 def parse_revision_id(revid):
682 """Try to parse a Subversion revision id.
684 :param revid: Revision id to parse
685 :return: tuple with (uuid, branch_path, mapping)
687 if not revid.startswith("svn-"):
688 raise InvalidRevisionId(revid, None)
689 mapping_version = revid[len("svn-"):len("svn-vx")]
690 mapping = mapping_registry.get(mapping_version)
691 return mapping.parse_revision_id(revid)
693 def get_default_mapping():
694 return mapping_registry.get_default()