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,
34
from bzrlib.bundle import (
35
serializer as bundle_serializer,
37
from bzrlib.email_message import EmailMessage
40
class MergeRequestBodyParams(object):
41
"""Parameter object for the merge_request_body hook."""
43
def __init__(self, body, orig_body, directive, to, basename, subject,
46
self.orig_body = orig_body
47
self.directive = directive
51
self.basename = basename
52
self.subject = subject
55
class MergeDirectiveHooks(hooks.Hooks):
56
"""Hooks for MergeDirective classes."""
59
hooks.Hooks.__init__(self)
60
self.create_hook(hooks.HookPoint('merge_request_body',
61
"Called with a MergeRequestBodyParams when a body is needed for"
62
" a merge request. Callbacks must return a body. If more"
63
" than one callback is registered, the output of one callback is"
64
" provided to the next.", (1, 15, 0), False))
67
class _BaseMergeDirective(object):
69
hooks = MergeDirectiveHooks()
71
def __init__(self, revision_id, testament_sha1, time, timezone,
72
target_branch, patch=None, source_branch=None, message=None,
76
:param revision_id: The revision to merge
77
:param testament_sha1: The sha1 of the testament of the revision to
79
:param time: The current POSIX timestamp time
80
:param timezone: The timezone offset
81
:param target_branch: The branch to apply the merge to
82
:param patch: The text of a diff or bundle
83
:param source_branch: A public location to merge the revision from
84
:param message: The message to use when committing this merge
86
self.revision_id = revision_id
87
self.testament_sha1 = testament_sha1
89
self.timezone = timezone
90
self.target_branch = target_branch
92
self.source_branch = source_branch
93
self.message = message
95
def _to_lines(self, base_revision=False):
96
"""Serialize as a list of lines
98
:return: a list of lines
100
time_str = timestamp.format_patch_date(self.time, self.timezone)
101
stanza = rio.Stanza(revision_id=self.revision_id, timestamp=time_str,
102
target_branch=self.target_branch,
103
testament_sha1=self.testament_sha1)
104
for key in ('source_branch', 'message'):
105
if self.__dict__[key] is not None:
106
stanza.add(key, self.__dict__[key])
108
stanza.add('base_revision_id', self.base_revision_id)
109
lines = ['# ' + self._format_string + '\n']
110
lines.extend(rio.to_patch_lines(stanza))
115
def from_objects(klass, repository, revision_id, time, timezone,
116
target_branch, patch_type='bundle',
117
local_target_branch=None, public_branch=None, message=None):
118
"""Generate a merge directive from various objects
120
:param repository: The repository containing the revision
121
:param revision_id: The revision to merge
122
:param time: The POSIX timestamp of the date the request was issued.
123
:param timezone: The timezone of the request
124
:param target_branch: The url of the branch to merge into
125
:param patch_type: 'bundle', 'diff' or None, depending on the type of
127
:param local_target_branch: a local copy of the target branch
128
:param public_branch: location of a public branch containing the target
130
:param message: Message to use when committing the merge
131
:return: The merge directive
133
The public branch is always used if supplied. If the patch_type is
134
not 'bundle', the public branch must be supplied, and will be verified.
136
If the message is not supplied, the message from revision_id will be
139
t_revision_id = revision_id
140
if revision_id == _mod_revision.NULL_REVISION:
142
t = testament.StrictTestament3.from_revision(repository, t_revision_id)
143
submit_branch = _mod_branch.Branch.open(target_branch)
144
if submit_branch.get_public_branch() is not None:
145
target_branch = submit_branch.get_public_branch()
146
if patch_type is None:
149
submit_revision_id = submit_branch.last_revision()
150
submit_revision_id = _mod_revision.ensure_null(submit_revision_id)
151
repository.fetch(submit_branch.repository, submit_revision_id)
152
graph = repository.get_graph()
153
ancestor_id = graph.find_unique_lca(revision_id,
155
type_handler = {'bundle': klass._generate_bundle,
156
'diff': klass._generate_diff,
157
None: lambda x, y, z: None }
158
patch = type_handler[patch_type](repository, revision_id,
161
if public_branch is not None and patch_type != 'bundle':
162
public_branch_obj = _mod_branch.Branch.open(public_branch)
163
if not public_branch_obj.repository.has_revision(revision_id):
164
raise errors.PublicBranchOutOfDate(public_branch,
167
return klass(revision_id, t.as_sha1(), time, timezone, target_branch,
168
patch, patch_type, public_branch, message)
170
def get_disk_name(self, branch):
171
"""Generate a suitable basename for storing this directive on disk
173
:param branch: The Branch this merge directive was generated fro
176
revno, revision_id = branch.last_revision_info()
177
if self.revision_id == revision_id:
180
revno = branch.get_revision_id_to_revno_map().get(self.revision_id,
182
nick = re.sub('(\W+)', '-', branch.nick).strip('-')
183
return '%s-%s' % (nick, '.'.join(str(n) for n in revno))
186
def _generate_diff(repository, revision_id, ancestor_id):
187
tree_1 = repository.revision_tree(ancestor_id)
188
tree_2 = repository.revision_tree(revision_id)
190
diff.show_diff_trees(tree_1, tree_2, s, old_label='', new_label='')
194
def _generate_bundle(repository, revision_id, ancestor_id):
196
bundle_serializer.write_bundle(repository, revision_id,
200
def to_signed(self, branch):
201
"""Serialize as a signed string.
203
:param branch: The source branch, to get the signing strategy
206
my_gpg = gpg.GPGStrategy(branch.get_config())
207
return my_gpg.sign(''.join(self.to_lines()))
209
def to_email(self, mail_to, branch, sign=False):
210
"""Serialize as an email message.
212
:param mail_to: The address to mail the message to
213
:param branch: The source branch, to get the signing strategy and
215
:param sign: If True, gpg-sign the email
216
:return: an email message
218
mail_from = branch.get_config().username()
219
if self.message is not None:
220
subject = self.message
222
revision = branch.repository.get_revision(self.revision_id)
223
subject = revision.message
225
body = self.to_signed(branch)
227
body = ''.join(self.to_lines())
228
message = EmailMessage(mail_from, mail_to, subject, body)
231
def install_revisions(self, target_repo):
232
"""Install revisions and return the target revision"""
233
if not target_repo.has_revision(self.revision_id):
234
if self.patch_type == 'bundle':
235
info = bundle_serializer.read_bundle(
236
StringIO(self.get_raw_bundle()))
237
# We don't use the bundle's target revision, because
238
# MergeDirective.revision_id is authoritative.
240
info.install_revisions(target_repo, stream_input=False)
241
except errors.RevisionNotPresent:
242
# At least one dependency isn't present. Try installing
243
# missing revisions from the submit branch
246
_mod_branch.Branch.open(self.target_branch)
247
except errors.NotBranchError:
248
raise errors.TargetNotBranch(self.target_branch)
249
missing_revisions = []
250
bundle_revisions = set(r.revision_id for r in
252
for revision in info.real_revisions:
253
for parent_id in revision.parent_ids:
254
if (parent_id not in bundle_revisions and
255
not target_repo.has_revision(parent_id)):
256
missing_revisions.append(parent_id)
257
# reverse missing revisions to try to get heads first
259
unique_missing_set = set()
260
for revision in reversed(missing_revisions):
261
if revision in unique_missing_set:
263
unique_missing.append(revision)
264
unique_missing_set.add(revision)
265
for missing_revision in unique_missing:
266
target_repo.fetch(submit_branch.repository,
268
info.install_revisions(target_repo, stream_input=False)
270
source_branch = _mod_branch.Branch.open(self.source_branch)
271
target_repo.fetch(source_branch.repository, self.revision_id)
272
return self.revision_id
274
def compose_merge_request(self, mail_client, to, body, branch, tree=None):
275
"""Compose a request to merge this directive.
277
:param mail_client: The mail client to use for composing this request.
278
:param to: The address to compose the request to.
279
:param branch: The Branch that was used to produce this directive.
280
:param tree: The Tree (if any) for the Branch used to produce this
283
basename = self.get_disk_name(branch)
285
if self.message is not None:
286
subject += self.message
288
revision = branch.repository.get_revision(self.revision_id)
289
subject += revision.get_summary()
290
if getattr(mail_client, 'supports_body', False):
292
for hook in self.hooks['merge_request_body']:
293
params = MergeRequestBodyParams(body, orig_body, self,
294
to, basename, subject, branch,
297
elif len(self.hooks['merge_request_body']) > 0:
298
trace.warning('Cannot run merge_request_body hooks because mail'
299
' client %s does not support message bodies.',
300
mail_client.__class__.__name__)
301
mail_client.compose_merge_request(to, subject,
302
''.join(self.to_lines()),
306
class MergeDirective(_BaseMergeDirective):
308
"""A request to perform a merge into a branch.
310
Designed to be serialized and mailed. It provides all the information
311
needed to perform a merge automatically, by providing at minimum a revision
312
bundle or the location of a branch.
314
The serialization format is robust against certain common forms of
315
deterioration caused by mailing.
317
The format is also designed to be patch-compatible. If the directive
318
includes a diff or revision bundle, it should be possible to apply it
319
directly using the standard patch program.
322
_format_string = 'Bazaar merge directive format 1'
324
def __init__(self, revision_id, testament_sha1, time, timezone,
325
target_branch, patch=None, patch_type=None,
326
source_branch=None, message=None, bundle=None):
329
:param revision_id: The revision to merge
330
:param testament_sha1: The sha1 of the testament of the revision to
332
:param time: The current POSIX timestamp time
333
:param timezone: The timezone offset
334
:param target_branch: The branch to apply the merge to
335
:param patch: The text of a diff or bundle
336
:param patch_type: None, "diff" or "bundle", depending on the contents
338
:param source_branch: A public location to merge the revision from
339
:param message: The message to use when committing this merge
341
_BaseMergeDirective.__init__(self, revision_id, testament_sha1, time,
342
timezone, target_branch, patch, source_branch, message)
343
if patch_type not in (None, 'diff', 'bundle'):
344
raise ValueError(patch_type)
345
if patch_type != 'bundle' and source_branch is None:
346
raise errors.NoMergeSource()
347
if patch_type is not None and patch is None:
348
raise errors.PatchMissing(patch_type)
349
self.patch_type = patch_type
351
def clear_payload(self):
353
self.patch_type = None
355
def get_raw_bundle(self):
359
if self.patch_type == 'bundle':
364
bundle = property(_bundle)
367
def from_lines(klass, lines):
368
"""Deserialize a MergeRequest from an iterable of lines
370
:param lines: An iterable of lines
371
:return: a MergeRequest
373
line_iter = iter(lines)
375
for line in line_iter:
376
if line.startswith('# Bazaar merge directive format '):
377
return _format_registry.get(line[2:].rstrip())._from_lines(
379
firstline = firstline or line.strip()
380
raise errors.NotAMergeDirective(firstline)
383
def _from_lines(klass, line_iter):
384
stanza = rio.read_patch_stanza(line_iter)
385
patch_lines = list(line_iter)
386
if len(patch_lines) == 0:
390
patch = ''.join(patch_lines)
392
bundle_serializer.read_bundle(StringIO(patch))
393
except (errors.NotABundle, errors.BundleNotSupported,
397
patch_type = 'bundle'
398
time, timezone = timestamp.parse_patch_date(stanza.get('timestamp'))
400
for key in ('revision_id', 'testament_sha1', 'target_branch',
401
'source_branch', 'message'):
403
kwargs[key] = stanza.get(key)
406
kwargs['revision_id'] = kwargs['revision_id'].encode('utf-8')
407
return MergeDirective(time=time, timezone=timezone,
408
patch_type=patch_type, patch=patch, **kwargs)
411
lines = self._to_lines()
412
if self.patch is not None:
413
lines.extend(self.patch.splitlines(True))
417
def _generate_bundle(repository, revision_id, ancestor_id):
419
bundle_serializer.write_bundle(repository, revision_id,
420
ancestor_id, s, '0.9')
423
def get_merge_request(self, repository):
424
"""Provide data for performing a merge
426
Returns suggested base, suggested target, and patch verification status
428
return None, self.revision_id, 'inapplicable'
431
class MergeDirective2(_BaseMergeDirective):
433
_format_string = 'Bazaar merge directive format 2 (Bazaar 0.90)'
435
def __init__(self, revision_id, testament_sha1, time, timezone,
436
target_branch, patch=None, source_branch=None, message=None,
437
bundle=None, base_revision_id=None):
438
if source_branch is None and bundle is None:
439
raise errors.NoMergeSource()
440
_BaseMergeDirective.__init__(self, revision_id, testament_sha1, time,
441
timezone, target_branch, patch, source_branch, message)
443
self.base_revision_id = base_revision_id
445
def _patch_type(self):
446
if self.bundle is not None:
448
elif self.patch is not None:
453
patch_type = property(_patch_type)
455
def clear_payload(self):
459
def get_raw_bundle(self):
460
if self.bundle is None:
463
return self.bundle.decode('base-64')
466
def _from_lines(klass, line_iter):
467
stanza = rio.read_patch_stanza(line_iter)
471
start = line_iter.next()
472
except StopIteration:
475
if start.startswith('# Begin patch'):
477
for line in line_iter:
478
if line.startswith('# Begin bundle'):
481
patch_lines.append(line)
484
patch = ''.join(patch_lines)
485
if start is not None:
486
if start.startswith('# Begin bundle'):
487
bundle = ''.join(line_iter)
489
raise errors.IllegalMergeDirectivePayload(start)
490
time, timezone = timestamp.parse_patch_date(stanza.get('timestamp'))
492
for key in ('revision_id', 'testament_sha1', 'target_branch',
493
'source_branch', 'message', 'base_revision_id'):
495
kwargs[key] = stanza.get(key)
498
kwargs['revision_id'] = kwargs['revision_id'].encode('utf-8')
499
kwargs['base_revision_id'] =\
500
kwargs['base_revision_id'].encode('utf-8')
501
return klass(time=time, timezone=timezone, patch=patch, bundle=bundle,
505
lines = self._to_lines(base_revision=True)
506
if self.patch is not None:
507
lines.append('# Begin patch\n')
508
lines.extend(self.patch.splitlines(True))
509
if self.bundle is not None:
510
lines.append('# Begin bundle\n')
511
lines.extend(self.bundle.splitlines(True))
515
def from_objects(klass, repository, revision_id, time, timezone,
516
target_branch, include_patch=True, include_bundle=True,
517
local_target_branch=None, public_branch=None, message=None,
518
base_revision_id=None):
519
"""Generate a merge directive from various objects
521
:param repository: The repository containing the revision
522
:param revision_id: The revision to merge
523
:param time: The POSIX timestamp of the date the request was issued.
524
:param timezone: The timezone of the request
525
:param target_branch: The url of the branch to merge into
526
:param include_patch: If true, include a preview patch
527
:param include_bundle: If true, include a bundle
528
:param local_target_branch: a local copy of the target branch
529
:param public_branch: location of a public branch containing the target
531
:param message: Message to use when committing the merge
532
:return: The merge directive
534
The public branch is always used if supplied. If no bundle is
535
included, the public branch must be supplied, and will be verified.
537
If the message is not supplied, the message from revision_id will be
542
repository.lock_write()
543
locked.append(repository)
544
t_revision_id = revision_id
545
if revision_id == 'null:':
547
t = testament.StrictTestament3.from_revision(repository,
549
submit_branch = _mod_branch.Branch.open(target_branch)
550
submit_branch.lock_read()
551
locked.append(submit_branch)
552
if submit_branch.get_public_branch() is not None:
553
target_branch = submit_branch.get_public_branch()
554
submit_revision_id = submit_branch.last_revision()
555
submit_revision_id = _mod_revision.ensure_null(submit_revision_id)
556
graph = repository.get_graph(submit_branch.repository)
557
ancestor_id = graph.find_unique_lca(revision_id,
559
if base_revision_id is None:
560
base_revision_id = ancestor_id
561
if (include_patch, include_bundle) != (False, False):
562
repository.fetch(submit_branch.repository, submit_revision_id)
564
patch = klass._generate_diff(repository, revision_id,
570
bundle = klass._generate_bundle(repository, revision_id,
571
ancestor_id).encode('base-64')
575
if public_branch is not None and not include_bundle:
576
public_branch_obj = _mod_branch.Branch.open(public_branch)
577
public_branch_obj.lock_read()
578
locked.append(public_branch_obj)
579
if not public_branch_obj.repository.has_revision(
581
raise errors.PublicBranchOutOfDate(public_branch,
583
testament_sha1 = t.as_sha1()
585
for entry in reversed(locked):
587
return klass(revision_id, testament_sha1, time, timezone,
588
target_branch, patch, public_branch, message, bundle,
591
def _verify_patch(self, repository):
592
calculated_patch = self._generate_diff(repository, self.revision_id,
593
self.base_revision_id)
594
# Convert line-endings to UNIX
595
stored_patch = re.sub('\r\n?', '\n', self.patch)
596
calculated_patch = re.sub('\r\n?', '\n', calculated_patch)
597
# Strip trailing whitespace
598
calculated_patch = re.sub(' *\n', '\n', calculated_patch)
599
stored_patch = re.sub(' *\n', '\n', stored_patch)
600
return (calculated_patch == stored_patch)
602
def get_merge_request(self, repository):
603
"""Provide data for performing a merge
605
Returns suggested base, suggested target, and patch verification status
607
verified = self._maybe_verify(repository)
608
return self.base_revision_id, self.revision_id, verified
610
def _maybe_verify(self, repository):
611
if self.patch is not None:
612
if self._verify_patch(repository):
617
return 'inapplicable'
620
class MergeDirectiveFormatRegistry(registry.Registry):
622
def register(self, directive, format_string=None):
623
if format_string is None:
624
format_string = directive._format_string
625
registry.Registry.register(self, format_string, directive)
628
_format_registry = MergeDirectiveFormatRegistry()
629
_format_registry.register(MergeDirective)
630
_format_registry.register(MergeDirective2)
631
# 0.19 never existed. It got renamed to 0.90. But by that point, there were
632
# already merge directives in the wild that used 0.19. Registering with the old
633
# format string to retain compatibility with those merge directives.
634
_format_registry.register(MergeDirective2,
635
'Bazaar merge directive format 2 (Bazaar 0.19)')