Repository: pre-commit/pre-commit
Pull request #633 (2017-10-07 22:18:19)

Add `pre-commit try-repo`
`try-repo` is useful for:
- Trying out a remote hook repository without needing to configure it.
- Testing a hook repository while developing it.

Refs #589

asottile: Here's a few example outputs:

## With just a repository listed

```
$ pre-commit try-repo git@github.com:pre-commit/pre-commit-hooks --all-files
[INFO] Initializing environment for git@github.com:pre-commit/pre-commit-hooks.
===============================================================================
Using config:
===============================================================================
repos:
-   repo: git@github.com:pre-commit/pre-commit-hooks
    sha: 5d08e77ad53064e92ab1d132d678bee3b44439f5
    hooks:
    -   id: autopep8-wrapper
    -   id: check-added-large-files
    -   id: check-ast
    -   id: check-byte-order-marker
    -   id: check-case-conflict
    -   id: check-docstring-first
    -   id: check-executables-have-shebangs
    -   id: check-json
    -   id: check-merge-conflict
    -   id: check-symlinks
    -   id: check-vcs-permalinks
    -   id: check-xml
    -   id: check-yaml
    -   id: debug-statements
    -   id: detect-aws-credentials
    -   id: detect-private-key
    -   id: double-quote-string-fixer
    -   id: end-of-file-fixer
    -   id: file-contents-sorter
    -   id: fix-encoding-pragma
    -   id: flake8
    -   id: forbid-new-submodules
    -   id: mixed-line-ending
    -   id: name-tests-test
    -   id: no-commit-to-branch
    -   id: pretty-format-json
    -   id: pyflakes
    -   id: requirements-txt-fixer
    -   id: sort-simple-yaml
    -   id: trailing-whitespace
===============================================================================
[INFO] Installing environment for git@github.com:pre-commit/pre-commit-hooks.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
autopep8 wrapper.........................................................Passed
Check for added large files..............................................Passed
Check python ast.........................................................Passed
Check for byte-order marker..............................................Passed
Check for case conflicts.................................................Passed
Check docstring is first.................................................Passed
Check that executables have shebangs.....................................Passed
Check JSON...............................................................Passed
Check for merge conflicts................................................Passed
Check for broken symlinks............................(no files to check)Skipped
Check vcs permalinks.....................................................Passed
Check Xml............................................(no files to check)Skipped
Check Yaml...............................................................Passed
Debug Statements (Python)................................................Passed
Detect AWS Credentials...................................................Failed
hookid: detect-aws-credentials

No AWS keys were found in the configured credential files and environment variables.
Please ensure you have the correct setting for --credentials-file
No AWS keys were found in the configured credential files and environment variables.
Please ensure you have the correct setting for --credentials-file

Detect Private Key.......................................................Passed
Fix double quoted strings................................................Failed
hookid: double-quote-string-fixer

Files were modified by this hook. Additional output:

Fixing strings in tests/prefixed_command_runner_test.py
Fixing strings in pre_commit/color_windows.py

Fix End of Files.........................................................Passed
File Contents Sorter.................................(no files to check)Skipped
Fix python encoding pragma...............................................Failed
hookid: fix-encoding-pragma

Files were modified by this hook. Additional output:

Added `# -*- coding: utf-8 -*-` to pre_commit/logging_handler.py
Added `# -*- coding: utf-8 -*-` to testing/util.py
Added `# -*- coding: utf-8 -*-` to pre_commit/resources/empty_template/setup.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/pcre.py
Added `# -*- coding: utf-8 -*-` to pre_commit/main.py
Added `# -*- coding: utf-8 -*-` to pre_commit/commands/clean.py
Added `# -*- coding: utf-8 -*-` to pre_commit/envcontext.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/helpers.py
Added `# -*- coding: utf-8 -*-` to tests/commands/migrate_config_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/docker.py
Added `# -*- coding: utf-8 -*-` to tests/languages/ruby_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/five.py
Added `# -*- coding: utf-8 -*-` to testing/auto_namedtuple.py
Added `# -*- coding: utf-8 -*-` to pre_commit/commands/sample_config.py
Added `# -*- coding: utf-8 -*-` to pre_commit/__main__.py
Added `# -*- coding: utf-8 -*-` to tests/main_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/git.py
Added `# -*- coding: utf-8 -*-` to tests/languages/docker_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/script.py
Added `# -*- coding: utf-8 -*-` to setup.py
Added `# -*- coding: utf-8 -*-` to pre_commit/commands/migrate_config.py
Added `# -*- coding: utf-8 -*-` to pre_commit/constants.py
Added `# -*- coding: utf-8 -*-` to tests/logging_handler_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/output.py
Added `# -*- coding: utf-8 -*-` to tests/prefixed_command_runner_test.py
Added `# -*- coding: utf-8 -*-` to tests/color_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/xargs.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/golang.py
Added `# -*- coding: utf-8 -*-` to tests/languages/pygrep_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/util.py
Added `# -*- coding: utf-8 -*-` to tests/staged_files_only_test.py
Added `# -*- coding: utf-8 -*-` to tests/runner_test.py
Added `# -*- coding: utf-8 -*-` to testing/resources/arbitrary_bytes_repo/setup.py
Added `# -*- coding: utf-8 -*-` to testing/resources/python3_hooks_repo/python3_hook/main.py
Added `# -*- coding: utf-8 -*-` to pre_commit/staged_files_only.py
Added `# -*- coding: utf-8 -*-` to pre_commit/manifest.py
Added `# -*- coding: utf-8 -*-` to tests/languages/helpers_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/ruby.py
Added `# -*- coding: utf-8 -*-` to pre_commit/make_archives.py
Added `# -*- coding: utf-8 -*-` to tests/commands/sample_config_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/prefixed_command_runner.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/all.py
Added `# -*- coding: utf-8 -*-` to pre_commit/runner.py
Added `# -*- coding: utf-8 -*-` to pre_commit/clientlib.py
Added `# -*- coding: utf-8 -*-` to pre_commit/schema.py
Added `# -*- coding: utf-8 -*-` to pre_commit/commands/install_uninstall.py
Added `# -*- coding: utf-8 -*-` to pre_commit/repository.py
Added `# -*- coding: utf-8 -*-` to tests/output_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/store.py
Added `# -*- coding: utf-8 -*-` to pre_commit/file_lock.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/pygrep.py
Added `# -*- coding: utf-8 -*-` to tests/commands/try_repo_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/node.py
Added `# -*- coding: utf-8 -*-` to tests/commands/clean_test.py
Added `# -*- coding: utf-8 -*-` to tests/store_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/color_windows.py
Added `# -*- coding: utf-8 -*-` to tests/commands/install_uninstall_test.py
Added `# -*- coding: utf-8 -*-` to testing/resources/python_hooks_repo/setup.py
Added `# -*- coding: utf-8 -*-` to tests/languages/python_test.py
Added `# -*- coding: utf-8 -*-` to tests/manifest_test.py
Added `# -*- coding: utf-8 -*-` to testing/fixtures.py
Added `# -*- coding: utf-8 -*-` to pre_commit/error_handler.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/docker_image.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/system.py
Added `# -*- coding: utf-8 -*-` to pre_commit/color.py
Added `# -*- coding: utf-8 -*-` to tests/clientlib_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/swift.py
Added `# -*- coding: utf-8 -*-` to tests/util_test.py
Added `# -*- coding: utf-8 -*-` to testing/resources/python3_hooks_repo/setup.py
Added `# -*- coding: utf-8 -*-` to tests/commands/run_test.py
Added `# -*- coding: utf-8 -*-` to tests/languages/all_test.py
Added `# -*- coding: utf-8 -*-` to tests/conftest.py
Added `# -*- coding: utf-8 -*-` to tests/schema_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/commands/try_repo.py
Added `# -*- coding: utf-8 -*-` to pre_commit/languages/python.py
Added `# -*- coding: utf-8 -*-` to tests/parse_shebang_test.py
Added `# -*- coding: utf-8 -*-` to tests/repository_test.py
Added `# -*- coding: utf-8 -*-` to testing/resources/python_hooks_repo/foo/main.py
Added `# -*- coding: utf-8 -*-` to tests/make_archives_test.py
Added `# -*- coding: utf-8 -*-` to pre_commit/commands/autoupdate.py
Added `# -*- coding: utf-8 -*-` to pre_commit/parse_shebang.py
Added `# -*- coding: utf-8 -*-` to pre_commit/commands/run.py
Added `# -*- coding: utf-8 -*-` to tests/commands/autoupdate_test.py
Added `# -*- coding: utf-8 -*-` to tests/xargs_test.py
Added `# -*- coding: utf-8 -*-` to tests/envcontext_test.py
Added `# -*- coding: utf-8 -*-` to tests/languages/golang_test.py

Flake8...................................................................Passed
Forbid new submodules....................................................Passed
Mixed line ending........................................................Passed
Tests should end in _test.py.............................................Passed
Don't commit to branch...................................................Passed
Pretty format JSON.......................................................Failed
hookid: pretty-format-json

File testing/resources/node_0_11_8_hooks_repo/package.json is not pretty-formatted
File testing/resources/node_hooks_repo/package.json is not pretty-formatted
File pre_commit/resources/empty_template/package.json is not pretty-formatted

Pyflakes (DEPRECATED, use flake8)........................................Passed
Fix requirements.txt.....................................................Passed
Sort simple YAML files...............................(no files to check)Skipped
Trim Trailing Whitespace.................................................Passed
```

## with a specific hook listed

```
$ pre-commit try-repo git@github.com:pre-commit/pre-commit-hooks check-vcs-permalinks --all-files
===============================================================================
Using config:
===============================================================================
repos:
-   repo: git@github.com:pre-commit/pre-commit-hooks
    sha: 5d08e77ad53064e92ab1d132d678bee3b44439f5
    hooks:
    -   id: check-vcs-permalinks
===============================================================================
[INFO] Initializing environment for git@github.com:pre-commit/pre-commit-hooks.
[INFO] Installing environment for git@github.com:pre-commit/pre-commit-hooks.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
Check vcs permalinks.....................................................Passed
```

## With a specific revision listed

```
$ pre-commit try-repo --ref v0.7.0 git@github.com:pre-commit/pre-commit-hooks flake8 --all-files
===============================================================================
Using config:
===============================================================================
repos:
-   repo: git@github.com:pre-commit/pre-commit-hooks
    sha: v0.7.0
    hooks:
    -   id: flake8
===============================================================================
[INFO] Initializing environment for git@github.com:pre-commit/pre-commit-hooks.
[INFO] Installing environment for git@github.com:pre-commit/pre-commit-hooks.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
Flake8...................................................................Passed
```

Patch:
diff --git a/pre_commit/commands/try_repo.py b/pre_commit/commands/try_repo.py
new file mode 100644
index 0000000..2e1933d
--- /dev/null
+++ b/pre_commit/commands/try_repo.py
@@ -0,0 +1,44 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import collections
+import os.path
+
+from aspy.yaml import ordered_dump
+
+import pre_commit.constants as C
+from pre_commit import git
+from pre_commit import output
+from pre_commit.commands.run import run
+from pre_commit.manifest import Manifest
+from pre_commit.runner import Runner
+from pre_commit.store import Store
+from pre_commit.util import tmpdir
+
+
+def try_repo(args):
+    ref = args.ref or git.head_sha(args.repo)
+
+    with tmpdir() as tempdir:
+        if args.hook:
+            hooks = [{'id': args.hook}]
+        else:
+            manifest = Manifest(Store(tempdir).clone(args.repo, ref))
+            hooks = [{'id': hook_id} for hook_id in sorted(manifest.hooks)]
+
+        items = (('repo', args.repo), ('sha', ref), ('hooks', hooks))
+        config = {'repos': [collections.OrderedDict(items)]}
+        config_s = ordered_dump(config, **C.YAML_DUMP_KWARGS)
+
+        config_filename = os.path.join(tempdir, C.CONFIG_FILE)
+        with open(config_filename, 'w') as cfg:
+            cfg.write(config_s)
+
+        output.write_line('=' * 79)
+        output.write_line('Using config:')
+        output.write_line('=' * 79)
+        output.write(config_s)
+        output.write_line('=' * 79)
+
+        runner = Runner('.', config_filename, store_dir=tempdir)
+        return run(runner, args)
diff --git a/pre_commit/git.py b/pre_commit/git.py
index 96a5155..c38b83a 100644
--- a/pre_commit/git.py
+++ b/pre_commit/git.py
@@ -97,6 +97,11 @@ def get_changed_files(new, old):
     )[1])
 
 
+def head_sha(remote):
+    _, out, _ = cmd_output('git', 'ls-remote', '--exit-code', remote, 'HEAD')
+    return out.split()[0]
+
+
 def check_for_cygwin_mismatch():
     """See https://github.com/pre-commit/pre-commit/issues/354"""
     if sys.platform in ('cygwin', 'win32'):  # pragma: no cover (windows)
diff --git a/pre_commit/main.py b/pre_commit/main.py
index 9167ee2..1405203 100644
--- a/pre_commit/main.py
+++ b/pre_commit/main.py
@@ -17,6 +17,7 @@ from pre_commit.commands.install_uninstall import uninstall
 from pre_commit.commands.migrate_config import migrate_config
 from pre_commit.commands.run import run
 from pre_commit.commands.sample_config import sample_config
+from pre_commit.commands.try_repo import try_repo
 from pre_commit.error_handler import error_handler
 from pre_commit.logging_handler import add_logging_handler
 from pre_commit.runner import Runner
@@ -53,6 +54,41 @@ def _add_hook_type_option(parser):
     )
 
 
+def _add_run_options(parser):
+    parser.add_argument('hook', nargs='?', help='A single hook-id to run')
+    parser.add_argument('--verbose', '-v', action='store_true', default=False)
+    parser.add_argument(
+        '--origin', '-o',
+        help="The origin branch's commit_id when using `git push`.",
+    )
+    parser.add_argument(
+        '--source', '-s',
+        help="The remote branch's commit_id when using `git push`.",
+    )
+    parser.add_argument(
+        '--commit-msg-filename',
+        help='Filename to check when running during `commit-msg`',
+    )
+    parser.add_argument(
+        '--hook-stage', choices=('commit', 'push', 'commit-msg'),
+        default='commit',
+        help='The stage during which the hook is fired e.g. commit or push.',
+    )
+    parser.add_argument(
+        '--show-diff-on-failure', action='store_true',
+        help='When hooks fail, run `git diff` directly afterward.',
+    )
+    mutex_group = parser.add_mutually_exclusive_group(required=False)
+    mutex_group.add_argument(
+        '--all-files', '-a', action='store_true', default=False,
+        help='Run on all the files in the repo.',
+    )
+    mutex_group.add_argument(
+        '--files', nargs='*', default=[],
+        help='Specific filenames to run hooks on.',
+    )
+
+
 def main(argv=None):
     argv = argv if argv is not None else sys.argv[1:]
     argv = [five.to_text(arg) for arg in argv]
@@ -142,40 +178,7 @@ def main(argv=None):
     run_parser = subparsers.add_parser('run', help='Run hooks.')
     _add_color_option(run_parser)
     _add_config_option(run_parser)
-    run_parser.add_argument('hook', nargs='?', help='A single hook-id to run')
-    run_parser.add_argument(
-        '--verbose', '-v', action='store_true', default=False,
-    )
-    run_parser.add_argument(
-        '--origin', '-o',
-        help="The origin branch's commit_id when using `git push`.",
-    )
-    run_parser.add_argument(
-        '--source', '-s',
-        help="The remote branch's commit_id when using `git push`.",
-    )
-    run_parser.add_argument(
-        '--commit-msg-filename',
-        help='Filename to check when running during `commit-msg`',
-    )
-    run_parser.add_argument(
-        '--hook-stage', choices=('commit', 'push', 'commit-msg'),
-        default='commit',
-        help='The stage during which the hook is fired e.g. commit or push.',
-    )
-    run_parser.add_argument(
-        '--show-diff-on-failure', action='store_true',
-        help='When hooks fail, run `git diff` directly afterward.',
-    )
-    run_mutex_group = run_parser.add_mutually_exclusive_group(required=False)
-    run_mutex_group.add_argument(
-        '--all-files', '-a', action='store_true', default=False,
-        help='Run on all the files in the repo.',
-    )
-    run_mutex_group.add_argument(
-        '--files', nargs='*', default=[],
-        help='Specific filenames to run hooks on.',
-    )
+    _add_run_options(run_parser)
 
     sample_config_parser = subparsers.add_parser(
         'sample-config', help='Produce a sample {} file'.format(C.CONFIG_FILE),
@@ -183,6 +186,24 @@ def main(argv=None):
     _add_color_option(sample_config_parser)
     _add_config_option(sample_config_parser)
 
+    try_repo_parser = subparsers.add_parser(
+        'try-repo',
+        help='Try the hooks in a repository, useful for developing new hooks.',
+    )
+    _add_color_option(try_repo_parser)
+    _add_config_option(try_repo_parser)
+    try_repo_parser.add_argument(
+        'repo', help='Repository to source hooks from.',
+    )
+    try_repo_parser.add_argument(
+        '--ref',
+        help=(
+            'Manually select a ref to run against, otherwise the `HEAD` '
+            'revision will be used.'
+        ),
+    )
+    _add_run_options(try_repo_parser)
+
     help = subparsers.add_parser(
         'help', help='Show help for a specific command.',
     )
@@ -231,6 +252,8 @@ def main(argv=None):
             return run(runner, args)
         elif args.command == 'sample-config':
             return sample_config()
+        elif args.command == 'try-repo':
+            return try_repo(args)
         else:
             raise NotImplementedError(
                 'Command {} not implemented.'.format(args.command),
diff --git a/pre_commit/manifest.py b/pre_commit/manifest.py
index df28844..99d8393 100644
--- a/pre_commit/manifest.py
+++ b/pre_commit/manifest.py
@@ -14,9 +14,8 @@ logger = logging.getLogger('pre_commit')
 
 
 class Manifest(object):
-    def __init__(self, repo_path, repo_url):
+    def __init__(self, repo_path):
         self.repo_path = repo_path
-        self.repo_url = repo_url
 
     @cached_property
     def manifest_contents(self):
diff --git a/pre_commit/repository.py b/pre_commit/repository.py
index 6955a73..4d7f0a5 100644
--- a/pre_commit/repository.py
+++ b/pre_commit/repository.py
@@ -146,7 +146,7 @@ class Repository(object):
 
     @cached_property
     def manifest(self):
-        return Manifest(self._repo_path, self.repo_config['repo'])
+        return Manifest(self._repo_path)
 
     @cached_property
     def hooks(self):
diff --git a/pre_commit/runner.py b/pre_commit/runner.py
index d853868..1983bab 100644
--- a/pre_commit/runner.py
+++ b/pre_commit/runner.py
@@ -15,13 +15,14 @@ class Runner(object):
     repository under test.
     """
 
-    def __init__(self, git_root, config_file):
+    def __init__(self, git_root, config_file, store_dir=None):
         self.git_root = git_root
         self.config_file = config_file
+        self._store_dir = store_dir
 
     @classmethod
     def create(cls, config_file):
-        """Creates a PreCommitRunner by doing the following:
+        """Creates a Runner by doing the following:
             - Finds the root of the current git repository
             - chdir to that directory
         """
@@ -63,4 +64,4 @@ class Runner(object):
 
     @cached_property
     def store(self):
-        return Store()
+        return Store(self._store_dir)


Test patch:
diff --git a/testing/fixtures.py b/testing/fixtures.py
index 388b344..b1c7a89 100644
--- a/testing/fixtures.py
+++ b/testing/fixtures.py
@@ -10,6 +10,7 @@ from aspy.yaml import ordered_dump
 from aspy.yaml import ordered_load
 
 import pre_commit.constants as C
+from pre_commit import git
 from pre_commit.clientlib import CONFIG_SCHEMA
 from pre_commit.clientlib import load_manifest
 from pre_commit.schema import apply_defaults
@@ -17,7 +18,6 @@ from pre_commit.schema import validate
 from pre_commit.util import cmd_output
 from pre_commit.util import copy_tree_to_path
 from pre_commit.util import cwd
-from testing.util import get_head_sha
 from testing.util import get_resource_path
 
 
@@ -84,7 +84,7 @@ def make_config_from_repo(repo_path, sha=None, hooks=None, check=True):
     manifest = load_manifest(os.path.join(repo_path, C.MANIFEST_FILE))
     config = OrderedDict((
         ('repo', 'file://{}'.format(repo_path)),
-        ('sha', sha or get_head_sha(repo_path)),
+        ('sha', sha or git.head_sha(repo_path)),
         (
             'hooks',
             hooks or [OrderedDict((('id', hook['id']),)) for hook in manifest],
diff --git a/testing/util.py b/testing/util.py
index 332b641..8a5bfc4 100644
--- a/testing/util.py
+++ b/testing/util.py
@@ -8,7 +8,7 @@ from pre_commit import parse_shebang
 from pre_commit.languages.docker import docker_is_running
 from pre_commit.languages.pcre import GREP
 from pre_commit.util import cmd_output
-from pre_commit.util import cwd
+from testing.auto_namedtuple import auto_namedtuple
 
 
 TESTING_DIR = os.path.abspath(os.path.dirname(__file__))
@@ -18,11 +18,6 @@ def get_resource_path(path):
     return os.path.join(TESTING_DIR, 'resources', path)
 
 
-def get_head_sha(dir):
-    with cwd(dir):
-        return cmd_output('git', 'rev-parse', 'HEAD')[1].strip()
-
-
 def cmd_output_mocked_pre_commit_home(*args, **kwargs):
     # keyword-only argument
     tempdir_factory = kwargs.pop('tempdir_factory')
@@ -72,3 +67,31 @@ xfailif_no_symlink = pytest.mark.xfail(
     not hasattr(os, 'symlink'),
     reason='Symlink is not supported on this platform',
 )
+
+
+def run_opts(
+        all_files=False,
+        files=(),
+        color=False,
+        verbose=False,
+        hook=None,
+        origin='',
+        source='',
+        hook_stage='commit',
+        show_diff_on_failure=False,
+        commit_msg_filename='',
+):
+    # These are mutually exclusive
+    assert not (all_files and files)
+    return auto_namedtuple(
+        all_files=all_files,
+        files=files,
+        color=color,
+        verbose=verbose,
+        hook=hook,
+        origin=origin,
+        source=source,
+        hook_stage=hook_stage,
+        show_diff_on_failure=show_diff_on_failure,
+        commit_msg_filename=commit_msg_filename,
+    )
diff --git a/tests/commands/autoupdate_test.py b/tests/commands/autoupdate_test.py
index 2877c5b..9ae70c6 100644
--- a/tests/commands/autoupdate_test.py
+++ b/tests/commands/autoupdate_test.py
@@ -7,6 +7,7 @@ from collections import OrderedDict
 import pytest
 
 import pre_commit.constants as C
+from pre_commit import git
 from pre_commit.clientlib import load_config
 from pre_commit.commands.autoupdate import _update_repo
 from pre_commit.commands.autoupdate import autoupdate
@@ -21,7 +22,6 @@ from testing.fixtures import git_dir
 from testing.fixtures import make_config_from_repo
 from testing.fixtures import make_repo
 from testing.fixtures import write_config
-from testing.util import get_head_sha
 from testing.util import get_resource_path
 
 
@@ -66,10 +66,10 @@ def test_autoupdate_old_revision_broken(
         cmd_output('git', 'mv', C.MANIFEST_FILE, 'nope.yaml')
         cmd_output('git', 'commit', '-m', 'simulate old repo')
         # Assume this is the revision the user's old repository was at
-        rev = get_head_sha(path)
+        rev = git.head_sha(path)
         cmd_output('git', 'mv', 'nope.yaml', C.MANIFEST_FILE)
         cmd_output('git', 'commit', '-m', 'move hooks file')
-        update_rev = get_head_sha(path)
+        update_rev = git.head_sha(path)
 
     config['sha'] = rev
     write_config('.', config)
@@ -84,12 +84,12 @@ def test_autoupdate_old_revision_broken(
 @pytest.yield_fixture
 def out_of_date_repo(tempdir_factory):
     path = make_repo(tempdir_factory, 'python_hooks_repo')
-    original_sha = get_head_sha(path)
+    original_sha = git.head_sha(path)
 
     # Make a commit
     with cwd(path):
         cmd_output('git', 'commit', '--allow-empty', '-m', 'foo')
-    head_sha = get_head_sha(path)
+    head_sha = git.head_sha(path)
 
     yield auto_namedtuple(
         path=path, original_sha=original_sha, head_sha=head_sha,
@@ -225,7 +225,7 @@ def test_autoupdate_tags_only(
 @pytest.yield_fixture
 def hook_disappearing_repo(tempdir_factory):
     path = make_repo(tempdir_factory, 'python_hooks_repo')
-    original_sha = get_head_sha(path)
+    original_sha = git.head_sha(path)
 
     with cwd(path):
         shutil.copy(
diff --git a/tests/commands/run_test.py b/tests/commands/run_test.py
index 51e4eac..d6812ae 100644
--- a/tests/commands/run_test.py
+++ b/tests/commands/run_test.py
@@ -20,12 +20,12 @@ from pre_commit.runner import Runner
 from pre_commit.util import cmd_output
 from pre_commit.util import cwd
 from pre_commit.util import make_executable
-from testing.auto_namedtuple import auto_namedtuple
 from testing.fixtures import add_config_to_repo
 from testing.fixtures import make_consuming_repo
 from testing.fixtures import modify_config
 from testing.fixtures import read_config
 from testing.util import cmd_output_mocked_pre_commit_home
+from testing.util import run_opts
 from testing.util import xfailif_no_symlink
 
 
@@ -48,34 +48,6 @@ def stage_a_file(filename='foo.py'):
     cmd_output('git', 'add', filename)
 
 
-def _get_opts(
-        all_files=False,
-        files=(),
-        color=False,
-        verbose=False,
-        hook=None,
-        origin='',
-        source='',
-        hook_stage='commit',
-        show_diff_on_failure=False,
-        commit_msg_filename='',
-):
-    # These are mutually exclusive
-    assert not (all_files and files)
-    return auto_namedtuple(
-        all_files=all_files,
-        files=files,
-        color=color,
-        verbose=verbose,
-        hook=hook,
-        origin=origin,
-        source=source,
-        hook_stage=hook_stage,
-        show_diff_on_failure=show_diff_on_failure,
-        commit_msg_filename=commit_msg_filename,
-    )
-
-
 def _do_run(cap_out, repo, args, environ={}, config_file=C.CONFIG_FILE):
     runner = Runner(repo, config_file)
     with cwd(runner.git_root):  # replicates Runner.create behaviour
@@ -90,7 +62,7 @@ def _test_run(
 ):
     if stage:
         stage_a_file()
-    args = _get_opts(**opts)
+    args = run_opts(**opts)
     ret, printed = _do_run(cap_out, repo, args, config_file=config_file)
 
     assert ret == expected_ret, (ret, expected_ret, printed)
@@ -161,7 +133,7 @@ def test_types_hook_repository(
     with cwd(git_path):
         stage_a_file('bar.py')
         stage_a_file('bar.notpy')
-        ret, printed = _do_run(cap_out, git_path, _get_opts())
+        ret, printed = _do_run(cap_out, git_path, run_opts())
         assert ret == 1
         assert b'bar.py' in printed
         assert b'bar.notpy' not in printed
@@ -177,7 +149,7 @@ def test_exclude_types_hook_repository(
         make_executable('exe')
         cmd_output('git', 'add', 'exe')
         stage_a_file('bar.py')
-        ret, printed = _do_run(cap_out, git_path, _get_opts())
+        ret, printed = _do_run(cap_out, git_path, run_opts())
         assert ret == 1
         assert b'bar.py' in printed
         assert b'exe' not in printed
@@ -191,7 +163,7 @@ def test_global_exclude(cap_out, tempdir_factory, mock_out_store_directory):
         open('foo.py', 'a').close()
         open('bar.py', 'a').close()
         cmd_output('git', 'add', '.')
-        ret, printed = _do_run(cap_out, git_path, _get_opts(verbose=True))
+        ret, printed = _do_run(cap_out, git_path, run_opts(verbose=True))
         assert ret == 0
         # Does not contain foo.py since it was excluded
         expected = b'hookid: bash_hook\n\nbar.py\nHello World\n\n'
@@ -332,7 +304,7 @@ def test_origin_source_error_msg(
         repo_with_passing_hook, origin, source, expect_failure,
         mock_out_store_directory, cap_out,
 ):
-    args = _get_opts(origin=origin, source=source)
+    args = run_opts(origin=origin, source=source)
     ret, printed = _do_run(cap_out, repo_with_passing_hook, args)
     warning_msg = b'Specify both --origin and --source.'
     if expect_failure:
@@ -350,7 +322,7 @@ def test_has_unmerged_paths(in_merge_conflict):
 
 
 def test_merge_conflict(cap_out, in_merge_conflict, mock_out_store_directory):
-    ret, printed = _do_run(cap_out, in_merge_conflict, _get_opts())
+    ret, printed = _do_run(cap_out, in_merge_conflict, run_opts())
     assert ret == 1
     assert b'Unmerged files.  Resolve before committing.' in printed
 
@@ -363,7 +335,7 @@ def test_merge_conflict_modified(
     with open('dummy', 'w') as dummy_file:
         dummy_file.write('bar\nbaz\n')
 
-    ret, printed = _do_run(cap_out, in_merge_conflict, _get_opts())
+    ret, printed = _do_run(cap_out, in_merge_conflict, run_opts())
     assert ret == 1
     assert b'Unmerged files.  Resolve before committing.' in printed
 
@@ -372,7 +344,7 @@ def test_merge_conflict_resolved(
         cap_out, in_merge_conflict, mock_out_store_directory,
 ):
     cmd_output('git', 'add', '.')
-    ret, printed = _do_run(cap_out, in_merge_conflict, _get_opts())
+    ret, printed = _do_run(cap_out, in_merge_conflict, run_opts())
     for msg in (
             b'Checking merge-conflict files only.', b'Bash hook', b'Passed',
     ):
@@ -415,7 +387,7 @@ def test_get_skips(environ, expected_output):
 
 def test_skip_hook(cap_out, repo_with_passing_hook, mock_out_store_directory):
     ret, printed = _do_run(
-        cap_out, repo_with_passing_hook, _get_opts(), {'SKIP': 'bash_hook'},
+        cap_out, repo_with_passing_hook, run_opts(), {'SKIP': 'bash_hook'},
     )
     for msg in (b'Bash hook', b'Skipped'):
         assert msg in printed
@@ -425,7 +397,7 @@ def test_hook_id_not_in_non_verbose_output(
         cap_out, repo_with_passing_hook, mock_out_store_directory,
 ):
     ret, printed = _do_run(
-        cap_out, repo_with_passing_hook, _get_opts(verbose=False),
+        cap_out, repo_with_passing_hook, run_opts(verbose=False),
     )
     assert b'[bash_hook]' not in printed
 
@@ -434,7 +406,7 @@ def test_hook_id_in_verbose_output(
         cap_out, repo_with_passing_hook, mock_out_store_directory,
 ):
     ret, printed = _do_run(
-        cap_out, repo_with_passing_hook, _get_opts(verbose=True),
+        cap_out, repo_with_passing_hook, run_opts(verbose=True),
     )
     assert b'[bash_hook] Bash hook' in printed
 
@@ -448,7 +420,7 @@ def test_multiple_hooks_same_id(
             config['repos'][0]['hooks'].append({'id': 'bash_hook'})
         stage_a_file()
 
-    ret, output = _do_run(cap_out, repo_with_passing_hook, _get_opts())
+    ret, output = _do_run(cap_out, repo_with_passing_hook, run_opts())
     assert ret == 0
     assert output.count(b'Bash hook') == 2
 
@@ -684,7 +656,7 @@ def modified_config_repo(repo_with_passing_hook):
 def test_error_with_unstaged_config(
         cap_out, modified_config_repo, mock_out_store_directory,
 ):
-    args = _get_opts()
+    args = run_opts()
     ret, printed = _do_run(cap_out, modified_config_repo, args)
     assert b'Your .pre-commit-config.yaml is unstaged.' in printed
     assert ret == 1
@@ -696,7 +668,7 @@ def test_error_with_unstaged_config(
 def test_no_unstaged_error_with_all_files_or_files(
         cap_out, modified_config_repo, mock_out_store_directory, opts,
 ):
-    args = _get_opts(**opts)
+    args = run_opts(**opts)
     ret, printed = _do_run(cap_out, modified_config_repo, args)
     assert b'Your .pre-commit-config.yaml is unstaged.' not in printed
 
@@ -742,7 +714,7 @@ def test_pass_filenames(
         config['repos'][0]['hooks'][0]['args'] = hook_args
     stage_a_file()
     ret, printed = _do_run(
-        cap_out, repo_with_passing_hook, _get_opts(verbose=True),
+        cap_out, repo_with_passing_hook, run_opts(verbose=True),
     )
     assert expected_out + b'\nHello World' in printed
     assert (b'foo.py' in printed) == pass_filenames
@@ -758,7 +730,7 @@ def test_fail_fast(
             config['repos'][0]['hooks'] *= 2
         stage_a_file()
 
-        ret, printed = _do_run(cap_out, repo_with_failing_hook, _get_opts())
+        ret, printed = _do_run(cap_out, repo_with_failing_hook, run_opts())
         # it should have only run one hook
         assert printed.count(b'Failing hook') == 1
 
diff --git a/tests/commands/try_repo_test.py b/tests/commands/try_repo_test.py
new file mode 100644
index 0000000..e530dee
--- /dev/null
+++ b/tests/commands/try_repo_test.py
@@ -0,0 +1,71 @@
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import re
+
+from pre_commit.commands.try_repo import try_repo
+from pre_commit.util import cmd_output
+from pre_commit.util import cwd
+from testing.auto_namedtuple import auto_namedtuple
+from testing.fixtures import git_dir
+from testing.fixtures import make_repo
+from testing.util import run_opts
+
+
+def try_repo_opts(repo, ref=None, **kwargs):
+    return auto_namedtuple(repo=repo, ref=ref, **run_opts(**kwargs)._asdict())
+
+
+def _get_out(cap_out):
+    out = cap_out.get().replace('\r\n', '\n')
+    out = re.sub('\[INFO\].+\n', '', out)
+    start, using_config, config, rest = out.split('=' * 79 + '\n')
+    assert start == ''
+    assert using_config == 'Using config:\n'
+    return config, rest
+
+
+def _run_try_repo(tempdir_factory, **kwargs):
+    repo = make_repo(tempdir_factory, 'modified_file_returns_zero_repo')
+    with cwd(git_dir(tempdir_factory)):
+        open('test-file', 'a').close()
+        cmd_output('git', 'add', '.')
+        assert not try_repo(try_repo_opts(repo, **kwargs))
+
+
+def test_try_repo_repo_only(cap_out, tempdir_factory):
+    _run_try_repo(tempdir_factory, verbose=True)
+    config, rest = _get_out(cap_out)
+    assert re.match(
+        '^repos:\n'
+        '-   repo: .+\n'
+        '    sha: .+\n'
+        '    hooks:\n'
+        '    -   id: bash_hook\n'
+        '    -   id: bash_hook2\n'
+        '    -   id: bash_hook3\n$',
+        config,
+    )
+    assert rest == (
+        '[bash_hook] Bash hook................................(no files to check)Skipped\n'  # noqa
+        '[bash_hook2] Bash hook...................................................Passed\n'  # noqa
+        'hookid: bash_hook2\n'
+        '\n'
+        'test-file\n'
+        '\n'
+        '[bash_hook3] Bash hook...............................(no files to check)Skipped\n'  # noqa
+    )
+
+
+def test_try_repo_with_specific_hook(cap_out, tempdir_factory):
+    _run_try_repo(tempdir_factory, hook='bash_hook', verbose=True)
+    config, rest = _get_out(cap_out)
+    assert re.match(
+        '^repos:\n'
+        '-   repo: .+\n'
+        '    sha: .+\n'
+        '    hooks:\n'
+        '    -   id: bash_hook\n$',
+        config,
+    )
+    assert rest == '[bash_hook] Bash hook................................(no files to check)Skipped\n'  # noqa
diff --git a/tests/main_test.py b/tests/main_test.py
index 933b525..e925cfc 100644
--- a/tests/main_test.py
+++ b/tests/main_test.py
@@ -92,12 +92,18 @@ def test_help_other_command(
 
 
 @pytest.mark.parametrize('command', CMDS)
-def test_install_command(command, mock_commands):
+def test_all_cmds(command, mock_commands):
     main.main((command,))
     assert getattr(mock_commands, command.replace('-', '_')).call_count == 1
     assert_only_one_mock_called(mock_commands)
 
 
+def test_try_repo():
+    with mock.patch.object(main, 'try_repo') as patch:
+        main.main(('try-repo', '.'))
+    assert patch.call_count == 1
+
+
 def test_help_cmd_in_empty_directory(
         mock_commands,
         tempdir_factory,
diff --git a/tests/make_archives_test.py b/tests/make_archives_test.py
index 3423342..9a0f1e5 100644
--- a/tests/make_archives_test.py
+++ b/tests/make_archives_test.py
@@ -6,11 +6,11 @@ import tarfile
 
 import pytest
 
+from pre_commit import git
 from pre_commit import make_archives
 from pre_commit.util import cmd_output
 from pre_commit.util import cwd
 from testing.fixtures import git_dir
-from testing.util import get_head_sha
 from testing.util import skipif_slowtests_false
 
 
@@ -23,7 +23,7 @@ def test_make_archive(tempdir_factory):
         cmd_output('git', 'add', '.')
         cmd_output('git', 'commit', '-m', 'foo')
         # We'll use this sha
-        head_sha = get_head_sha('.')
+        head_sha = git.head_sha('.')
         # And check that this file doesn't exist
         open('bar', 'a').close()
         cmd_output('git', 'add', '.')
diff --git a/tests/manifest_test.py b/tests/manifest_test.py
index ee1857c..b7603a0 100644
--- a/tests/manifest_test.py
+++ b/tests/manifest_test.py
@@ -3,16 +3,16 @@ from __future__ import unicode_literals
 
 import pytest
 
+from pre_commit import git
 from pre_commit.manifest import Manifest
 from testing.fixtures import make_repo
-from testing.util import get_head_sha
 
 
 @pytest.yield_fixture
 def manifest(store, tempdir_factory):
     path = make_repo(tempdir_factory, 'script_hooks_repo')
-    repo_path = store.clone(path, get_head_sha(path))
-    yield Manifest(repo_path, path)
+    repo_path = store.clone(path, git.head_sha(path))
+    yield Manifest(repo_path)
 
 
 def test_manifest_contents(manifest):
@@ -62,8 +62,8 @@ def test_hooks(manifest):
 
 def test_default_python_language_version(store, tempdir_factory):
     path = make_repo(tempdir_factory, 'python_hooks_repo')
-    repo_path = store.clone(path, get_head_sha(path))
-    manifest = Manifest(repo_path, path)
+    repo_path = store.clone(path, git.head_sha(path))
+    manifest = Manifest(repo_path)
 
     # This assertion is difficult as it is version dependent, just assert
     # that it is *something*
diff --git a/tests/store_test.py b/tests/store_test.py
index 718f24d..deb22bb 100644
--- a/tests/store_test.py
+++ b/tests/store_test.py
@@ -9,13 +9,13 @@ import mock
 import pytest
 import six
 
+from pre_commit import git
 from pre_commit.store import _get_default_directory
 from pre_commit.store import Store
 from pre_commit.util import cmd_output
 from pre_commit.util import cwd
 from pre_commit.util import rmtree
 from testing.fixtures import git_dir
-from testing.util import get_head_sha
 
 
 def test_our_session_fixture_works():
@@ -91,7 +91,7 @@ def test_clone(store, tempdir_factory, log_info_mock):
     path = git_dir(tempdir_factory)
     with cwd(path):
         cmd_output('git', 'commit', '--allow-empty', '-m', 'foo')
-        sha = get_head_sha(path)
+        sha = git.head_sha(path)
         cmd_output('git', 'commit', '--allow-empty', '-m', 'bar')
 
     ret = store.clone(path, sha)
@@ -107,7 +107,7 @@ def test_clone(store, tempdir_factory, log_info_mock):
     _, dirname = os.path.split(ret)
     assert dirname.startswith('repo')
     # Should be checked out to the sha we specified
-    assert get_head_sha(ret) == sha
+    assert git.head_sha(ret) == sha
 
     # Assert there's an entry in the sqlite db for this
     with sqlite3.connect(store.db_path) as db: