1
# Copyright (C) 2007-2011 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
17
from __future__ import absolute_import
21
from io import BytesIO
24
from . import lazy_import
25
lazy_import.lazy_import(globals(), """
27
branch as _mod_branch,
33
revision as _mod_revision,
38
from breezy.bzr import (
41
from breezy.bzr.bundle import (
42
serializer as bundle_serializer,
50
class MergeRequestBodyParams(object):
51
"""Parameter object for the merge_request_body hook."""
53
def __init__(self, body, orig_body, directive, to, basename, subject,
56
self.orig_body = orig_body
57
self.directive = directive
61
self.basename = basename
62
self.subject = subject
65
class MergeDirectiveHooks(hooks.Hooks):
66
"""Hooks for MergeDirective classes."""
69
hooks.Hooks.__init__(self, "breezy.merge_directive",
70
"BaseMergeDirective.hooks")
73
"Called with a MergeRequestBodyParams when a body is needed for"
74
" a merge request. Callbacks must return a body. If more"
75
" than one callback is registered, the output of one callback is"
76
" provided to the next.", (1, 15, 0))
79
class BaseMergeDirective(object):
80
"""A request to perform a merge into a branch.
82
This is the base class that all merge directive implementations
85
:cvar multiple_output_files: Whether or not this merge directive
86
stores a set of revisions in more than one file
89
hooks = MergeDirectiveHooks()
91
multiple_output_files = False
93
def __init__(self, revision_id, testament_sha1, time, timezone,
94
target_branch, patch=None, source_branch=None,
95
message=None, bundle=None):
98
:param revision_id: The revision to merge
99
:param testament_sha1: The sha1 of the testament of the revision to
101
:param time: The current POSIX timestamp time
102
:param timezone: The timezone offset
103
:param target_branch: Location of branch to apply the merge to
104
:param patch: The text of a diff or bundle
105
:param source_branch: A public location to merge the revision from
106
:param message: The message to use when committing this merge
108
self.revision_id = revision_id
109
self.testament_sha1 = testament_sha1
111
self.timezone = timezone
112
self.target_branch = target_branch
114
self.source_branch = source_branch
115
self.message = message
118
"""Serialize as a list of lines
120
:return: a list of lines
122
raise NotImplementedError(self.to_lines)
125
"""Serialize as a set of files.
127
:return: List of tuples with filename and contents as lines
129
raise NotImplementedError(self.to_files)
131
def get_raw_bundle(self):
132
"""Return the bundle for this merge directive.
134
:return: bundle text or None if there is no bundle
138
def _to_lines(self, base_revision=False):
139
"""Serialize as a list of lines
141
:return: a list of lines
143
time_str = timestamp.format_patch_date(self.time, self.timezone)
144
stanza = rio.Stanza(revision_id=self.revision_id, timestamp=time_str,
145
target_branch=self.target_branch,
146
testament_sha1=self.testament_sha1)
147
for key in ('source_branch', 'message'):
148
if self.__dict__[key] is not None:
149
stanza.add(key, self.__dict__[key])
151
stanza.add('base_revision_id', self.base_revision_id)
152
lines = [b'# ' + self._format_string + b'\n']
153
lines.extend(rio.to_patch_lines(stanza))
154
lines.append(b'# \n')
157
def write_to_directory(self, path):
158
"""Write this merge directive to a series of files in a directory.
160
:param path: Filesystem path to write to
162
raise NotImplementedError(self.write_to_directory)
165
def from_objects(klass, repository, revision_id, time, timezone,
166
target_branch, patch_type='bundle',
167
local_target_branch=None, public_branch=None, message=None):
168
"""Generate a merge directive from various objects
170
:param repository: The repository containing the revision
171
:param revision_id: The revision to merge
172
:param time: The POSIX timestamp of the date the request was issued.
173
:param timezone: The timezone of the request
174
:param target_branch: The url of the branch to merge into
175
:param patch_type: 'bundle', 'diff' or None, depending on the type of
177
:param local_target_branch: the submit branch, either itself or a local copy
178
:param public_branch: location of a public branch containing
180
:param message: Message to use when committing the merge
181
:return: The merge directive
183
The public branch is always used if supplied. If the patch_type is
184
not 'bundle', the public branch must be supplied, and will be verified.
186
If the message is not supplied, the message from revision_id will be
189
t_revision_id = revision_id
190
if revision_id == _mod_revision.NULL_REVISION:
192
t = testament.StrictTestament3.from_revision(repository, t_revision_id)
193
if local_target_branch is None:
194
submit_branch = _mod_branch.Branch.open(target_branch)
196
submit_branch = local_target_branch
197
if submit_branch.get_public_branch() is not None:
198
target_branch = submit_branch.get_public_branch()
199
if patch_type is None:
202
submit_revision_id = submit_branch.last_revision()
203
submit_revision_id = _mod_revision.ensure_null(submit_revision_id)
204
repository.fetch(submit_branch.repository, submit_revision_id)
205
graph = repository.get_graph()
206
ancestor_id = graph.find_unique_lca(revision_id,
208
type_handler = {'bundle': klass._generate_bundle,
209
'diff': klass._generate_diff,
210
None: lambda x, y, z: None}
211
patch = type_handler[patch_type](repository, revision_id,
214
if public_branch is not None and patch_type != 'bundle':
215
public_branch_obj = _mod_branch.Branch.open(public_branch)
216
if not public_branch_obj.repository.has_revision(revision_id):
217
raise errors.PublicBranchOutOfDate(public_branch,
220
return klass(revision_id, t.as_sha1(), time, timezone, target_branch,
221
patch, patch_type, public_branch, message)
223
def get_disk_name(self, branch):
224
"""Generate a suitable basename for storing this directive on disk
226
:param branch: The Branch this merge directive was generated fro
229
revno, revision_id = branch.last_revision_info()
230
if self.revision_id == revision_id:
234
revno = branch.revision_id_to_dotted_revno(self.revision_id)
235
except errors.NoSuchRevision:
237
nick = re.sub('(\\W+)', '-', branch.nick).strip('-')
238
return '%s-%s' % (nick, '.'.join(str(n) for n in revno))
241
def _generate_diff(repository, revision_id, ancestor_id):
242
tree_1 = repository.revision_tree(ancestor_id)
243
tree_2 = repository.revision_tree(revision_id)
245
diff.show_diff_trees(tree_1, tree_2, s, old_label='', new_label='')
249
def _generate_bundle(repository, revision_id, ancestor_id):
251
bundle_serializer.write_bundle(repository, revision_id,
255
def to_signed(self, branch):
256
"""Serialize as a signed string.
258
:param branch: The source branch, to get the signing strategy
261
my_gpg = gpg.GPGStrategy(branch.get_config_stack())
262
return my_gpg.sign(b''.join(self.to_lines()), gpg.MODE_CLEAR)
264
def to_email(self, mail_to, branch, sign=False):
265
"""Serialize as an email message.
267
:param mail_to: The address to mail the message to
268
:param branch: The source branch, to get the signing strategy and
270
:param sign: If True, gpg-sign the email
271
:return: an email message
273
mail_from = branch.get_config_stack().get('email')
274
if self.message is not None:
275
subject = self.message
277
revision = branch.repository.get_revision(self.revision_id)
278
subject = revision.message
280
body = self.to_signed(branch)
282
body = b''.join(self.to_lines())
283
message = email_message.EmailMessage(mail_from, mail_to, subject,
287
def install_revisions(self, target_repo):
288
"""Install revisions and return the target revision"""
289
if not target_repo.has_revision(self.revision_id):
290
if self.patch_type == 'bundle':
291
info = bundle_serializer.read_bundle(
292
BytesIO(self.get_raw_bundle()))
293
# We don't use the bundle's target revision, because
294
# MergeDirective.revision_id is authoritative.
296
info.install_revisions(target_repo, stream_input=False)
297
except errors.RevisionNotPresent:
298
# At least one dependency isn't present. Try installing
299
# missing revisions from the submit branch
302
_mod_branch.Branch.open(self.target_branch)
303
except errors.NotBranchError:
304
raise errors.TargetNotBranch(self.target_branch)
305
missing_revisions = []
306
bundle_revisions = set(r.revision_id for r in
308
for revision in info.real_revisions:
309
for parent_id in revision.parent_ids:
310
if (parent_id not in bundle_revisions
311
and not target_repo.has_revision(parent_id)):
312
missing_revisions.append(parent_id)
313
# reverse missing revisions to try to get heads first
315
unique_missing_set = set()
316
for revision in reversed(missing_revisions):
317
if revision in unique_missing_set:
319
unique_missing.append(revision)
320
unique_missing_set.add(revision)
321
for missing_revision in unique_missing:
322
target_repo.fetch(submit_branch.repository,
324
info.install_revisions(target_repo, stream_input=False)
326
source_branch = _mod_branch.Branch.open(self.source_branch)
327
target_repo.fetch(source_branch.repository, self.revision_id)
328
return self.revision_id
330
def compose_merge_request(self, mail_client, to, body, branch, tree=None):
331
"""Compose a request to merge this directive.
333
:param mail_client: The mail client to use for composing this request.
334
:param to: The address to compose the request to.
335
:param branch: The Branch that was used to produce this directive.
336
:param tree: The Tree (if any) for the Branch used to produce this
339
basename = self.get_disk_name(branch)
341
if self.message is not None:
342
subject += self.message
344
revision = branch.repository.get_revision(self.revision_id)
345
subject += revision.get_summary()
346
if getattr(mail_client, 'supports_body', False):
348
for hook in self.hooks['merge_request_body']:
349
params = MergeRequestBodyParams(body, orig_body, self,
350
to, basename, subject, branch,
353
elif len(self.hooks['merge_request_body']) > 0:
354
trace.warning('Cannot run merge_request_body hooks because mail'
355
' client %s does not support message bodies.',
356
mail_client.__class__.__name__)
357
mail_client.compose_merge_request(to, subject,
358
b''.join(self.to_lines()),
362
class MergeDirective(BaseMergeDirective):
364
"""A request to perform a merge into a branch.
366
Designed to be serialized and mailed. It provides all the information
367
needed to perform a merge automatically, by providing at minimum a revision
368
bundle or the location of a branch.
370
The serialization format is robust against certain common forms of
371
deterioration caused by mailing.
373
The format is also designed to be patch-compatible. If the directive
374
includes a diff or revision bundle, it should be possible to apply it
375
directly using the standard patch program.
378
_format_string = b'Bazaar merge directive format 1'
380
def __init__(self, revision_id, testament_sha1, time, timezone,
381
target_branch, patch=None, patch_type=None,
382
source_branch=None, message=None, bundle=None):
385
:param revision_id: The revision to merge
386
:param testament_sha1: The sha1 of the testament of the revision to
388
:param time: The current POSIX timestamp time
389
:param timezone: The timezone offset
390
:param target_branch: Location of the branch to apply the merge to
391
:param patch: The text of a diff or bundle
392
:param patch_type: None, "diff" or "bundle", depending on the contents
394
:param source_branch: A public location to merge the revision from
395
:param message: The message to use when committing this merge
397
BaseMergeDirective.__init__(self, revision_id, testament_sha1, time,
398
timezone, target_branch, patch, source_branch, message)
399
if patch_type not in (None, 'diff', 'bundle'):
400
raise ValueError(patch_type)
401
if patch_type != 'bundle' and source_branch is None:
402
raise errors.NoMergeSource()
403
if patch_type is not None and patch is None:
404
raise errors.PatchMissing(patch_type)
405
self.patch_type = patch_type
407
def clear_payload(self):
409
self.patch_type = None
411
def get_raw_bundle(self):
415
if self.patch_type == 'bundle':
420
bundle = property(_bundle)
423
def from_lines(klass, lines):
424
"""Deserialize a MergeRequest from an iterable of lines
426
:param lines: An iterable of lines
427
:return: a MergeRequest
429
line_iter = iter(lines)
431
for line in line_iter:
432
if line.startswith(b'# Bazaar merge directive format '):
433
return _format_registry.get(line[2:].rstrip())._from_lines(
435
firstline = firstline or line.strip()
436
raise errors.NotAMergeDirective(firstline)
439
def _from_lines(klass, line_iter):
440
stanza = rio.read_patch_stanza(line_iter)
441
patch_lines = list(line_iter)
442
if len(patch_lines) == 0:
446
patch = b''.join(patch_lines)
448
bundle_serializer.read_bundle(BytesIO(patch))
449
except (errors.NotABundle, errors.BundleNotSupported,
453
patch_type = 'bundle'
454
time, timezone = timestamp.parse_patch_date(stanza.get('timestamp'))
456
for key in ('revision_id', 'testament_sha1', 'target_branch',
457
'source_branch', 'message'):
459
kwargs[key] = stanza.get(key)
462
kwargs['revision_id'] = kwargs['revision_id'].encode('utf-8')
463
if 'testament_sha1' in kwargs:
464
kwargs['testament_sha1'] = kwargs['testament_sha1'].encode('ascii')
465
return MergeDirective(time=time, timezone=timezone,
466
patch_type=patch_type, patch=patch, **kwargs)
469
lines = self._to_lines()
470
if self.patch is not None:
471
lines.extend(self.patch.splitlines(True))
475
def _generate_bundle(repository, revision_id, ancestor_id):
477
bundle_serializer.write_bundle(repository, revision_id,
478
ancestor_id, s, '0.9')
481
def get_merge_request(self, repository):
482
"""Provide data for performing a merge
484
Returns suggested base, suggested target, and patch verification status
486
return None, self.revision_id, 'inapplicable'
489
class MergeDirective2(BaseMergeDirective):
491
_format_string = b'Bazaar merge directive format 2 (Bazaar 0.90)'
493
def __init__(self, revision_id, testament_sha1, time, timezone,
494
target_branch, patch=None, source_branch=None, message=None,
495
bundle=None, base_revision_id=None):
496
if source_branch is None and bundle is None:
497
raise errors.NoMergeSource()
498
BaseMergeDirective.__init__(self, revision_id, testament_sha1, time,
499
timezone, target_branch, patch, source_branch, message)
501
self.base_revision_id = base_revision_id
503
def _patch_type(self):
504
if self.bundle is not None:
506
elif self.patch is not None:
511
patch_type = property(_patch_type)
513
def clear_payload(self):
517
def get_raw_bundle(self):
518
if self.bundle is None:
521
return base64.b64decode(self.bundle)
524
def _from_lines(klass, line_iter):
525
stanza = rio.read_patch_stanza(line_iter)
529
start = next(line_iter)
530
except StopIteration:
533
if start.startswith(b'# Begin patch'):
535
for line in line_iter:
536
if line.startswith(b'# Begin bundle'):
539
patch_lines.append(line)
542
patch = b''.join(patch_lines)
543
if start is not None:
544
if start.startswith(b'# Begin bundle'):
545
bundle = b''.join(line_iter)
547
raise errors.IllegalMergeDirectivePayload(start)
548
time, timezone = timestamp.parse_patch_date(stanza.get('timestamp'))
550
for key in ('revision_id', 'testament_sha1', 'target_branch',
551
'source_branch', 'message', 'base_revision_id'):
553
kwargs[key] = stanza.get(key)
556
kwargs['revision_id'] = kwargs['revision_id'].encode('utf-8')
557
kwargs['base_revision_id'] =\
558
kwargs['base_revision_id'].encode('utf-8')
559
if 'testament_sha1' in kwargs:
560
kwargs['testament_sha1'] = kwargs['testament_sha1'].encode('ascii')
561
return klass(time=time, timezone=timezone, patch=patch, bundle=bundle,
565
lines = self._to_lines(base_revision=True)
566
if self.patch is not None:
567
lines.append(b'# Begin patch\n')
568
lines.extend(self.patch.splitlines(True))
569
if self.bundle is not None:
570
lines.append(b'# Begin bundle\n')
571
lines.extend(self.bundle.splitlines(True))
575
def from_objects(klass, repository, revision_id, time, timezone,
576
target_branch, include_patch=True, include_bundle=True,
577
local_target_branch=None, public_branch=None, message=None,
578
base_revision_id=None):
579
"""Generate a merge directive from various objects
581
:param repository: The repository containing the revision
582
:param revision_id: The revision to merge
583
:param time: The POSIX timestamp of the date the request was issued.
584
:param timezone: The timezone of the request
585
:param target_branch: The url of the branch to merge into
586
:param include_patch: If true, include a preview patch
587
:param include_bundle: If true, include a bundle
588
:param local_target_branch: the target branch, either itself or a local copy
589
:param public_branch: location of a public branch containing
591
:param message: Message to use when committing the merge
592
:return: The merge directive
594
The public branch is always used if supplied. If no bundle is
595
included, the public branch must be supplied, and will be verified.
597
If the message is not supplied, the message from revision_id will be
600
with contextlib.ExitStack() as exit_stack:
601
exit_stack.enter_context(repository.lock_write())
602
t_revision_id = revision_id
603
if revision_id == b'null:':
605
t = testament.StrictTestament3.from_revision(repository,
607
if local_target_branch is None:
608
submit_branch = _mod_branch.Branch.open(target_branch)
610
submit_branch = local_target_branch
611
exit_stack.enter_context(submit_branch.lock_read())
612
if submit_branch.get_public_branch() is not None:
613
target_branch = submit_branch.get_public_branch()
614
submit_revision_id = submit_branch.last_revision()
615
submit_revision_id = _mod_revision.ensure_null(submit_revision_id)
616
graph = repository.get_graph(submit_branch.repository)
617
ancestor_id = graph.find_unique_lca(revision_id,
619
if base_revision_id is None:
620
base_revision_id = ancestor_id
621
if (include_patch, include_bundle) != (False, False):
622
repository.fetch(submit_branch.repository, submit_revision_id)
624
patch = klass._generate_diff(repository, revision_id,
630
bundle = base64.b64encode(klass._generate_bundle(repository, revision_id,
635
if public_branch is not None and not include_bundle:
636
public_branch_obj = _mod_branch.Branch.open(public_branch)
637
exit_stack.enter_context(public_branch_obj.lock_read())
638
if not public_branch_obj.repository.has_revision(
640
raise errors.PublicBranchOutOfDate(public_branch,
642
testament_sha1 = t.as_sha1()
643
return klass(revision_id, testament_sha1, time, timezone,
644
target_branch, patch, public_branch, message, bundle,
647
def _verify_patch(self, repository):
648
calculated_patch = self._generate_diff(repository, self.revision_id,
649
self.base_revision_id)
650
# Convert line-endings to UNIX
651
stored_patch = re.sub(b'\r\n?', b'\n', self.patch)
652
calculated_patch = re.sub(b'\r\n?', b'\n', calculated_patch)
653
# Strip trailing whitespace
654
calculated_patch = re.sub(b' *\n', b'\n', calculated_patch)
655
stored_patch = re.sub(b' *\n', b'\n', stored_patch)
656
return (calculated_patch == stored_patch)
658
def get_merge_request(self, repository):
659
"""Provide data for performing a merge
661
Returns suggested base, suggested target, and patch verification status
663
verified = self._maybe_verify(repository)
664
return self.base_revision_id, self.revision_id, verified
666
def _maybe_verify(self, repository):
667
if self.patch is not None:
668
if self._verify_patch(repository):
673
return 'inapplicable'
676
class MergeDirectiveFormatRegistry(registry.Registry):
678
def register(self, directive, format_string=None):
679
if format_string is None:
680
format_string = directive._format_string
681
registry.Registry.register(self, format_string, directive)
684
_format_registry = MergeDirectiveFormatRegistry()
685
_format_registry.register(MergeDirective)
686
_format_registry.register(MergeDirective2)
687
# 0.19 never existed. It got renamed to 0.90. But by that point, there were
688
# already merge directives in the wild that used 0.19. Registering with the old
689
# format string to retain compatibility with those merge directives.
690
_format_registry.register(MergeDirective2,
691
b'Bazaar merge directive format 2 (Bazaar 0.19)')