← Back to team overview

yellow team mailing list archive

[Merge] lp:~frankban/lpsetup/bug-1023895-init-repo-no-checkout into lp:lpsetup

 

Francesco Banconi has proposed merging lp:~frankban/lpsetup/bug-1023895-init-repo-no-checkout into lp:lpsetup.

Requested reviews:
  Yellow Squad (yellow)
Related bugs:
  Bug #1023895 in lpsetup: "lpsetup: add --no-checkout option to init-repo"
  https://bugs.launchpad.net/lpsetup/+bug/1023895

For more details, see:
https://code.launchpad.net/~frankban/lpsetup/bug-1023895-init-repo-no-checkout/+merge/115108

== Changes ==


*handle_directories* now handles relative paths. E.g. it is possible to use a relative path for the target repository *init-repo* command line argument. Also updated the relevant TestCase.


Added the *handle_branch_and_checkout* handler: it validates branch and checkout names.


s/CHECKOUT_DIR/LP_REPOSITORY_DIR everywhere.


Added `--no-checkout` option to *init-repo*: it creates a "normal" non-lightweight checkout.


The runtime errors that can be generated by `bzr` calls in *init-repo* are now handled and raise an *ExecutionError*. To avoid race conditions this error handling is done by the step itself and not by the handlers.
The *fetch()* step now tries a lot: it contains 3 similar try/except blocks that could be abstracted, for instance, with a code like this::

    def bzrcall(args, error=''):
        """Execute bzr passing *args*.

        If the bzr command fails, raise an ExecutionError containing the given
        *error* + the original bzr failure message.
        """
        cmd = ['bzr'] + list(args)
        try:
            call(*cmd)
        except subprocess.CalledProcessError as err:
            raise exceptions.ExecutionError(error + err.output)
        
I decided to leave the explicit calls in *fetch()*: please feel free to suggest another approach.


Fixed *setup_bzr_locations*: now the step correctly handles a custom branch name that can be passed as an argument.


Implemented an integration TestCase for *init-repo*. The tests create a fake test branch and use it as source bzr repository. The template directory used to create the fake branch is placed in `lpsetup/tests/test-branch/`: right now it contains only a test file, but in the future it can be used as a "mocked" Launchpad branch. 3 tests are currently implemented: init-repo with tree, without tree, and bazaar location checks.


Created a *BackupFile* context manager, useful to backup and restore a single file: in this branch it is used to preserve bazaar's `locations.conf`.


Added tests for the *capture* context manager.


Added the helper function *create_test_branch()*: create a temporary fake test branch.


`setup.cfg` excludes *create_test_branch* and includes *utils*.
-- 
https://code.launchpad.net/~frankban/lpsetup/bug-1023895-init-repo-no-checkout/+merge/115108
Your team Yellow Squad is requested to review the proposed merge of lp:~frankban/lpsetup/bug-1023895-init-repo-no-checkout into lp:lpsetup.
=== modified file 'lpsetup/handlers.py'
--- lpsetup/handlers.py	2012-07-13 16:01:13 +0000
+++ lpsetup/handlers.py	2012-07-16 10:50:29 +0000
@@ -234,9 +234,8 @@
             err = ("argument directory cannot contain "
                    "spaces: '{0}'".format(directory))
             raise ValidationError(err)
-
-        directory = directory.replace('~', namespace.home_dir)
-        directory = os.path.abspath(directory)
+        directory = os.path.abspath(
+            directory.replace('~', namespace.home_dir))
         if not directory.startswith(namespace.home_dir + os.path.sep):
             raise ValidationError(
                 'argument {0} does not reside under the home '
@@ -279,3 +278,33 @@
 def handle_working_dir(namespace):
     """Handle path to the working directory."""
     namespace.working_dir = normalize_path(namespace.working_dir)
+
+
+def handle_branch_and_checkout(namespace):
+    """Handle branch and checkout names.
+
+    Raise *ValidationError* if::
+
+    - branch/checkout name is '.' or '..'
+    - branch/checkout name contains '/'
+    - lightweight checkout is used and branch and checkout have the same name
+
+    The namespace must contain the following names::
+
+        - branch_name
+        - checkout_name
+
+    This handler does not modify the namespace.
+    """
+    branch_name = namespace.branch_name
+    checkout_name = namespace.checkout_name
+    for name in (branch_name, checkout_name):
+        if name in ('.', '..') or '/' in name:
+            raise ValidationError(
+                'invalid name "{0}": branch or checkout names can not '
+                'be "." or ".." and can not contain "/"'.format(name))
+    is_lightweight = not getattr(namespace, 'no_checkout', False)
+    if is_lightweight and (checkout_name == branch_name):
+        raise ValidationError(
+            'branch and checkout: can not use the same name ({0}).'.format(
+                checkout_name))

=== modified file 'lpsetup/settings.py'
--- lpsetup/settings.py	2012-07-11 15:47:34 +0000
+++ lpsetup/settings.py	2012-07-16 10:50:29 +0000
@@ -15,7 +15,6 @@
     )
 # a2enmod requires apache2.2-common
 BASE_PACKAGES = ['ssh', 'bzr', 'apache2.2-common']
-CHECKOUT_DIR = '~/launchpad/lp-branches'
 DEPENDENCIES_DIR = '~/launchpad/lp-sourcedeps'
 HOSTS_CONTENT = (
     ('127.0.0.88',
@@ -39,22 +38,23 @@
     )
 LP_BZR_LOCATIONS = {
     '{repository}': {
-        'submit_branch': '{checkout_dir}',
+        'submit_branch': '{branch_dir}',
         'public_branch': 'bzr+ssh://bazaar.launchpad.net/~{lpuser}/launchpad',
         'public_branch:policy': 'appendpath',
         'push_location': 'lp:~{lpuser}/launchpad',
         'push_location:policy': 'appendpath',
-        'merge_target': '{checkout_dir}',
+        'merge_target': '{branch_dir}',
         'submit_to': 'merge@xxxxxxxxxxxxxxxxxx',
         },
-    '{checkout_dir}': {
+    '{branch_dir}': {
         'public_branch':
             'bzr+ssh://bazaar.launchpad.net/~launchpad-pqm/launchpad/devel',
         }
     }
 LP_BRANCH_NAME = 'devel'
 LP_CHECKOUT_NAME = 'sandbox'
-LP_CODE_DIR = os.path.join(CHECKOUT_DIR, LP_CHECKOUT_NAME)
+LP_REPOSITORY_DIR = '~/launchpad/lp-branches'
+LP_CODE_DIR = os.path.join(LP_REPOSITORY_DIR, LP_CHECKOUT_NAME)
 LP_PACKAGES = [
     # "launchpad-database-dependencies-9.1" can be removed once 8.x is
     # no longer an option in "launchpad-developer-dependencies.

=== modified file 'lpsetup/subcommands/initrepo.py'
--- lpsetup/subcommands/initrepo.py	2012-07-12 18:23:37 +0000
+++ lpsetup/subcommands/initrepo.py	2012-07-16 10:50:29 +0000
@@ -19,16 +19,20 @@
     ]
 
 import os
+import subprocess
 
 from shelltoolbox import mkdirs
 
-from lpsetup import argparser
-from lpsetup import handlers
+from lpsetup import (
+    argparser,
+    exceptions,
+    handlers,
+    )
 from lpsetup.settings import (
-    CHECKOUT_DIR,
     LP_BRANCH_NAME,
     LP_BZR_LOCATIONS,
     LP_CHECKOUT_NAME,
+    LP_REPOSITORY_DIR,
     )
 from lpsetup.utils import (
     call,
@@ -37,23 +41,42 @@
     )
 
 
-def fetch(source, repository, branch_name, checkout_name):
+def fetch(source, repository, branch_name, checkout_name, no_checkout):
     """Create a repo for the Launchpad code and retrieve it."""
     # XXX should we warn if root?
     # Set up the repository.
     mkdirs(repository)
-    call('bzr', 'init-repo', repository, '--no-trees')
+    is_lightweight = not no_checkout
+    no_trees = '--no-trees' if is_lightweight else None
+    # Initialize the repository.
+    try:
+        call('bzr', 'init-repo', '--quiet', repository, no_trees)
+    except subprocess.CalledProcessError as err:
+        msg = 'Error: unable to initialize the repository: '
+        raise exceptions.ExecutionError(msg + err.output)
     # Set up the codebase.
     branch_dir = os.path.join(repository, branch_name)
-    checkout_dir = os.path.join(repository, checkout_name)
-    call('bzr', 'branch', source, branch_dir)
-    call('bzr', 'co', '--lightweight', branch_dir, checkout_dir)
-
-
-def setup_bzr_locations(lpuser, repository, template=LP_BZR_LOCATIONS):
+    # Retrieve the branch.
+    try:
+        call('bzr', 'branch', source, branch_dir)
+    except subprocess.CalledProcessError as err:
+        msg = 'Error: unable to branch source: '
+        raise exceptions.ExecutionError(msg + err.output)
+    if is_lightweight:
+        checkout_dir = os.path.join(repository, checkout_name)
+        # Create a lightweight checkout.
+        try:
+            call('bzr', 'co', '--lightweight', branch_dir, checkout_dir)
+        except subprocess.CalledProcessError as err:
+            msg = 'Error: unable to create the lightweight checkout: '
+            raise exceptions.ExecutionError(msg + err.output)
+
+
+def setup_bzr_locations(
+    lpuser, repository, branch_name, template=LP_BZR_LOCATIONS):
     """Set up bazaar locations."""
     context = {
-        'checkout_dir': os.path.join(repository, LP_BRANCH_NAME),
+        'branch_dir': os.path.join(repository, branch_name),
         'repository': repository,
         'lpuser': lpuser,
         }
@@ -78,8 +101,10 @@
     """Get the Launchpad code and dependent source code."""
 
     steps = (
-        (fetch, 'source', 'repository', 'branch_name', 'checkout_name'),
-        (setup_bzr_locations, 'lpuser', 'repository'),
+        (fetch,
+         'source', 'repository', 'branch_name', 'checkout_name',
+         'no_checkout'),
+        (setup_bzr_locations, 'lpuser', 'repository', 'branch_name'),
         )
 
     help = __doc__
@@ -87,6 +112,7 @@
         handlers.handle_user,
         handlers.handle_lpuser_from_lplogin,
         handlers.handle_directories,
+        handlers.handle_branch_and_checkout,
         handlers.handle_source,
         )
 
@@ -107,14 +133,19 @@
                 'Defaults to {0}.'.format(LP_BRANCH_NAME))
         parser.add_argument(
             '--checkout-name', default=LP_CHECKOUT_NAME,
-            help='Create a checkout with the given name.  Defaults to {0}.'
-                .format(LP_CHECKOUT_NAME))
+            help='Create a checkout with the given name. '
+                 'Ignored if --no-checkout is specified. '
+                 'Defaults to {0}.'.format(LP_CHECKOUT_NAME))
         parser.add_argument(
-            '-r', '--repository', default=CHECKOUT_DIR,
-            help='The directory of the Launchpad repository to be updated. '
+            '-r', '--repository', default=LP_REPOSITORY_DIR,
+            help='The directory of the Launchpad repository to be created. '
                  'The directory must reside under the home directory of the '
                  'given user (see -u argument). '
-                 '[DEFAULT={0}]'.format(CHECKOUT_DIR))
+                 '[DEFAULT={0}]'.format(LP_REPOSITORY_DIR))
+        parser.add_argument(
+            '--no-checkout', action='store_true', dest='no_checkout',
+            default=False, help='Initialize a bzr repository with trees and '
+            'do not create a lightweight checkout.')
 
     def add_arguments(self, parser):
         super(SubCommand, self).add_arguments(parser)

=== modified file 'lpsetup/tests/subcommands/test_initrepo.py'
--- lpsetup/tests/subcommands/test_initrepo.py	2012-07-05 18:38:51 +0000
+++ lpsetup/tests/subcommands/test_initrepo.py	2012-07-16 10:50:29 +0000
@@ -4,39 +4,52 @@
 
 """Tests for the initrepo subcommand."""
 
+import getpass
+import os
+import shutil
+import tempfile
 import unittest
 
-from lpsetup import handlers
+from lpsetup import (
+    cli,
+    handlers,
+    )
 from lpsetup.subcommands import initrepo
 from lpsetup.tests.utils import (
+    BackupFile,
+    create_test_branch,
     get_random_string,
     StepsBasedSubCommandTestMixin,
     )
+from lpsetup.utils import ConfigParser
 
 
 fetch_step = (initrepo.fetch,
-    ['source', 'repository', 'branch_name', 'checkout_name'])
+    ['source', 'repository', 'branch_name', 'checkout_name', 'no_checkout'])
 setup_bzr_locations_step = (initrepo.setup_bzr_locations,
-                            ['lpuser', 'repository'])
+    ['lpuser', 'repository', 'branch_name'])
 
 
 def get_arguments():
     return (
         '--source', get_random_string(),
+        '--repository', get_random_string(),
         '--branch-name', get_random_string(),
         '--checkout-name', get_random_string(),
+        '--no-checkout',
         )
 
 
 class InitrepoTest(StepsBasedSubCommandTestMixin, unittest.TestCase):
 
-    sub_command_name = 'initrepo'
+    sub_command_name = 'init-repo'
     sub_command_class = initrepo.SubCommand
     expected_arguments = get_arguments()
     expected_handlers = (
         handlers.handle_user,
         handlers.handle_lpuser_from_lplogin,
         handlers.handle_directories,
+        handlers.handle_branch_and_checkout,
         handlers.handle_source,
         )
     expected_steps = (
@@ -44,3 +57,100 @@
         setup_bzr_locations_step,
         )
     needs_root = False
+
+
+class IntegrationTest(unittest.TestCase):
+
+    def setUp(self):
+        """Create a fake source bzr branch under `/tmp/`."""
+        # Create the source branch and schedule removing.
+        self.source = create_test_branch()
+        self.addCleanup(shutil.rmtree, self.source)
+        # The target repository (containing the branch and the checkout)
+        # must be placed inside the user home, otherwise init-repo exits
+        # with a ValidationError.
+        home = os.path.expanduser('~')
+        self.repository = tempfile.mktemp(dir=home)
+        branch_name = 'branch-' + get_random_string()
+        checkout_name = 'checkout-' + get_random_string()
+        self.cmd = (
+            'init-repo',
+            '--source', self.source,
+            '--repository', self.repository,
+            '--branch-name', branch_name,
+            '--checkout-name', checkout_name,
+            )
+        self.branch = os.path.join(self.repository, branch_name)
+        self.checkout = os.path.join(self.repository, checkout_name)
+        # The command init-repo updates `~/.bazaar/locations.conf`:
+        # the tests need to preserve that file.
+        self.bazaar_locations = os.path.join(
+            home, '.bazaar', 'locations.conf')
+
+    def tearDown(self):
+        """Remove the target repository."""
+        if os.path.isdir(self.repository):
+            shutil.rmtree(self.repository)
+
+    def assertIsBranch(self, path, no_tree=False):
+        """Assert the given *path* is a valid bzr branch.
+
+        If *no_tree* is True, also check the *path* does not contain
+        a working tree.
+        If *no_tree* is False, also check the *path* contains the test file.
+        """
+        if not os.path.isdir(path):
+            self.fail(path + ' is not a directory')
+        contents = os.listdir(path)
+        if '.bzr' not in contents:
+            self.fail(path + ' is not a bzr repository')
+        if no_tree:
+            if len(contents) > 1:
+                self.fail(path + ' contains a working tree')
+        elif 'test-file' not in contents:
+            self.fail(path + ' does not contain a working tree')
+
+    def test_no_trees(self):
+        # Ensure a lightweight checkout is correctly created by init-repo.
+        with BackupFile(self.bazaar_locations):
+            retcode = cli.main(self.cmd)
+        self.assertIsNone(retcode)
+        # The branch is created without tree.
+        self.assertIsBranch(self.branch, no_tree=True)
+        # The checkout is created with tree.
+        self.assertIsBranch(self.checkout, no_tree=False)
+
+    def test_with_trees(self):
+        # Ensure a "normal" branch is correctly created by init-repo.
+        with BackupFile(self.bazaar_locations):
+            retcode = cli.main(self.cmd + ('--no-checkout',))
+        self.assertIsNone(retcode)
+        # The branch is created with tree.
+        self.assertIsBranch(self.branch, no_tree=False)
+
+    def test_bazaar_locations(self):
+        # Ensure the file `~/.bazaar/locations.conf` is correctly updated.
+        # Assume lpuser is the current user.
+        user = getpass.getuser()
+        expected_locations = {
+            self.repository: {
+                'merge_target': self.branch,
+                'public_branch': 'bzr+ssh://bazaar.launchpad.net/~{}/'
+                                 'launchpad'.format(user),
+                'public_branch:policy': 'appendpath',
+                'push_location': 'lp:~{}/launchpad'.format(user),
+                'push_location:policy': 'appendpath',
+                'submit_branch': self.branch,
+                'submit_to': 'merge@xxxxxxxxxxxxxxxxxx',
+                },
+            self.branch: {
+                'public_branch': 'bzr+ssh://bazaar.launchpad.net/'
+                                 '~launchpad-pqm/launchpad/devel',
+                },
+            }
+        with BackupFile(self.bazaar_locations) as cm:
+            cli.main(self.cmd)
+            parser = ConfigParser()
+            parser.read(cm.path)
+            for section, options in expected_locations.items():
+                self.assertItemsEqual(options.items(), parser.items(section))

=== added directory 'lpsetup/tests/test-branch'
=== added file 'lpsetup/tests/test-branch/test-file'
=== modified file 'lpsetup/tests/test_handlers.py'
--- lpsetup/tests/test_handlers.py	2012-07-09 21:18:21 +0000
+++ lpsetup/tests/test_handlers.py	2012-07-16 10:50:29 +0000
@@ -7,11 +7,13 @@
 import argparse
 from contextlib import contextmanager
 import getpass
+import os
 import pwd
 import unittest
 
 from lpsetup.exceptions import ValidationError
 from lpsetup.handlers import (
+    handle_branch_and_checkout,
     handle_directories,
     handle_lpuser_as_username,
     handle_ssh_keys,
@@ -61,9 +63,48 @@
                 raise TypeError
 
 
+class HandleBranchAndCheckoutTest(HandlersTestMixin, unittest.TestCase):
+
+    def test_valid_arguments(self):
+        # The validation succeed if there are no conflicts in arguments.
+        namespace = argparse.Namespace(
+            branch_name='branch',
+            checkout_name='checkout')
+        handle_branch_and_checkout(namespace)
+
+    def test_checkout_equals_branch(self):
+        # The validation fails if checkout and branch have the same name.
+        namespace = argparse.Namespace(
+            branch_name='branch',
+            checkout_name='branch')
+        with self.assertNotValid('checkout'):
+            handle_branch_and_checkout(namespace)
+
+    def test_checkout_equals_branch_without_trees(self):
+        # The validation succeed if checkout and branch have the same name
+        # but `--no-checkout` is specified.
+        namespace = argparse.Namespace(
+            branch_name='branch',
+            checkout_name='branch',
+            no_checkout=True)
+        handle_branch_and_checkout(namespace)
+
+    def test_invalid_names(self):
+        # The validation fails if the branch/checkout name is '.' or '..',
+        # or if it contains '/'.
+        for kwargs in (
+            {'branch_name': '.', 'checkout_name': 'checkout'},
+            {'branch_name': 'branch', 'checkout_name': '..'},
+            {'branch_name': '../', 'checkout_name': 'checkout'},
+            {'branch_name': 'branch', 'checkout_name': 'check/out'},
+            ):
+            with self.assertNotValid('invalid name'):
+                handle_branch_and_checkout(argparse.Namespace(**kwargs))
+
+
 class HandleDirectoriesTest(HandlersTestMixin, unittest.TestCase):
 
-    home_dir = '/home/foo'
+    home_dir = os.path.expanduser('~')
     dependencies_dir = '~/launchpad/deps'
 
     def test_home_is_expanded(self):
@@ -72,9 +113,12 @@
             repository='~/launchpad', home_dir=self.home_dir,
             dependencies_dir=self.dependencies_dir)
         handle_directories(namespace)
-        self.assertEqual('/home/foo/launchpad', namespace.repository)
-        self.assertEqual(
-            '/home/foo/launchpad/deps', namespace.dependencies_dir)
+        self.assertEqual(
+            os.path.join(self.home_dir, 'launchpad'),
+            namespace.repository)
+        self.assertEqual(
+            os.path.join(self.home_dir, 'launchpad', 'deps'),
+            namespace.dependencies_dir)
 
     def test_directory_not_in_home(self):
         # The validation fails for directories not residing inside the home.
@@ -90,6 +134,27 @@
         with self.assertNotValid('directory'):
             handle_directories(namespace)
 
+    def test_relative_path(self):
+        # Ensure relative paths are correctly expanded.
+        namespace = argparse.Namespace(
+            repository='launchpad', home_dir=self.home_dir,
+            dependencies_dir='launchpad/../deps')
+        handle_directories(namespace)
+        cwd = os.getcwd()
+        self.assertEqual(
+            os.path.join(cwd, 'launchpad'),
+            namespace.repository)
+        self.assertEqual(
+            os.path.join(cwd, 'deps'),
+            namespace.dependencies_dir)
+
+    def test_invalid_relative_path(self):
+        # The validation fails if a relative path points outside the home.
+        namespace = argparse.Namespace(
+            repository='~/../', home_dir=self.home_dir)
+        with self.assertNotValid('repository'):
+            handle_directories(namespace)
+
 
 class HandleLPUserTest(unittest.TestCase):
 

=== modified file 'lpsetup/tests/utils.py'
--- lpsetup/tests/utils.py	2012-06-27 09:20:50 +0000
+++ lpsetup/tests/utils.py	2012-07-16 10:50:29 +0000
@@ -15,13 +15,141 @@
 
 from contextlib import contextmanager
 from functools import partial
+import os
 import random
+import shutil
 import string
 from StringIO import StringIO
 import sys
+import tempfile
+import unittest
+
+from shelltoolbox import run
 
 from lpsetup import argparser
 from lpsetup.tests import examples
+from lpsetup.utils import call
+
+
+class BackupFile(object):
+    """A context manager to backup and restore a file.
+
+    E.g.::
+
+        with Backup('/path/to/file') as cm:
+            cm.exists  # True if the file to backup actually exists
+            cm.original  # the place where original file is temporarily stored
+            cm.path  # the given file path
+
+    If the file exists, it is restored when the context manager exits.
+    Otherwise, if created during cm execution, it is deleted exiting.
+    """
+    def __init__(self, path):
+        self.path = path
+        self.original = tempfile.mktemp()
+        self.exists = False
+        self.saved = False
+        self.restored = False
+
+    def __enter__(self):
+        """Backup the original path."""
+        self.backup()
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        """Restore the original path."""
+        self.restore()
+
+    def backup(self):
+        """Actually copy the file in a temporary position if it exists."""
+        self.exists = os.path.exists(self.path)
+        if not self.saved and self.exists:
+            shutil.copy2(self.path, self.original)
+        self.saved = True
+
+    def restore(self):
+        """Restore the file in the original position if it existed."""
+        if self.saved and not self.restored:
+            if os.path.exists(self.path):
+                os.remove(self.path)
+            if self.exists:
+                shutil.move(self.original, self.path)
+        self.restored = True
+
+
+class BackupFileTest(unittest.TestCase):
+    """Tests for the *BackupFile* context manager."""
+
+    contents = 'contents'
+
+    def temp_file(self):
+        f = tempfile.NamedTemporaryFile()
+        f.write(self.contents)
+        f.flush()
+        return f
+
+    def test_existent_file_is_restored(self):
+        # Ensure an existing file is correctly restored by the cm.
+        with self.temp_file() as f:
+            with BackupFile(f.name) as cm:
+                self.assertTrue(cm.exists)
+                f.write('new contents')
+                f.close()
+            self.assertEqual(self.contents, open(f.name).read())
+
+    def test_non_existent_file_is_deleted(self):
+        # Ensure a non existent file, if created, is then deleted by the cm.
+        path = tempfile.mktemp()
+        with BackupFile(path) as cm:
+            self.assertFalse(cm.exists)
+            with open(path, 'w') as f:
+                f.write('contents')
+        self.assertFalse(os.path.exists(path))
+
+    def test_unexisting_file_not_created(self):
+        # Ensure nothing happen when a non existent file is not created
+        # during cm execution.
+        path = tempfile.mktemp()
+        with BackupFile(path):
+            pass
+        self.assertFalse(os.path.exists(path))
+
+    def test_saved_restored_flags(self):
+        # Ensure the context manager correctly store his state.
+        with BackupFile(tempfile.mktemp()) as cm:
+            self.assertTrue(cm.saved)
+            self.assertFalse(cm.restored)
+        self.assertTrue(cm.restored)
+
+    def test_multiple_brackup(self):
+        # Ensure backup is called only one time.
+        with self.temp_file() as f:
+            with BackupFile(f.name) as cm:
+                f.write('new contents')
+                f.flush()
+                # Try to execute the backup a second time.
+                cm.backup()
+                # And ensure the original contents are not modified.
+                self.assertEqual(self.contents, open(cm.original).read())
+
+    def test_multiple_restore(self):
+        # Ensure backup is called only one time.
+        with self.temp_file() as f:
+            with BackupFile(f.name) as cm:
+                pass
+            # Re-create the backup file with different contents.
+            with open(cm.original, 'w') as original:
+                original.write('new contents')
+            # Ensure another call to restore does not change the target file.
+            cm.restore()
+            self.assertEqual(self.contents, open(f.name).read())
+
+    def test_backup_is_deleted(self):
+        # Ensure the backup file is deleted when the context manager exits.
+        with self.temp_file() as f:
+            with BackupFile(f.name) as cm:
+                pass
+        self.assertFalse(os.path.exists(cm.original))
 
 
 @contextmanager
@@ -39,6 +167,60 @@
 capture_error = partial(capture, 'stderr')
 
 
+class CaptureTest(unittest.TestCase):
+    """Tests for the *capture* context manager."""
+
+    message = 'message'
+
+    def test_capture_output(self):
+        with capture_output() as stream:
+            print self.message
+        self.assertEqual(self.message + '\n', stream.getvalue())
+
+    def test_capture_error(self):
+        with capture_error() as stream:
+            print >> sys.stderr, self.message
+        self.assertEqual(self.message + '\n', stream.getvalue())
+
+
+def create_test_branch(template_dir=None):
+    """Create a temporary test branch containing files in *template_dir*.
+
+    If *template_dir* is None, the files in `lpsetup/tests/test-branch`
+    are used.
+
+    Return the path of the newly created branch.
+    """
+    if template_dir is None:
+        template_dir = os.path.join(os.path.dirname(__file__), 'test-branch')
+    branch_path = tempfile.mktemp()
+    shutil.copytree(template_dir, branch_path)
+    call('bzr', 'init', '--quiet', branch_path)
+    call('bzr', 'add', '--quiet', branch_path)
+    call('bzr', 'commit', branch_path, '-m', 'Initial commit.')
+    return branch_path
+
+
+class CreateTestBranchTest(unittest.TestCase):
+    """Tests for the *create_test_branch* helper function."""
+
+    def setUp(self):
+        self.branch_path = create_test_branch()
+
+    def tearDown(self):
+        shutil.rmtree(self.branch_path)
+
+    def test_revno(self):
+        # Ensure the bzr branch is correctly created and contains a revision.
+        revno = run('bzr', 'revno', self.branch_path)
+        self.assertEqual(1, int(revno.strip()))
+
+    def test_working_tree(self):
+        # Ensure the working tree is present in the newly created branch.
+        test_file_path = os.path.join(self.branch_path, 'test-file')
+        self.assertTrue(os.path.exists(test_file_path))
+
+
 def get_random_string(size=10):
     """Return a random string to be used in tests."""
     return ''.join(random.sample(string.ascii_letters, size))

=== modified file 'setup.cfg'
--- setup.cfg	2012-07-11 18:37:31 +0000
+++ setup.cfg	2012-07-16 10:50:29 +0000
@@ -1,10 +1,11 @@
 [nosetests]
+# Specifying 'where' should not be required but it is a work-around
+# for a problem presented by having 'ignore-files'.
+where=lpsetup
+exclude=handle_testing|create_test_branch
+include=utils
 detailed-errors=1
-exclude=handle_testing
 with-coverage=1
 cover-package=lpsetup
 with-doctest=1
-# Specifying 'where' should not be required but it is a work-around
-# for a problem presented by having 'ignore-files'.
-where=lpsetup
 ignore-files=disabled*