Skip to content

Commit 59bb7c7

Browse files
committed
Make it possible to merge changes up through release branches
1 parent 2a4d57a commit 59bb7c7

File tree

2 files changed

+221
-10
lines changed

2 files changed

+221
-10
lines changed

vcs_repo_mgr/__init__.py

+158-2
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787

8888
# External dependencies.
8989
from executor import execute, quote
90-
from humanfriendly import coerce_boolean, compact, concatenate, format, format_path, parse_path
90+
from humanfriendly import Timer, coerce_boolean, compact, concatenate, format, format_path, parse_path, pluralize
9191
from natsort import natsort, natsort_key
9292
from property_manager import PropertyManager, lazy_property, required_property, writable_property
9393
from six import string_types
@@ -104,7 +104,7 @@
104104
)
105105

106106
# Semi-standard module versioning.
107-
__version__ = '0.28'
107+
__version__ = '0.29'
108108

109109
USER_CONFIG_FILE = os.path.expanduser('~/.vcs-repo-mgr.ini')
110110
"""The absolute pathname of the user-specific configuration file (a string)."""
@@ -872,6 +872,100 @@ def merge(self, revision=None):
872872
**self.get_author()
873873
))
874874

875+
def merge_up(self, target_branch=None, feature_branch=None, delete=True):
876+
"""
877+
Merge a change into one or more release branches and the default branch.
878+
879+
:param target_branch: The name of the release branch where merging of
880+
the feature branch starts (a string or
881+
:data:`None`, defaults to
882+
:attr:`current_branch`).
883+
:param feature_branch: The feature branch to merge in (a string or
884+
:data:`None`). Strings are parsed using
885+
:class:`FeatureBranchSpec`.
886+
:param delete: :data:`True` (the default) to delete or close the
887+
feature branch after it is merged, :data:`False`
888+
otherwise.
889+
:returns: If `feature_branch` is given the global revision id of the
890+
feature branch is returned, otherwise the global revision id
891+
of the target branch (before any merges performed by
892+
:func:`merge_up()`) is returned.
893+
:raises: The following exceptions can be raised:
894+
895+
- :exc:`~exceptions.TypeError` when `target_branch` and
896+
:attr:`current_branch` are both :data:`None`.
897+
- :exc:`~exceptions.ValueError` when the given target branch
898+
doesn't exist (based on :attr:`branches`).
899+
"""
900+
timer = Timer()
901+
# Validate the target branch or select the default target branch.
902+
if target_branch:
903+
if target_branch not in self.branches:
904+
raise ValueError("The target branch %r doesn't exist!" % target_branch)
905+
else:
906+
target_branch = self.current_branch
907+
if not target_branch:
908+
raise TypeError("You need to specify the target branch! (where merging starts)")
909+
# Parse the feature branch specification.
910+
feature_branch = FeatureBranchSpec(feature_branch) if feature_branch else None
911+
# Make sure we start with a clean working tree.
912+
self.ensure_clean()
913+
# Make sure we're up to date with our upstream repository (if any).
914+
self.update()
915+
# Check out the target branch.
916+
self.checkout(revision=target_branch)
917+
# Get the global revision id of the release branch we're about to merge.
918+
revision_to_merge = self.find_revision_id(target_branch)
919+
# Check if we need to merge in a feature branch.
920+
if feature_branch:
921+
if feature_branch.location:
922+
# Pull in the feature branch.
923+
self.update(remote=feature_branch.location)
924+
# Get the global revision id of the feature branch we're about to merge.
925+
revision_to_merge = self.find_revision_id(feature_branch.revision)
926+
# Merge in the feature branch.
927+
self.merge(revision=feature_branch.revision)
928+
# Commit the merge.
929+
self.commit(message="Merged %s" % feature_branch.expression)
930+
# Find the release branches in the repository.
931+
release_branches = [release.revision.branch for release in self.ordered_releases]
932+
logger.debug("Found %s: %s",
933+
pluralize(len(release_branches), "release branch", "release branches"),
934+
concatenate(release_branches))
935+
# Find the release branches after the target branch.
936+
later_branches = release_branches[release_branches.index(target_branch) + 1:]
937+
logger.info("Found %s after target branch (%s): %s",
938+
pluralize(len(later_branches), "release branch", "release branches"),
939+
target_branch,
940+
concatenate(later_branches))
941+
# Determine the branches that need to be merged.
942+
branches_to_upmerge = later_branches + [self.default_revision]
943+
logger.info("Merging up from %s to %s: %s",
944+
target_branch,
945+
pluralize(len(branches_to_upmerge), "branch", "branches"),
946+
concatenate(branches_to_upmerge))
947+
# Merge the feature branch up through the selected branches.
948+
merge_queue = [target_branch] + branches_to_upmerge
949+
while len(merge_queue) >= 2:
950+
from_branch = merge_queue[0]
951+
to_branch = merge_queue[1]
952+
logger.info("Merging %s into %s ..", from_branch, to_branch)
953+
self.checkout(revision=to_branch)
954+
self.merge(revision=from_branch)
955+
self.commit(message="Merged %s" % from_branch)
956+
merge_queue.pop(0)
957+
# Check if we need to delete or close the feature branch.
958+
if delete and feature_branch and feature_branch.revision in self.branches:
959+
# Delete or close the feature branch.
960+
self.delete_branch(
961+
branch_name=feature_branch.revision,
962+
message="Closing feature branch %s" % feature_branch.revision,
963+
)
964+
# Update the working tree to the default branch.
965+
self.checkout()
966+
logger.info("Done! Finished merging up in %s.", timer)
967+
return revision_to_merge
968+
875969
def add_files(self, *pathnames, **kw):
876970
"""
877971
Stage new files in the working tree to be included in the next commit.
@@ -1412,6 +1506,68 @@ def __repr__(self):
14121506
]))
14131507

14141508

1509+
class FeatureBranchSpec(PropertyManager):
1510+
1511+
"""Simple and human friendly feature branch specifications."""
1512+
1513+
def __init__(self, expression):
1514+
"""
1515+
Initialize a :class:`FeatureBranchSpec` object.
1516+
1517+
:param expression: A feature branch specification (a string).
1518+
1519+
The `expression` string is parsed as follows:
1520+
1521+
- If `expression` contains two nonempty substrings separated by the
1522+
character ``#`` it is split into two parts where the first part is
1523+
used to set :attr:`location` and the second part is used to set
1524+
:attr:`revision`.
1525+
- Otherwise `expression` is interpreted as a revision without a
1526+
location (in this case :attr:`location` will be :data:`None`).
1527+
1528+
Some examples to make things more concrete:
1529+
1530+
>>> from vcs_repo_mgr import FeatureBranchSpec
1531+
>>> FeatureBranchSpec('https://github.com/xolox/python-vcs-repo-mgr.git#remote-feature-branch')
1532+
FeatureBranchSpec(expression='https://github.com/xolox/python-vcs-repo-mgr.git#remote-feature-branch',
1533+
location='https://github.com/xolox/python-vcs-repo-mgr.git',
1534+
revision='remote-feature-branch')
1535+
>>> FeatureBranchSpec('local-feature-branch')
1536+
FeatureBranchSpec(expression='local-feature-branch',
1537+
location=None,
1538+
revision='local-feature-branch')
1539+
"""
1540+
super(FeatureBranchSpec, self).__init__(expression=expression)
1541+
1542+
@required_property
1543+
def expression(self):
1544+
"""The feature branch specification provided by the user (a string)."""
1545+
1546+
@writable_property
1547+
def location(self):
1548+
"""
1549+
The location of the repository that contains :attr:`revision` (a string or :data:`None`).
1550+
1551+
The computed default value of :attr:`location` is based on the value of
1552+
:attr:`expression` as described in the documentation of
1553+
:func:`__init__()`.
1554+
"""
1555+
location, _, revision = self.expression.partition('#')
1556+
return location if location and revision else None
1557+
1558+
@required_property
1559+
def revision(self):
1560+
"""
1561+
The name of the feature branch (a string).
1562+
1563+
The computed default value of :attr:`revision` is based on the value of
1564+
:attr:`expression` as described in the documentation of
1565+
:func:`__init__()`.
1566+
"""
1567+
location, _, revision = self.expression.partition('#')
1568+
return revision if location and revision else self.expression
1569+
1570+
14151571
class HgRepo(Repository):
14161572

14171573
"""

vcs_repo_mgr/tests.py

+63-8
Original file line numberDiff line numberDiff line change
@@ -314,14 +314,7 @@ def check_working_tree_support(self, source_repo, file_to_change='setup.py'):
314314
# Make sure the source repository contains a bare checkout.
315315
assert source_repo.is_bare, "Expected a bare repository checkout!"
316316
# Create a clone of the repository that does have a working tree.
317-
# TODO Cloning of repository objects might deserve being a feature?
318-
kw = dict((n, getattr(source_repo, n)) for n in ('release_scheme', 'release_filter', 'default_revision'))
319-
cloned_repo = source_repo.__class__(
320-
author="Peter Odding <vcs-repo-mgr@peterodding.com>",
321-
local=create_temporary_directory(),
322-
remote=source_repo.local,
323-
bare=False, **kw
324-
)
317+
cloned_repo = self.clone_repo(source_repo, bare=False)
325318
# Make sure the clone doesn't exist yet.
326319
assert not cloned_repo.exists
327320
# Create the clone.
@@ -345,6 +338,20 @@ def check_working_tree_support(self, source_repo, file_to_change='setup.py'):
345338
self.check_checkout_support(cloned_repo)
346339
self.check_commit_support(cloned_repo)
347340
self.check_branch_support(cloned_repo)
341+
self.check_merge_up_support(cloned_repo)
342+
343+
def clone_repo(self, repository, **kw):
344+
"""Clone a repository object."""
345+
# TODO Cloning of repository objects might deserve being a feature?
346+
properties = 'bare', 'default_revision', 'release_scheme', 'release_filter'
347+
options = dict((n, getattr(repository, n)) for n in properties)
348+
options.update(kw)
349+
return repository.__class__(
350+
author="Peter Odding <vcs-repo-mgr@peterodding.com>",
351+
local=create_temporary_directory(),
352+
remote=repository.local,
353+
**options
354+
)
348355

349356
def check_checkout_support(self, repository):
350357
"""Make sure that checkout() works and it can clean the working tree."""
@@ -439,6 +446,54 @@ def check_merge_support(self, repository, source_branch, target_branch):
439446
except NotImplementedError as e:
440447
logger.warning("%s", e)
441448

449+
def check_merge_up_support(self, repository, num_branches=5):
450+
"""Make sure we can merge changes up through release branches."""
451+
logger.info("Testing merge_up() support ..")
452+
try:
453+
# Clone the repository with a custom release scheme/filter.
454+
repository = self.clone_repo(
455+
repository, bare=False,
456+
release_scheme='branches',
457+
release_filter='^v(\d*)$',
458+
)
459+
# Pick a directory name of which we can reasonably expect that
460+
# no existing repository will already contain this directory.
461+
unique_directory = 'vcs-repo-mgr-merge-up-support-%s' % random_string()
462+
absolute_directory = os.path.join(repository.local, unique_directory)
463+
# Create the release branches.
464+
previous_branch = repository.current_branch
465+
for i in range(1, num_branches + 1):
466+
branch_name = 'v%i' % i
467+
repository.checkout(revision=previous_branch)
468+
repository.create_branch(branch_name)
469+
if not os.path.isdir(absolute_directory):
470+
os.mkdir(absolute_directory)
471+
with open(os.path.join(absolute_directory, branch_name), 'w') as handle:
472+
handle.write("Version %i\n" % i)
473+
repository.add_files(all=True)
474+
repository.commit(message="Create release branch %s" % branch_name)
475+
previous_branch = branch_name
476+
# Create a feature branch based on the initial release branch.
477+
feature_branch = 'vcs-repo-mgr-feature-branch-%s' % random_string()
478+
repository.checkout('v1')
479+
repository.create_branch(feature_branch)
480+
with open(os.path.join(absolute_directory, 'v1'), 'w') as handle:
481+
handle.write("Version 1.1\n")
482+
repository.commit(message="Fixed a bug in version 1")
483+
assert feature_branch in repository.branches
484+
# Merge the change up into the release branches.
485+
expected_revision = repository.find_revision_id(revision=feature_branch)
486+
merged_revision = repository.merge_up(target_branch='v1', feature_branch=feature_branch)
487+
assert merged_revision == expected_revision
488+
# Make sure the feature branch was closed.
489+
assert feature_branch not in repository.branches
490+
# Validate the contents of the default branch.
491+
repository.checkout()
492+
entries = os.listdir(absolute_directory)
493+
assert all('v%i' % i in entries for i in range(1, num_branches + 1))
494+
except NotImplementedError as e:
495+
logger.warning("%s", e)
496+
442497
def mutate_working_tree(self, repository):
443498
"""Mutate an arbitrary tracked file in the repository's working tree."""
444499
vcs_directory = os.path.abspath(repository.vcs_directory)

0 commit comments

Comments
 (0)