1
# Copyright (C) 2007 Canonical Ltd
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 2 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, write to the Free Software
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
18
from StringIO import StringIO
22
branch as _mod_branch,
28
revision as _mod_revision,
33
from bzrlib.bundle import (
34
serializer as bundle_serializer,
36
from bzrlib.email_message import EmailMessage
39
class MergeRequestBodyParams(object):
41
def __init__(self, body, orig_body, directive, to, basename, subject,
44
self.orig_body = orig_body
45
self.directive = directive
49
self.basename = basename
50
self.subject = subject
53
class MergeDirectiveHooks(hooks.Hooks):
56
hooks.Hooks.__init__(self)
57
self.create_hook(hooks.HookPoint('merge_request_body',
58
"Called with a MergeRequestBodyParams when a body is needed for"
59
" a merge request. Callbacks must return a body. If more"
60
" than one callback is registered, the output of one callback is"
61
" provided to the next.", (1, 15, 0), False))
64
class _BaseMergeDirective(object):
66
hooks = MergeDirectiveHooks()
68
def __init__(self, revision_id, testament_sha1, time, timezone,
69
target_branch, patch=None, source_branch=None, message=None,
73
:param revision_id: The revision to merge
74
:param testament_sha1: The sha1 of the testament of the revision to
76
:param time: The current POSIX timestamp time
77
:param timezone: The timezone offset
78
:param target_branch: The branch to apply the merge to
79
:param patch: The text of a diff or bundle
80
:param source_branch: A public location to merge the revision from
81
:param message: The message to use when committing this merge
83
self.revision_id = revision_id
84
self.testament_sha1 = testament_sha1
86
self.timezone = timezone
87
self.target_branch = target_branch
89
self.source_branch = source_branch
90
self.message = message
92
def _to_lines(self, base_revision=False):
93
"""Serialize as a list of lines
95
:return: a list of lines
97
time_str = timestamp.format_patch_date(self.time, self.timezone)
98
stanza = rio.Stanza(revision_id=self.revision_id, timestamp=time_str,
99
target_branch=self.target_branch,
100
testament_sha1=self.testament_sha1)
101
for key in ('source_branch', 'message'):
102
if self.__dict__[key] is not None:
103
stanza.add(key, self.__dict__[key])
105
stanza.add('base_revision_id', self.base_revision_id)
106
lines = ['# ' + self._format_string + '\n']
107
lines.extend(rio.to_patch_lines(stanza))
112
def from_objects(klass, repository, revision_id, time, timezone,
113
target_branch, patch_type='bundle',
114
local_target_branch=None, public_branch=None, message=None):
115
"""Generate a merge directive from various objects
117
:param repository: The repository containing the revision
118
:param revision_id: The revision to merge
119
:param time: The POSIX timestamp of the date the request was issued.
120
:param timezone: The timezone of the request
121
:param target_branch: The url of the branch to merge into
122
:param patch_type: 'bundle', 'diff' or None, depending on the type of
124
:param local_target_branch: a local copy of the target branch
125
:param public_branch: location of a public branch containing the target
127
:param message: Message to use when committing the merge
128
:return: The merge directive
130
The public branch is always used if supplied. If the patch_type is
131
not 'bundle', the public branch must be supplied, and will be verified.
133
If the message is not supplied, the message from revision_id will be
136
t_revision_id = revision_id
137
if revision_id == _mod_revision.NULL_REVISION:
139
t = testament.StrictTestament3.from_revision(repository, t_revision_id)
140
submit_branch = _mod_branch.Branch.open(target_branch)
141
if submit_branch.get_public_branch() is not None:
142
target_branch = submit_branch.get_public_branch()
143
if patch_type is None:
146
submit_revision_id = submit_branch.last_revision()
147
submit_revision_id = _mod_revision.ensure_null(submit_revision_id)
148
repository.fetch(submit_branch.repository, submit_revision_id)
149
graph = repository.get_graph()
150
ancestor_id = graph.find_unique_lca(revision_id,
152
type_handler = {'bundle': klass._generate_bundle,
153
'diff': klass._generate_diff,
154
None: lambda x, y, z: None }
155
patch = type_handler[patch_type](repository, revision_id,
158
if public_branch is not None and patch_type != 'bundle':
159
public_branch_obj = _mod_branch.Branch.open(public_branch)
160
if not public_branch_obj.repository.has_revision(revision_id):
161
raise errors.PublicBranchOutOfDate(public_branch,
164
return klass(revision_id, t.as_sha1(), time, timezone, target_branch,
165
patch, patch_type, public_branch, message)
167
def get_disk_name(self, branch):
168
"""Generate a suitable basename for storing this directive on disk
170
:param branch: The Branch this merge directive was generated fro
173
revno, revision_id = branch.last_revision_info()
174
if self.revision_id == revision_id:
177
revno = branch.get_revision_id_to_revno_map().get(self.revision_id,
179
nick = re.sub('(\W+)', '-', branch.nick).strip('-')
180
return '%s-%s' % (nick, '.'.join(str(n) for n in revno))
183
def _generate_diff(repository, revision_id, ancestor_id):
184
tree_1 = repository.revision_tree(ancestor_id)
185
tree_2 = repository.revision_tree(revision_id)
187
diff.show_diff_trees(tree_1, tree_2, s, old_label='', new_label='')
191
def _generate_bundle(repository, revision_id, ancestor_id):
193
bundle_serializer.write_bundle(repository, revision_id,
197
def to_signed(self, branch):
198
"""Serialize as a signed string.
200
:param branch: The source branch, to get the signing strategy
203
my_gpg = gpg.GPGStrategy(branch.get_config())
204
return my_gpg.sign(''.join(self.to_lines()))
206
def to_email(self, mail_to, branch, sign=False):
207
"""Serialize as an email message.
209
:param mail_to: The address to mail the message to
210
:param branch: The source branch, to get the signing strategy and
212
:param sign: If True, gpg-sign the email
213
:return: an email message
215
mail_from = branch.get_config().username()
216
if self.message is not None:
217
subject = self.message
219
revision = branch.repository.get_revision(self.revision_id)
220
subject = revision.message
222
body = self.to_signed(branch)
224
body = ''.join(self.to_lines())
225
message = EmailMessage(mail_from, mail_to, subject, body)
228
def install_revisions(self, target_repo):
229
"""Install revisions and return the target revision"""
230
if not target_repo.has_revision(self.revision_id):
231
if self.patch_type == 'bundle':
232
info = bundle_serializer.read_bundle(
233
StringIO(self.get_raw_bundle()))
234
# We don't use the bundle's target revision, because
235
# MergeDirective.revision_id is authoritative.
237
info.install_revisions(target_repo, stream_input=False)
238
except errors.RevisionNotPresent:
239
# At least one dependency isn't present. Try installing
240
# missing revisions from the submit branch
243
_mod_branch.Branch.open(self.target_branch)
244
except errors.NotBranchError:
245
raise errors.TargetNotBranch(self.target_branch)
246
missing_revisions = []
247
bundle_revisions = set(r.revision_id for r in
249
for revision in info.real_revisions:
250
for parent_id in revision.parent_ids:
251
if (parent_id not in bundle_revisions and
252
not target_repo.has_revision(parent_id)):
253
missing_revisions.append(parent_id)
254
# reverse missing revisions to try to get heads first
256
unique_missing_set = set()
257
for revision in reversed(missing_revisions):
258
if revision in unique_missing_set:
260
unique_missing.append(revision)
261
unique_missing_set.add(revision)
262
for missing_revision in unique_missing:
263
target_repo.fetch(submit_branch.repository,
265
info.install_revisions(target_repo, stream_input=False)
267
source_branch = _mod_branch.Branch.open(self.source_branch)
268
target_repo.fetch(source_branch.repository, self.revision_id)
269
return self.revision_id
271
def compose_merge_request(self, mail_client, to, body, branch, tree=None):
272
basename = self.get_disk_name(branch)
274
if self.message is not None:
275
subject += self.message
277
revision = branch.repository.get_revision(self.revision_id)
278
subject += revision.get_summary()
280
for hook in self.hooks['merge_request_body']:
281
params = MergeRequestBodyParams(body, orig_body, self,
282
to, basename, subject, branch,
285
mail_client.compose_merge_request(to, subject,
286
''.join(self.to_lines()),
290
class MergeDirective(_BaseMergeDirective):
292
"""A request to perform a merge into a branch.
294
Designed to be serialized and mailed. It provides all the information
295
needed to perform a merge automatically, by providing at minimum a revision
296
bundle or the location of a branch.
298
The serialization format is robust against certain common forms of
299
deterioration caused by mailing.
301
The format is also designed to be patch-compatible. If the directive
302
includes a diff or revision bundle, it should be possible to apply it
303
directly using the standard patch program.
306
_format_string = 'Bazaar merge directive format 1'
308
def __init__(self, revision_id, testament_sha1, time, timezone,
309
target_branch, patch=None, patch_type=None,
310
source_branch=None, message=None, bundle=None):
313
:param revision_id: The revision to merge
314
:param testament_sha1: The sha1 of the testament of the revision to
316
:param time: The current POSIX timestamp time
317
:param timezone: The timezone offset
318
:param target_branch: The branch to apply the merge to
319
:param patch: The text of a diff or bundle
320
:param patch_type: None, "diff" or "bundle", depending on the contents
322
:param source_branch: A public location to merge the revision from
323
:param message: The message to use when committing this merge
325
_BaseMergeDirective.__init__(self, revision_id, testament_sha1, time,
326
timezone, target_branch, patch, source_branch, message)
327
if patch_type not in (None, 'diff', 'bundle'):
328
raise ValueError(patch_type)
329
if patch_type != 'bundle' and source_branch is None:
330
raise errors.NoMergeSource()
331
if patch_type is not None and patch is None:
332
raise errors.PatchMissing(patch_type)
333
self.patch_type = patch_type
335
def clear_payload(self):
337
self.patch_type = None
339
def get_raw_bundle(self):
343
if self.patch_type == 'bundle':
348
bundle = property(_bundle)
351
def from_lines(klass, lines):
352
"""Deserialize a MergeRequest from an iterable of lines
354
:param lines: An iterable of lines
355
:return: a MergeRequest
357
line_iter = iter(lines)
358
for line in line_iter:
359
if line.startswith('# Bazaar merge directive format '):
363
raise errors.NotAMergeDirective(lines[0])
365
raise errors.NotAMergeDirective('')
366
return _format_registry.get(line[2:].rstrip())._from_lines(line_iter)
369
def _from_lines(klass, line_iter):
370
stanza = rio.read_patch_stanza(line_iter)
371
patch_lines = list(line_iter)
372
if len(patch_lines) == 0:
376
patch = ''.join(patch_lines)
378
bundle_serializer.read_bundle(StringIO(patch))
379
except (errors.NotABundle, errors.BundleNotSupported,
383
patch_type = 'bundle'
384
time, timezone = timestamp.parse_patch_date(stanza.get('timestamp'))
386
for key in ('revision_id', 'testament_sha1', 'target_branch',
387
'source_branch', 'message'):
389
kwargs[key] = stanza.get(key)
392
kwargs['revision_id'] = kwargs['revision_id'].encode('utf-8')
393
return MergeDirective(time=time, timezone=timezone,
394
patch_type=patch_type, patch=patch, **kwargs)
397
lines = self._to_lines()
398
if self.patch is not None:
399
lines.extend(self.patch.splitlines(True))
403
def _generate_bundle(repository, revision_id, ancestor_id):
405
bundle_serializer.write_bundle(repository, revision_id,
406
ancestor_id, s, '0.9')
409
def get_merge_request(self, repository):
410
"""Provide data for performing a merge
412
Returns suggested base, suggested target, and patch verification status
414
return None, self.revision_id, 'inapplicable'
417
class MergeDirective2(_BaseMergeDirective):
419
_format_string = 'Bazaar merge directive format 2 (Bazaar 0.90)'
421
def __init__(self, revision_id, testament_sha1, time, timezone,
422
target_branch, patch=None, source_branch=None, message=None,
423
bundle=None, base_revision_id=None):
424
if source_branch is None and bundle is None:
425
raise errors.NoMergeSource()
426
_BaseMergeDirective.__init__(self, revision_id, testament_sha1, time,
427
timezone, target_branch, patch, source_branch, message)
429
self.base_revision_id = base_revision_id
431
def _patch_type(self):
432
if self.bundle is not None:
434
elif self.patch is not None:
439
patch_type = property(_patch_type)
441
def clear_payload(self):
445
def get_raw_bundle(self):
446
if self.bundle is None:
449
return self.bundle.decode('base-64')
452
def _from_lines(klass, line_iter):
453
stanza = rio.read_patch_stanza(line_iter)
457
start = line_iter.next()
458
except StopIteration:
461
if start.startswith('# Begin patch'):
463
for line in line_iter:
464
if line.startswith('# Begin bundle'):
467
patch_lines.append(line)
470
patch = ''.join(patch_lines)
471
if start is not None:
472
if start.startswith('# Begin bundle'):
473
bundle = ''.join(line_iter)
475
raise errors.IllegalMergeDirectivePayload(start)
476
time, timezone = timestamp.parse_patch_date(stanza.get('timestamp'))
478
for key in ('revision_id', 'testament_sha1', 'target_branch',
479
'source_branch', 'message', 'base_revision_id'):
481
kwargs[key] = stanza.get(key)
484
kwargs['revision_id'] = kwargs['revision_id'].encode('utf-8')
485
kwargs['base_revision_id'] =\
486
kwargs['base_revision_id'].encode('utf-8')
487
return klass(time=time, timezone=timezone, patch=patch, bundle=bundle,
491
lines = self._to_lines(base_revision=True)
492
if self.patch is not None:
493
lines.append('# Begin patch\n')
494
lines.extend(self.patch.splitlines(True))
495
if self.bundle is not None:
496
lines.append('# Begin bundle\n')
497
lines.extend(self.bundle.splitlines(True))
501
def from_objects(klass, repository, revision_id, time, timezone,
502
target_branch, include_patch=True, include_bundle=True,
503
local_target_branch=None, public_branch=None, message=None,
504
base_revision_id=None):
505
"""Generate a merge directive from various objects
507
:param repository: The repository containing the revision
508
:param revision_id: The revision to merge
509
:param time: The POSIX timestamp of the date the request was issued.
510
:param timezone: The timezone of the request
511
:param target_branch: The url of the branch to merge into
512
:param include_patch: If true, include a preview patch
513
:param include_bundle: If true, include a bundle
514
:param local_target_branch: a local copy of the target branch
515
:param public_branch: location of a public branch containing the target
517
:param message: Message to use when committing the merge
518
:return: The merge directive
520
The public branch is always used if supplied. If no bundle is
521
included, the public branch must be supplied, and will be verified.
523
If the message is not supplied, the message from revision_id will be
528
repository.lock_write()
529
locked.append(repository)
530
t_revision_id = revision_id
531
if revision_id == 'null:':
533
t = testament.StrictTestament3.from_revision(repository,
535
submit_branch = _mod_branch.Branch.open(target_branch)
536
submit_branch.lock_read()
537
locked.append(submit_branch)
538
if submit_branch.get_public_branch() is not None:
539
target_branch = submit_branch.get_public_branch()
540
submit_revision_id = submit_branch.last_revision()
541
submit_revision_id = _mod_revision.ensure_null(submit_revision_id)
542
graph = repository.get_graph(submit_branch.repository)
543
ancestor_id = graph.find_unique_lca(revision_id,
545
if base_revision_id is None:
546
base_revision_id = ancestor_id
547
if (include_patch, include_bundle) != (False, False):
548
repository.fetch(submit_branch.repository, submit_revision_id)
550
patch = klass._generate_diff(repository, revision_id,
556
bundle = klass._generate_bundle(repository, revision_id,
557
ancestor_id).encode('base-64')
561
if public_branch is not None and not include_bundle:
562
public_branch_obj = _mod_branch.Branch.open(public_branch)
563
public_branch_obj.lock_read()
564
locked.append(public_branch_obj)
565
if not public_branch_obj.repository.has_revision(
567
raise errors.PublicBranchOutOfDate(public_branch,
570
for entry in reversed(locked):
572
return klass(revision_id, t.as_sha1(), time, timezone, target_branch,
573
patch, public_branch, message, bundle, base_revision_id)
575
def _verify_patch(self, repository):
576
calculated_patch = self._generate_diff(repository, self.revision_id,
577
self.base_revision_id)
578
# Convert line-endings to UNIX
579
stored_patch = re.sub('\r\n?', '\n', self.patch)
580
calculated_patch = re.sub('\r\n?', '\n', calculated_patch)
581
# Strip trailing whitespace
582
calculated_patch = re.sub(' *\n', '\n', calculated_patch)
583
stored_patch = re.sub(' *\n', '\n', stored_patch)
584
return (calculated_patch == stored_patch)
586
def get_merge_request(self, repository):
587
"""Provide data for performing a merge
589
Returns suggested base, suggested target, and patch verification status
591
verified = self._maybe_verify(repository)
592
return self.base_revision_id, self.revision_id, verified
594
def _maybe_verify(self, repository):
595
if self.patch is not None:
596
if self._verify_patch(repository):
601
return 'inapplicable'
604
class MergeDirectiveFormatRegistry(registry.Registry):
606
def register(self, directive, format_string=None):
607
if format_string is None:
608
format_string = directive._format_string
609
registry.Registry.register(self, format_string, directive)
612
_format_registry = MergeDirectiveFormatRegistry()
613
_format_registry.register(MergeDirective)
614
_format_registry.register(MergeDirective2)
615
# 0.19 never existed. It got renamed to 0.90. But by that point, there were
616
# already merge directives in the wild that used 0.19. Registering with the old
617
# format string to retain compatibility with those merge directives.
618
_format_registry.register(MergeDirective2,
619
'Bazaar merge directive format 2 (Bazaar 0.19)')