Repository: cookiecutter/cookiecutter

Issue:
Unhelpful error if template branch is specified incorrectly
- Cookiecutter version: 1.4.0
- Template project url: https://github.com/pybee/Python-Android-template
- Python version: 2.7
- Operating System: macOS
### Description:

If you specify a template project URL that exists, but a branch that does _not_ exist, the `git clone` command fails, raising an exception.

This is entirely expected. However, the error message that is raised isn't especially helpful - it's a simple dump of the subprocess failure, rather than something pointing at the underlying problem.
### What I've run:

The problem is easy to reproduce - specify a valid template URL, but a branch name that doesn't exist. In this example, I'm using Python-Android-template, with the non-existent 2.7 branch.

```
$ cookiecutter https://github.com/pybee/Python-Android-template --checkout 2.7
You've cloned /Users/rkm/.cookiecutters/Python-Android-template before. Is it okay to delete and re-clone it? [yes]: 
Cloning into 'Python-Android-template'...
remote: Counting objects: 174, done.
remote: Total 174 (delta 0), reused 0 (delta 0), pack-reused 174
Receiving objects: 100% (174/174), 75.87 KiB | 0 bytes/s, done.
Resolving deltas: 100% (64/64), done.
Checking connectivity... done.
error: pathspec '2.7' did not match any file(s) known to git.
Traceback (most recent call last):
  File "setup.py", line 56, in <module>
    test_suite='tests'
  File "/usr/local/Cellar/python/2.7.9/Frameworks/Python.framework/Versions/2.7/lib/python2.7/distutils/core.py", line 151, in setup
    dist.run_commands()
  File "/usr/local/Cellar/python/2.7.9/Frameworks/Python.framework/Versions/2.7/lib/python2.7/distutils/dist.py", line 953, in run_commands
    self.run_command(cmd)
  File "/usr/local/Cellar/python/2.7.9/Frameworks/Python.framework/Versions/2.7/lib/python2.7/distutils/dist.py", line 972, in run_command
    cmd_obj.run()
  File "/Users/rkm/projects/beeware/briefcase/briefcase/app.py", line 193, in run
    self.generate_app_template()
  File "/Users/rkm/projects/beeware/briefcase/briefcase/app.py", line 110, in generate_app_template
    'month': date.today().strftime('%B'),
  File "/Users/rkm/.virtualenvs/briefcase/lib/python2.7/site-packages/cookiecutter/main.py", line 107, in cookiecutter
    no_input=no_input
  File "/Users/rkm/.virtualenvs/briefcase/lib/python2.7/site-packages/cookiecutter/vcs.py", line 122, in clone
    cwd=repo_dir)
  File "/usr/local/Cellar/python/2.7.9/Frameworks/Python.framework/Versions/2.7/lib/python2.7/subprocess.py", line 540, in check_call
    raise CalledProcessError(retcode, cmd)
subprocess.CalledProcessError: Command '[u'git', u'checkout', '2.7']' returned non-zero exit status 1
```

The message `error: pathspec '2.7' did not match any file(s) known to git.` is contained higher up in the stack trace; a public facing error message that said this would be more helpful than details about non-zero exit status of the subprocess. 

I've also tried invoking this directly via the API:

```
from cookiecutter.main import cookiecutter
cookiecutter(
        'https://github.com/pybee/Python-Android-template',
        no_input=True,
        checkout='%s.%s' % (sys.version_info.major, sys.version_info.minor),
        extra_context={
            'app_name': self.distribution.get_name(),
            'formal_name': self.formal_name,
            'organization_name': self.organization_name,
            'dir_name': self.dir,
            'bundle': self.bundle,
            'year': date.today().strftime('%Y'),
            'month': date.today().strftime('%B'),
        }
    )
```

Again, this raises a subprocess exception, rather than something more meaningful that could be caught and reported.



Discussion:

Resolving patch:
diff --git a/cookiecutter/cli.py b/cookiecutter/cli.py
--- a/cookiecutter/cli.py
+++ b/cookiecutter/cli.py
@@ -23,7 +23,8 @@
     FailedHookException,
     UndefinedVariableInTemplate,
     UnknownExtension,
-    RepositoryNotFound
+    RepositoryNotFound,
+    RepositoryCloneFailed
 )
 
 logger = logging.getLogger(__name__)
@@ -124,7 +125,8 @@ def main(template, extra_context, no_input, checkout, verbose, replay,
             InvalidModeException,
             FailedHookException,
             UnknownExtension,
-            RepositoryNotFound) as e:
+            RepositoryNotFound,
+            RepositoryCloneFailed) as e:
         click.echo(e)
         sys.exit(1)
     except UndefinedVariableInTemplate as undefined_err:
diff --git a/cookiecutter/exceptions.py b/cookiecutter/exceptions.py
--- a/cookiecutter/exceptions.py
+++ b/cookiecutter/exceptions.py
@@ -113,3 +113,7 @@ class RepositoryNotFound(CookiecutterException):
     """
     Raised when the specified cookiecutter repository doesn't exist.
     """
+
+
+class RepositoryCloneFailed(CookiecutterException):
+    """Raised when a cookiecutter template can't be cloned."""
diff --git a/cookiecutter/vcs.py b/cookiecutter/vcs.py
--- a/cookiecutter/vcs.py
+++ b/cookiecutter/vcs.py
@@ -15,11 +15,19 @@
 
 from whichcraft import which
 
-from .exceptions import UnknownRepoType, VCSNotInstalled
+from .exceptions import (
+    RepositoryNotFound, RepositoryCloneFailed, UnknownRepoType, VCSNotInstalled
+)
 from .prompt import read_user_yes_no
 from .utils import make_sure_path_exists, rmtree
 
 
+BRANCH_ERRORS = [
+    'error: pathspec',
+    'unknown revision',
+]
+
+
 def prompt_and_delete_repo(repo_dir, no_input=False):
     """
     Asks the user whether it's okay to delete the previously-cloned repo.
@@ -49,10 +57,10 @@ def prompt_and_delete_repo(repo_dir, no_input=False):
 def identify_repo(repo_url):
     """
     Determines if `repo_url` should be treated as a URL to a git or hg repo.
-    Repos can be identified prepeding "hg+" or "git+" to repo URL.
+    Repos can be identified by prepending "hg+" or "git+" to the repo URL.
 
     :param repo_url: Repo URL of unknown type.
-    :returns: ("git", repo_url), ("hg", repo_url), or None.
+    :returns: ('git', repo_url), ('hg', repo_url), or None.
     """
     repo_url_values = repo_url.split('+')
     if len(repo_url_values) == 2:
@@ -62,10 +70,10 @@ def identify_repo(repo_url):
         else:
             raise UnknownRepoType
     else:
-        if "git" in repo_url:
-            return "git", repo_url
-        elif "bitbucket" in repo_url:
-            return "hg", repo_url
+        if 'git' in repo_url:
+            return 'git', repo_url
+        elif 'bitbucket' in repo_url:
+            return 'hg', repo_url
         else:
             raise UnknownRepoType
 
@@ -79,7 +87,7 @@ def is_vcs_installed(repo_type):
     return bool(which(repo_type))
 
 
-def clone(repo_url, checkout=None, clone_to_dir=".", no_input=False):
+def clone(repo_url, checkout=None, clone_to_dir='.', no_input=False):
     """
     Clone a repo to the current directory.
 
@@ -114,10 +122,29 @@ def clone(repo_url, checkout=None, clone_to_dir=".", no_input=False):
     if os.path.isdir(repo_dir):
         prompt_and_delete_repo(repo_dir, no_input=no_input)
 
-    if repo_type in ['git', 'hg']:
-        subprocess.check_call([repo_type, 'clone', repo_url], cwd=clone_to_dir)
+    try:
+        subprocess.check_output(
+            [repo_type, 'clone', repo_url],
+            cwd=clone_to_dir,
+            stderr=subprocess.STDOUT,
+        )
         if checkout is not None:
-            subprocess.check_call([repo_type, 'checkout', checkout],
-                                  cwd=repo_dir)
+            subprocess.check_output(
+                [repo_type, 'checkout', checkout],
+                cwd=repo_dir,
+                stderr=subprocess.STDOUT,
+            )
+    except subprocess.CalledProcessError as clone_error:
+        if 'not found' in clone_error.output.lower():
+            raise RepositoryNotFound(
+                'The repository {} could not be found, '
+                'have you made a typo?'.format(repo_url)
+            )
+        if any(error in clone_error.output for error in BRANCH_ERRORS):
+            raise RepositoryCloneFailed(
+                'The {} branch of repository {} could not found, '
+                'have you made a typo?'.format(checkout, repo_url)
+            )
+        raise
 
     return repo_dir


Test patch:
diff --git a/tests/test_identify_repo.py b/tests/test_identify_repo.py
deleted file mode 100644
--- a/tests/test_identify_repo.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""
-test_identify_repo
-------------------
-"""
-
-import pytest
-
-from cookiecutter import exceptions, vcs
-
-
-def test_identify_git_github():
-    repo_url = 'https://github.com/audreyr/cookiecutter-pypackage.git'
-    assert vcs.identify_repo(repo_url)[0] == 'git'
-
-
-def test_identify_git_github_no_extension():
-    repo_url = 'https://github.com/audreyr/cookiecutter-pypackage'
-    assert vcs.identify_repo(repo_url)[0] == 'git'
-
-
-def test_identify_git_gitorious():
-    repo_url = (
-        'git@gitorious.org:cookiecutter-gitorious/cookiecutter-gitorious.git'
-    )
-    assert vcs.identify_repo(repo_url)[0] == 'git'
-
-
-def test_identify_hg_mercurial():
-    repo_url = 'https://audreyr@bitbucket.org/audreyr/cookiecutter-bitbucket'
-    assert vcs.identify_repo(repo_url)[0] == 'hg'
-
-
-def test_unknown_repo_type():
-    repo_url = 'http://norepotypespecified.com'
-    with pytest.raises(exceptions.UnknownRepoType):
-        vcs.identify_repo(repo_url)[0]
diff --git a/tests/test_vcs.py b/tests/test_vcs.py
deleted file mode 100644
--- a/tests/test_vcs.py
+++ /dev/null
@@ -1,243 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""
-test_vcs
-------------
-
-Tests for `cookiecutter.vcs` module.
-"""
-
-import os
-import pytest
-
-from cookiecutter import exceptions, vcs
-
-
-@pytest.mark.parametrize('repo_url, exp_repo_type, exp_repo_url', [
-    (
-        "git+https://github.com/pytest-dev/cookiecutter-pytest-plugin.git",
-        "git",
-        "https://github.com/pytest-dev/cookiecutter-pytest-plugin.git"
-    ), (
-        "hg+https://bitbucket.org/foo/bar.hg",
-        "hg",
-        "https://bitbucket.org/foo/bar.hg"
-    ), (
-        "https://github.com/pytest-dev/cookiecutter-pytest-plugin.git",
-        "git",
-        "https://github.com/pytest-dev/cookiecutter-pytest-plugin.git"
-    ), (
-        "https://bitbucket.org/foo/bar.hg",
-        "hg",
-        "https://bitbucket.org/foo/bar.hg"
-    )
-])
-def test_identify_known_repo(repo_url, exp_repo_type, exp_repo_url):
-    assert vcs.identify_repo(repo_url) == (exp_repo_type, exp_repo_url)
-
-
-@pytest.fixture(params=[
-    "foo+git",  # uses explicit identifier with 'git' in the wrong place
-    "foo+hg",  # uses explicit identifier with 'hg' in the wrong place
-    "foo+bar",  # uses explicit identifier with neither 'git' nor 'hg'
-    "foobar"  # no identifier but neither 'git' nor 'bitbucket' in url
-])
-def unknown_repo_type_url(request):
-    return request.param
-
-
-def test_identify_raise_on_unknown_repo(unknown_repo_type_url):
-    with pytest.raises(exceptions.UnknownRepoType):
-        vcs.identify_repo(unknown_repo_type_url)
-
-
-def test_prompt_should_ask_and_rm_repo_dir(mocker, tmpdir):
-    """In `prompt_and_delete_repo()`, if the user agrees to delete/reclone the
-    repo, the repo should be deleted.
-    """
-    mock_read_user = mocker.patch(
-        'cookiecutter.vcs.read_user_yes_no',
-        return_value=True,
-        autospec=True
-    )
-    repo_dir = tmpdir.mkdir('repo')
-
-    vcs.prompt_and_delete_repo(str(repo_dir))
-
-    assert mock_read_user.called
-    assert not repo_dir.exists()
-
-
-def test_prompt_should_ask_and_keep_repo_dir(mocker, tmpdir):
-    """In `prompt_and_delete_repo()`, if the user wants to keep their old
-    cloned template repo, it should not be deleted.
-    """
-    mock_read_user = mocker.patch(
-        'cookiecutter.vcs.read_user_yes_no',
-        return_value=False,
-        autospec=True
-    )
-    repo_dir = tmpdir.mkdir('repo')
-
-    with pytest.raises(SystemExit):
-        vcs.prompt_and_delete_repo(str(repo_dir))
-
-    assert mock_read_user.called
-    assert repo_dir.exists()
-
-
-def test_prompt_should_not_ask_if_no_input_and_rm_repo_dir(mocker, tmpdir):
-    """In `prompt_and_delete_repo()`, if `no_input` is True, the call to
-    `vcs.read_user_yes_no()` should be suppressed.
-    """
-    mock_read_user = mocker.patch(
-        'cookiecutter.vcs.read_user_yes_no',
-        return_value=True,
-        autospec=True
-    )
-    repo_dir = tmpdir.mkdir('repo')
-
-    vcs.prompt_and_delete_repo(str(repo_dir), no_input=True)
-
-    assert not mock_read_user.called
-    assert not repo_dir.exists()
-
-
-@pytest.fixture
-def clone_dir(tmpdir):
-    """Simulates creation of a directory called `clone_dir` inside of `tmpdir`.
-    Returns a str to said directory.
-    """
-    return str(tmpdir.mkdir('clone_dir'))
-
-
-def test_clone_should_raise_if_vcs_not_installed(mocker, clone_dir):
-    """In `clone()`, a `VCSNotInstalled` exception should be raised if no VCS
-    is installed.
-    """
-    mocker.patch(
-        'cookiecutter.vcs.is_vcs_installed',
-        autospec=True,
-        return_value=False
-    )
-
-    repo_url = 'https://github.com/pytest-dev/cookiecutter-pytest-plugin.git'
-
-    with pytest.raises(exceptions.VCSNotInstalled):
-        vcs.clone(repo_url, clone_to_dir=clone_dir)
-
-
-@pytest.mark.parametrize('which_return, result', [
-    ('', False),
-    (None, False),
-    (False, False),
-    ('/usr/local/bin/git', True),
-])
-def test_is_vcs_installed(mocker, which_return, result):
-    mocker.patch(
-        'cookiecutter.vcs.which',
-        autospec=True,
-        return_value=which_return
-    )
-    assert vcs.is_vcs_installed('git') == result
-
-
-@pytest.mark.parametrize('repo_type, repo_url, repo_name', [
-    ('git', 'https://github.com/hello/world.git', 'world'),
-    ('hg', 'https://bitbucket.org/foo/bar', 'bar'),
-])
-def test_clone_should_invoke_git(
-        mocker, clone_dir, repo_type, repo_url, repo_name):
-    """When `clone()` is called with a git/hg repo, the corresponding VCS
-    command should be run via `subprocess.check_call()`.
-
-    This should take place:
-    * In the correct dir
-    * With the correct args.
-    """
-    mocker.patch(
-        'cookiecutter.vcs.is_vcs_installed',
-        autospec=True,
-        return_value=True
-    )
-
-    mock_subprocess = mocker.patch(
-        'cookiecutter.vcs.subprocess.check_call',
-        autospec=True,
-    )
-    expected_repo_dir = os.path.normpath(os.path.join(clone_dir, repo_name))
-
-    branch = 'foobar'
-
-    repo_dir = vcs.clone(
-        repo_url,
-        checkout=branch,
-        clone_to_dir=clone_dir,
-        no_input=True
-    )
-
-    assert repo_dir == expected_repo_dir
-
-    mock_subprocess.assert_any_call(
-        [repo_type, 'clone', repo_url], cwd=clone_dir
-    )
-    mock_subprocess.assert_any_call(
-        [repo_type, 'checkout', branch], cwd=expected_repo_dir
-    )
-
-
-def test_clone_should_abort_if_user_does_not_want_to_reclone(mocker, tmpdir):
-    """In `clone()`, if user doesn't want to reclone, Cookiecutter should exit
-    without cloning anything.
-    """
-    mocker.patch(
-        'cookiecutter.vcs.is_vcs_installed',
-        autospec=True,
-        return_value=True
-    )
-    mocker.patch(
-        'cookiecutter.vcs.prompt_and_delete_repo',
-        side_effect=SystemExit,
-        autospec=True
-    )
-    mock_subprocess = mocker.patch(
-        'cookiecutter.vcs.subprocess.check_call',
-        autospec=True,
-    )
-
-    clone_to_dir = tmpdir.mkdir('clone')
-
-    # Create repo_dir to trigger prompt_and_delete_repo
-    clone_to_dir.mkdir('cookiecutter-pytest-plugin')
-
-    repo_url = 'https://github.com/pytest-dev/cookiecutter-pytest-plugin.git'
-
-    with pytest.raises(SystemExit):
-        vcs.clone(repo_url, clone_to_dir=str(clone_to_dir))
-    assert not mock_subprocess.called
-
-
-def test_clone_should_rstrip_trailing_slash_in_repo_url(mocker, clone_dir):
-    """In `clone()`, repo URL's trailing slash should be stripped if one is
-    present.
-    """
-    mocker.patch(
-        'cookiecutter.vcs.is_vcs_installed',
-        autospec=True,
-        return_value=True
-    )
-
-    mock_subprocess = mocker.patch(
-        'cookiecutter.vcs.subprocess.check_call',
-        autospec=True,
-    )
-
-    vcs.clone(
-        'https://github.com/foo/bar/',
-        clone_to_dir=clone_dir,
-        no_input=True
-    )
-
-    mock_subprocess.assert_called_once_with(
-        ['git', 'clone', 'https://github.com/foo/bar'], cwd=clone_dir
-    )
diff --git a/tests/vcs/test_clone.py b/tests/vcs/test_clone.py
new file mode 100644
--- /dev/null
+++ b/tests/vcs/test_clone.py
@@ -0,0 +1,219 @@
+# -*- coding: utf-8 -*-
+import os
+import subprocess
+
+import pytest
+
+from cookiecutter import exceptions, vcs
+
+
+@pytest.fixture
+def clone_dir(tmpdir):
+    """Simulates creation of a directory called `clone_dir` inside of `tmpdir`.
+    Returns a str to said directory.
+    """
+    return str(tmpdir.mkdir('clone_dir'))
+
+
+def test_clone_should_raise_if_vcs_not_installed(mocker, clone_dir):
+    """In `clone()`, a `VCSNotInstalled` exception should be raised if no VCS
+    is installed.
+    """
+    mocker.patch(
+        'cookiecutter.vcs.is_vcs_installed',
+        autospec=True,
+        return_value=False
+    )
+
+    repo_url = 'https://github.com/pytest-dev/cookiecutter-pytest-plugin.git'
+
+    with pytest.raises(exceptions.VCSNotInstalled):
+        vcs.clone(repo_url, clone_to_dir=clone_dir)
+
+
+def test_clone_should_rstrip_trailing_slash_in_repo_url(mocker, clone_dir):
+    """In `clone()`, repo URL's trailing slash should be stripped if one is
+    present.
+    """
+    mocker.patch(
+        'cookiecutter.vcs.is_vcs_installed',
+        autospec=True,
+        return_value=True
+    )
+
+    mock_subprocess = mocker.patch(
+        'cookiecutter.vcs.subprocess.check_output',
+        autospec=True,
+    )
+
+    vcs.clone(
+        'https://github.com/foo/bar/',
+        clone_to_dir=clone_dir,
+        no_input=True
+    )
+
+    mock_subprocess.assert_called_once_with(
+        ['git', 'clone', 'https://github.com/foo/bar'],
+        cwd=clone_dir,
+        stderr=subprocess.STDOUT
+    )
+
+
+def test_clone_should_abort_if_user_does_not_want_to_reclone(mocker, tmpdir):
+    """In `clone()`, if user doesn't want to reclone, Cookiecutter should exit
+    without cloning anything.
+    """
+    mocker.patch(
+        'cookiecutter.vcs.is_vcs_installed',
+        autospec=True,
+        return_value=True
+    )
+    mocker.patch(
+        'cookiecutter.vcs.prompt_and_delete_repo',
+        side_effect=SystemExit,
+        autospec=True
+    )
+    mock_subprocess = mocker.patch(
+        'cookiecutter.vcs.subprocess.check_output',
+        autospec=True,
+    )
+
+    clone_to_dir = tmpdir.mkdir('clone')
+
+    # Create repo_dir to trigger prompt_and_delete_repo
+    clone_to_dir.mkdir('cookiecutter-pytest-plugin')
+
+    repo_url = 'https://github.com/pytest-dev/cookiecutter-pytest-plugin.git'
+
+    with pytest.raises(SystemExit):
+        vcs.clone(repo_url, clone_to_dir=str(clone_to_dir))
+    assert not mock_subprocess.called
+
+
+@pytest.mark.parametrize('repo_type, repo_url, repo_name', [
+    ('git', 'https://github.com/hello/world.git', 'world'),
+    ('hg', 'https://bitbucket.org/foo/bar', 'bar'),
+])
+def test_clone_should_invoke_vcs_command(
+        mocker, clone_dir, repo_type, repo_url, repo_name):
+    """When `clone()` is called with a git/hg repo, the corresponding VCS
+    command should be run via `subprocess.check_output()`.
+
+    This should take place:
+    * In the correct dir
+    * With the correct args.
+    """
+    mocker.patch(
+        'cookiecutter.vcs.is_vcs_installed',
+        autospec=True,
+        return_value=True
+    )
+
+    mock_subprocess = mocker.patch(
+        'cookiecutter.vcs.subprocess.check_output',
+        autospec=True,
+    )
+    expected_repo_dir = os.path.normpath(os.path.join(clone_dir, repo_name))
+
+    branch = 'foobar'
+
+    repo_dir = vcs.clone(
+        repo_url,
+        checkout=branch,
+        clone_to_dir=clone_dir,
+        no_input=True
+    )
+
+    assert repo_dir == expected_repo_dir
+
+    mock_subprocess.assert_any_call(
+        [repo_type, 'clone', repo_url],
+        cwd=clone_dir,
+        stderr=subprocess.STDOUT
+    )
+    mock_subprocess.assert_any_call(
+        [repo_type, 'checkout', branch],
+        cwd=expected_repo_dir,
+        stderr=subprocess.STDOUT
+    )
+
+
+@pytest.mark.parametrize('error_message', [
+    "fatal: repository 'https://github.com/hackebro/cookiedozer' not found",
+    'hg: abort: HTTP Error 404: Not Found',
+])
+def test_clone_handles_repo_typo(mocker, clone_dir, error_message):
+    """In `clone()`, repository not found errors should raise an
+    appropriate exception.
+    """
+    # side_effect is set to an iterable here (and below),
+    # because of a Python 3.4 unittest.mock regression
+    # http://bugs.python.org/issue23661
+    mocker.patch(
+        'cookiecutter.vcs.subprocess.check_output',
+        autospec=True,
+        side_effect=[subprocess.CalledProcessError(
+            -1, 'cmd', output=error_message
+        )]
+    )
+
+    repository_url = 'https://github.com/hackebro/cookiedozer'
+    with pytest.raises(exceptions.RepositoryNotFound) as err:
+        vcs.clone(
+            repository_url,
+            clone_to_dir=clone_dir,
+            no_input=True
+        )
+
+    assert str(err.value) == (
+        'The repository {} could not be found, have you made a typo?'
+    ).format(repository_url)
+
+
+@pytest.mark.parametrize('error_message', [
+    "error: pathspec 'unknown_branch' did not match any file(s) known to git.",
+    "hg: abort: unknown revision 'unknown_branch'!",
+])
+def test_clone_handles_branch_typo(mocker, clone_dir, error_message):
+    """In `clone()`, branch not found errors should raise an
+    appropriate exception.
+    """
+    mocker.patch(
+        'cookiecutter.vcs.subprocess.check_output',
+        autospec=True,
+        side_effect=[subprocess.CalledProcessError(
+            -1, 'cmd', output=error_message
+        )]
+    )
+
+    repository_url = 'https://github.com/pytest-dev/cookiecutter-pytest-plugin'
+    with pytest.raises(exceptions.RepositoryCloneFailed) as err:
+        vcs.clone(
+            repository_url,
+            clone_to_dir=clone_dir,
+            checkout='unknown_branch',
+            no_input=True
+        )
+
+    assert str(err.value) == (
+        'The unknown_branch branch of repository '
+        '{} could not found, have you made a typo?'
+    ).format(repository_url)
+
+
+def test_clone_unknown_subprocess_error(mocker, clone_dir):
+    """In `clone()`, unknown subprocess errors should be raised."""
+    mocker.patch(
+        'cookiecutter.vcs.subprocess.check_output',
+        autospec=True,
+        side_effect=[subprocess.CalledProcessError(
+            -1, 'cmd', output='Something went wrong'
+        )]
+    )
+
+    with pytest.raises(subprocess.CalledProcessError):
+        vcs.clone(
+            'https://github.com/pytest-dev/cookiecutter-pytest-plugin',
+            clone_to_dir=clone_dir,
+            no_input=True
+        )
diff --git a/tests/vcs/test_identify_repo.py b/tests/vcs/test_identify_repo.py
new file mode 100644
--- /dev/null
+++ b/tests/vcs/test_identify_repo.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+import pytest
+
+from cookiecutter import exceptions, vcs
+
+
+@pytest.mark.parametrize('repo_url, exp_repo_type, exp_repo_url', [
+    (
+        'git+https://github.com/pytest-dev/cookiecutter-pytest-plugin.git',
+        'git',
+        'https://github.com/pytest-dev/cookiecutter-pytest-plugin.git'
+    ), (
+        'hg+https://bitbucket.org/foo/bar.hg',
+        'hg',
+        'https://bitbucket.org/foo/bar.hg'
+    ), (
+        'https://github.com/pytest-dev/cookiecutter-pytest-plugin.git',
+        'git',
+        'https://github.com/pytest-dev/cookiecutter-pytest-plugin.git'
+    ), (
+        'https://bitbucket.org/foo/bar.hg',
+        'hg',
+        'https://bitbucket.org/foo/bar.hg'
+    ), (
+        'https://github.com/audreyr/cookiecutter-pypackage.git',
+        'git',
+        'https://github.com/audreyr/cookiecutter-pypackage.git',
+    ), (
+        'https://github.com/audreyr/cookiecutter-pypackage',
+        'git',
+        'https://github.com/audreyr/cookiecutter-pypackage',
+    ), (
+        'git@gitorious.org:cookiecutter-gitorious/cookiecutter-gitorious.git',
+        'git',
+        'git@gitorious.org:cookiecutter-gitorious/cookiecutter-gitorious.git',
+    ), (
+        'https://audreyr@bitbucket.org/audreyr/cookiecutter-bitbucket',
+        'hg',
+        'https://audreyr@bitbucket.org/audreyr/cookiecutter-bitbucket',
+    )
+])
+def test_identify_known_repo(repo_url, exp_repo_type, exp_repo_url):
+    assert vcs.identify_repo(repo_url) == (exp_repo_type, exp_repo_url)
+
+
+@pytest.fixture(params=[
+    'foo+git',  # uses explicit identifier with 'git' in the wrong place
+    'foo+hg',  # uses explicit identifier with 'hg' in the wrong place
+    'foo+bar',  # uses explicit identifier with neither 'git' nor 'hg'
+    'foobar',  # no identifier but neither 'git' nor 'bitbucket' in url
+    'http://norepotypespecified.com'
+])
+def unknown_repo_type_url(request):
+    return request.param
+
+
+def test_identify_raise_on_unknown_repo(unknown_repo_type_url):
+    with pytest.raises(exceptions.UnknownRepoType):
+        vcs.identify_repo(unknown_repo_type_url)
diff --git a/tests/vcs/test_is_vcs_installed.py b/tests/vcs/test_is_vcs_installed.py
new file mode 100644
--- /dev/null
+++ b/tests/vcs/test_is_vcs_installed.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+import pytest
+
+from cookiecutter import vcs
+
+
+@pytest.mark.parametrize('which_return, result', [
+    ('', False),
+    (None, False),
+    (False, False),
+    ('/usr/local/bin/git', True),
+])
+def test_is_vcs_installed(mocker, which_return, result):
+    mocker.patch(
+        'cookiecutter.vcs.which',
+        autospec=True,
+        return_value=which_return
+    )
+    assert vcs.is_vcs_installed('git') == result
diff --git a/tests/vcs/test_prompt_and_delete_repo.py b/tests/vcs/test_prompt_and_delete_repo.py
new file mode 100644
--- /dev/null
+++ b/tests/vcs/test_prompt_and_delete_repo.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+import pytest
+
+from cookiecutter import vcs
+
+
+def test_prompt_should_ask_and_rm_repo_dir(mocker, tmpdir):
+    """In `prompt_and_delete_repo()`, if the user agrees to delete/reclone the
+    repo, the repo should be deleted.
+    """
+    mock_read_user = mocker.patch(
+        'cookiecutter.vcs.read_user_yes_no',
+        return_value=True,
+        autospec=True
+    )
+    repo_dir = tmpdir.mkdir('repo')
+
+    vcs.prompt_and_delete_repo(str(repo_dir))
+
+    assert mock_read_user.called
+    assert not repo_dir.exists()
+
+
+def test_prompt_should_ask_and_keep_repo_dir(mocker, tmpdir):
+    """In `prompt_and_delete_repo()`, if the user wants to keep their old
+    cloned template repo, it should not be deleted.
+    """
+    mock_read_user = mocker.patch(
+        'cookiecutter.vcs.read_user_yes_no',
+        return_value=False,
+        autospec=True
+    )
+    repo_dir = tmpdir.mkdir('repo')
+
+    with pytest.raises(SystemExit):
+        vcs.prompt_and_delete_repo(str(repo_dir))
+
+    assert mock_read_user.called
+    assert repo_dir.exists()
+
+
+def test_prompt_should_not_ask_if_no_input_and_rm_repo_dir(mocker, tmpdir):
+    """In `prompt_and_delete_repo()`, if `no_input` is True, the call to
+    `vcs.read_user_yes_no()` should be suppressed.
+    """
+    mock_read_user = mocker.patch(
+        'cookiecutter.vcs.read_user_yes_no',
+        return_value=True,
+        autospec=True
+    )
+    repo_dir = tmpdir.mkdir('repo')
+
+    vcs.prompt_and_delete_repo(str(repo_dir), no_input=True)
+
+    assert not mock_read_user.called
+    assert not repo_dir.exists()