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)
374
for line in line_iter:
375
if line.startswith('# Bazaar merge directive format '):
379
raise errors.NotAMergeDirective(lines[0])
381
raise errors.NotAMergeDirective('')
382
return _format_registry.get(line[2:].rstrip())._from_lines(line_iter)
385
def _from_lines(klass, line_iter):
386
stanza = rio.read_patch_stanza(line_iter)
387
patch_lines = list(line_iter)
388
if len(patch_lines) == 0:
392
patch = ''.join(patch_lines)
394
bundle_serializer.read_bundle(StringIO(patch))
395
except (errors.NotABundle, errors.BundleNotSupported,
399
patch_type = 'bundle'
400
time, timezone = timestamp.parse_patch_date(stanza.get('timestamp'))
402
for key in ('revision_id', 'testament_sha1', 'target_branch',
403
'source_branch', 'message'):
405
kwargs[key] = stanza.get(key)
408
kwargs['revision_id'] = kwargs['revision_id'].encode('utf-8')
409
return MergeDirective(time=time, timezone=timezone,
410
patch_type=patch_type, patch=patch, **kwargs)
413
lines = self._to_lines()
414
if self.patch is not None:
415
lines.extend(self.patch.splitlines(True))
419
def _generate_bundle(repository, revision_id, ancestor_id):
421
bundle_serializer.write_bundle(repository, revision_id,
422
ancestor_id, s, '0.9')
425
def get_merge_request(self, repository):
426
"""Provide data for performing a merge
428
Returns suggested base, suggested target, and patch verification status
430
return None, self.revision_id, 'inapplicable'
433
class MergeDirective2(_BaseMergeDirective):
435
_format_string = 'Bazaar merge directive format 2 (Bazaar 0.90)'
437
def __init__(self, revision_id, testament_sha1, time, timezone,
438
target_branch, patch=None, source_branch=None, message=None,
439
bundle=None, base_revision_id=None):
440
if source_branch is None and bundle is None:
441
raise errors.NoMergeSource()
442
_BaseMergeDirective.__init__(self, revision_id, testament_sha1, time,
443
timezone, target_branch, patch, source_branch, message)
445
self.base_revision_id = base_revision_id
447
def _patch_type(self):
448
if self.bundle is not None:
450
elif self.patch is not None:
455
patch_type = property(_patch_type)
457
def clear_payload(self):
461
def get_raw_bundle(self):
462
if self.bundle is None:
465
return self.bundle.decode('base-64')
468
def _from_lines(klass, line_iter):
469
stanza = rio.read_patch_stanza(line_iter)
473
start = line_iter.next()
474
except StopIteration:
477
if start.startswith('# Begin patch'):
479
for line in line_iter:
480
if line.startswith('# Begin bundle'):
483
patch_lines.append(line)
486
patch = ''.join(patch_lines)
487
if start is not None:
488
if start.startswith('# Begin bundle'):
489
bundle = ''.join(line_iter)
491
raise errors.IllegalMergeDirectivePayload(start)
492
time, timezone = timestamp.parse_patch_date(stanza.get('timestamp'))
494
for key in ('revision_id', 'testament_sha1', 'target_branch',
495
'source_branch', 'message', 'base_revision_id'):
497
kwargs[key] = stanza.get(key)
500
kwargs['revision_id'] = kwargs['revision_id'].encode('utf-8')
501
kwargs['base_revision_id'] =\
502
kwargs['base_revision_id'].encode('utf-8')
503
return klass(time=time, timezone=timezone, patch=patch, bundle=bundle,
507
lines = self._to_lines(base_revision=True)
508
if self.patch is not None:
509
lines.append('# Begin patch\n')
510
lines.extend(self.patch.splitlines(True))
511
if self.bundle is not None:
512
lines.append('# Begin bundle\n')
513
lines.extend(self.bundle.splitlines(True))
517
def from_objects(klass, repository, revision_id, time, timezone,
518
target_branch, include_patch=True, include_bundle=True,
519
local_target_branch=None, public_branch=None, message=None,
520
base_revision_id=None):
521
"""Generate a merge directive from various objects
523
:param repository: The repository containing the revision
524
:param revision_id: The revision to merge
525
:param time: The POSIX timestamp of the date the request was issued.
526
:param timezone: The timezone of the request
527
:param target_branch: The url of the branch to merge into
528
:param include_patch: If true, include a preview patch
529
:param include_bundle: If true, include a bundle
530
:param local_target_branch: a local copy of the target branch
531
:param public_branch: location of a public branch containing the target
533
:param message: Message to use when committing the merge
534
:return: The merge directive
536
The public branch is always used if supplied. If no bundle is
537
included, the public branch must be supplied, and will be verified.
539
If the message is not supplied, the message from revision_id will be
544
repository.lock_write()
545
locked.append(repository)
546
t_revision_id = revision_id
547
if revision_id == 'null:':
549
t = testament.StrictTestament3.from_revision(repository,
551
submit_branch = _mod_branch.Branch.open(target_branch)
552
submit_branch.lock_read()
553
locked.append(submit_branch)
554
if submit_branch.get_public_branch() is not None:
555
target_branch = submit_branch.get_public_branch()
556
submit_revision_id = submit_branch.last_revision()
557
submit_revision_id = _mod_revision.ensure_null(submit_revision_id)
558
graph = repository.get_graph(submit_branch.repository)
559
ancestor_id = graph.find_unique_lca(revision_id,
561
if base_revision_id is None:
562
base_revision_id = ancestor_id
563
if (include_patch, include_bundle) != (False, False):
564
repository.fetch(submit_branch.repository, submit_revision_id)
566
patch = klass._generate_diff(repository, revision_id,
572
bundle = klass._generate_bundle(repository, revision_id,
573
ancestor_id).encode('base-64')
577
if public_branch is not None and not include_bundle:
578
public_branch_obj = _mod_branch.Branch.open(public_branch)
579
public_branch_obj.lock_read()
580
locked.append(public_branch_obj)
581
if not public_branch_obj.repository.has_revision(
583
raise errors.PublicBranchOutOfDate(public_branch,
586
for entry in reversed(locked):
588
return klass(revision_id, t.as_sha1(), time, timezone, target_branch,
589
patch, public_branch, message, bundle, base_revision_id)
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)')