Skip to content

Commit

Permalink
Make it possible to merge changes up through release branches
Browse files Browse the repository at this point in the history
  • Loading branch information
xolox committed Mar 18, 2016
1 parent 2a4d57a commit 59bb7c7
Show file tree
Hide file tree
Showing 2 changed files with 221 additions and 10 deletions.
160 changes: 158 additions & 2 deletions vcs_repo_mgr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@

# External dependencies.
from executor import execute, quote
from humanfriendly import coerce_boolean, compact, concatenate, format, format_path, parse_path
from humanfriendly import Timer, coerce_boolean, compact, concatenate, format, format_path, parse_path, pluralize
from natsort import natsort, natsort_key
from property_manager import PropertyManager, lazy_property, required_property, writable_property
from six import string_types
Expand All @@ -104,7 +104,7 @@
)

# Semi-standard module versioning.
__version__ = '0.28'
__version__ = '0.29'

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

def merge_up(self, target_branch=None, feature_branch=None, delete=True):
"""
Merge a change into one or more release branches and the default branch.
:param target_branch: The name of the release branch where merging of
the feature branch starts (a string or
:data:`None`, defaults to
:attr:`current_branch`).
:param feature_branch: The feature branch to merge in (a string or
:data:`None`). Strings are parsed using
:class:`FeatureBranchSpec`.
:param delete: :data:`True` (the default) to delete or close the
feature branch after it is merged, :data:`False`
otherwise.
:returns: If `feature_branch` is given the global revision id of the
feature branch is returned, otherwise the global revision id
of the target branch (before any merges performed by
:func:`merge_up()`) is returned.
:raises: The following exceptions can be raised:
- :exc:`~exceptions.TypeError` when `target_branch` and
:attr:`current_branch` are both :data:`None`.
- :exc:`~exceptions.ValueError` when the given target branch
doesn't exist (based on :attr:`branches`).
"""
timer = Timer()
# Validate the target branch or select the default target branch.
if target_branch:
if target_branch not in self.branches:
raise ValueError("The target branch %r doesn't exist!" % target_branch)
else:
target_branch = self.current_branch
if not target_branch:
raise TypeError("You need to specify the target branch! (where merging starts)")
# Parse the feature branch specification.
feature_branch = FeatureBranchSpec(feature_branch) if feature_branch else None
# Make sure we start with a clean working tree.
self.ensure_clean()
# Make sure we're up to date with our upstream repository (if any).
self.update()
# Check out the target branch.
self.checkout(revision=target_branch)
# Get the global revision id of the release branch we're about to merge.
revision_to_merge = self.find_revision_id(target_branch)
# Check if we need to merge in a feature branch.
if feature_branch:
if feature_branch.location:
# Pull in the feature branch.
self.update(remote=feature_branch.location)
# Get the global revision id of the feature branch we're about to merge.
revision_to_merge = self.find_revision_id(feature_branch.revision)
# Merge in the feature branch.
self.merge(revision=feature_branch.revision)
# Commit the merge.
self.commit(message="Merged %s" % feature_branch.expression)
# Find the release branches in the repository.
release_branches = [release.revision.branch for release in self.ordered_releases]
logger.debug("Found %s: %s",
pluralize(len(release_branches), "release branch", "release branches"),
concatenate(release_branches))
# Find the release branches after the target branch.
later_branches = release_branches[release_branches.index(target_branch) + 1:]
logger.info("Found %s after target branch (%s): %s",
pluralize(len(later_branches), "release branch", "release branches"),
target_branch,
concatenate(later_branches))
# Determine the branches that need to be merged.
branches_to_upmerge = later_branches + [self.default_revision]
logger.info("Merging up from %s to %s: %s",
target_branch,
pluralize(len(branches_to_upmerge), "branch", "branches"),
concatenate(branches_to_upmerge))
# Merge the feature branch up through the selected branches.
merge_queue = [target_branch] + branches_to_upmerge
while len(merge_queue) >= 2:
from_branch = merge_queue[0]
to_branch = merge_queue[1]
logger.info("Merging %s into %s ..", from_branch, to_branch)
self.checkout(revision=to_branch)
self.merge(revision=from_branch)
self.commit(message="Merged %s" % from_branch)
merge_queue.pop(0)
# Check if we need to delete or close the feature branch.
if delete and feature_branch and feature_branch.revision in self.branches:
# Delete or close the feature branch.
self.delete_branch(
branch_name=feature_branch.revision,
message="Closing feature branch %s" % feature_branch.revision,
)
# Update the working tree to the default branch.
self.checkout()
logger.info("Done! Finished merging up in %s.", timer)
return revision_to_merge

def add_files(self, *pathnames, **kw):
"""
Stage new files in the working tree to be included in the next commit.
Expand Down Expand Up @@ -1412,6 +1506,68 @@ def __repr__(self):
]))


class FeatureBranchSpec(PropertyManager):

"""Simple and human friendly feature branch specifications."""

def __init__(self, expression):
"""
Initialize a :class:`FeatureBranchSpec` object.
:param expression: A feature branch specification (a string).
The `expression` string is parsed as follows:
- If `expression` contains two nonempty substrings separated by the
character ``#`` it is split into two parts where the first part is
used to set :attr:`location` and the second part is used to set
:attr:`revision`.
- Otherwise `expression` is interpreted as a revision without a
location (in this case :attr:`location` will be :data:`None`).
Some examples to make things more concrete:
>>> from vcs_repo_mgr import FeatureBranchSpec
>>> FeatureBranchSpec('https://github.com/xolox/python-vcs-repo-mgr.git#remote-feature-branch')
FeatureBranchSpec(expression='https://github.com/xolox/python-vcs-repo-mgr.git#remote-feature-branch',
location='https://github.com/xolox/python-vcs-repo-mgr.git',
revision='remote-feature-branch')
>>> FeatureBranchSpec('local-feature-branch')
FeatureBranchSpec(expression='local-feature-branch',
location=None,
revision='local-feature-branch')
"""
super(FeatureBranchSpec, self).__init__(expression=expression)

@required_property
def expression(self):
"""The feature branch specification provided by the user (a string)."""

@writable_property
def location(self):
"""
The location of the repository that contains :attr:`revision` (a string or :data:`None`).
The computed default value of :attr:`location` is based on the value of
:attr:`expression` as described in the documentation of
:func:`__init__()`.
"""
location, _, revision = self.expression.partition('#')
return location if location and revision else None

@required_property
def revision(self):
"""
The name of the feature branch (a string).
The computed default value of :attr:`revision` is based on the value of
:attr:`expression` as described in the documentation of
:func:`__init__()`.
"""
location, _, revision = self.expression.partition('#')
return revision if location and revision else self.expression


class HgRepo(Repository):

"""
Expand Down
71 changes: 63 additions & 8 deletions vcs_repo_mgr/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,14 +314,7 @@ def check_working_tree_support(self, source_repo, file_to_change='setup.py'):
# Make sure the source repository contains a bare checkout.
assert source_repo.is_bare, "Expected a bare repository checkout!"
# Create a clone of the repository that does have a working tree.
# TODO Cloning of repository objects might deserve being a feature?
kw = dict((n, getattr(source_repo, n)) for n in ('release_scheme', 'release_filter', 'default_revision'))
cloned_repo = source_repo.__class__(
author="Peter Odding <vcs-repo-mgr@peterodding.com>",
local=create_temporary_directory(),
remote=source_repo.local,
bare=False, **kw
)
cloned_repo = self.clone_repo(source_repo, bare=False)
# Make sure the clone doesn't exist yet.
assert not cloned_repo.exists
# Create the clone.
Expand All @@ -345,6 +338,20 @@ def check_working_tree_support(self, source_repo, file_to_change='setup.py'):
self.check_checkout_support(cloned_repo)
self.check_commit_support(cloned_repo)
self.check_branch_support(cloned_repo)
self.check_merge_up_support(cloned_repo)

def clone_repo(self, repository, **kw):
"""Clone a repository object."""
# TODO Cloning of repository objects might deserve being a feature?
properties = 'bare', 'default_revision', 'release_scheme', 'release_filter'
options = dict((n, getattr(repository, n)) for n in properties)
options.update(kw)
return repository.__class__(
author="Peter Odding <vcs-repo-mgr@peterodding.com>",
local=create_temporary_directory(),
remote=repository.local,
**options
)

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

def check_merge_up_support(self, repository, num_branches=5):
"""Make sure we can merge changes up through release branches."""
logger.info("Testing merge_up() support ..")
try:
# Clone the repository with a custom release scheme/filter.
repository = self.clone_repo(
repository, bare=False,
release_scheme='branches',
release_filter='^v(\d*)$',
)
# Pick a directory name of which we can reasonably expect that
# no existing repository will already contain this directory.
unique_directory = 'vcs-repo-mgr-merge-up-support-%s' % random_string()
absolute_directory = os.path.join(repository.local, unique_directory)
# Create the release branches.
previous_branch = repository.current_branch
for i in range(1, num_branches + 1):
branch_name = 'v%i' % i
repository.checkout(revision=previous_branch)
repository.create_branch(branch_name)
if not os.path.isdir(absolute_directory):
os.mkdir(absolute_directory)
with open(os.path.join(absolute_directory, branch_name), 'w') as handle:
handle.write("Version %i\n" % i)
repository.add_files(all=True)
repository.commit(message="Create release branch %s" % branch_name)
previous_branch = branch_name
# Create a feature branch based on the initial release branch.
feature_branch = 'vcs-repo-mgr-feature-branch-%s' % random_string()
repository.checkout('v1')
repository.create_branch(feature_branch)
with open(os.path.join(absolute_directory, 'v1'), 'w') as handle:
handle.write("Version 1.1\n")
repository.commit(message="Fixed a bug in version 1")
assert feature_branch in repository.branches
# Merge the change up into the release branches.
expected_revision = repository.find_revision_id(revision=feature_branch)
merged_revision = repository.merge_up(target_branch='v1', feature_branch=feature_branch)
assert merged_revision == expected_revision
# Make sure the feature branch was closed.
assert feature_branch not in repository.branches
# Validate the contents of the default branch.
repository.checkout()
entries = os.listdir(absolute_directory)
assert all('v%i' % i in entries for i in range(1, num_branches + 1))
except NotImplementedError as e:
logger.warning("%s", e)

def mutate_working_tree(self, repository):
"""Mutate an arbitrary tracked file in the repository's working tree."""
vcs_directory = os.path.abspath(repository.vcs_directory)
Expand Down

0 comments on commit 59bb7c7

Please sign in to comment.