Merge pull request #211 from pre-commit/check-executables-have-shebangs

Add a checker for executables without shebangs
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
index dac48f0..1aba741 100644
--- a/.pre-commit-hooks.yaml
+++ b/.pre-commit-hooks.yaml
@@ -51,6 +51,15 @@
     # for backward compatibility
     files: ''
     minimum_pre_commit_version: 0.15.0
+-   id: check-executables-have-shebangs
+    name: Check that executables have shebangs
+    description: Ensures that (non-binary) executables have a shebang.
+    entry: check-executables-have-shebangs
+    language: python
+    types: [text, executable]
+    # for backward compatibility
+    files: ''
+    minimum_pre_commit_version: 0.15.0
 -   id: check-json
     name: Check JSON
     description: This hook checks json files for parseable syntax.
diff --git a/README.md b/README.md
index 4bb73b3..a25a756 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,8 @@
   case-insensitive filesystem like MacOS HFS+ or Windows FAT.
 - `check-docstring-first` - Checks for a common error of placing code before
   the docstring.
+- `check-executables-have-shebangs` - Checks that non-binary executables have a
+  proper shebang.
 - `check-json` - Attempts to load all json files to verify syntax.
 - `check-merge-conflict` - Check for files that contain merge conflict strings.
 - `check-symlinks` - Checks for symlinks which do not point to anything.
diff --git a/hooks.yaml b/hooks.yaml
index 7a4f933..5278bf5 100644
--- a/hooks.yaml
+++ b/hooks.yaml
@@ -34,6 +34,12 @@
     entry: upgrade-your-pre-commit-version
     files: ''
     minimum_pre_commit_version: 0.15.0
+-   id: check-executables-have-shebangs
+    language: system
+    name: upgrade-your-pre-commit-version
+    entry: upgrade-your-pre-commit-version
+    files: ''
+    minimum_pre_commit_version: 0.15.0
 -   id: check-json
     language: system
     name: upgrade-your-pre-commit-version
diff --git a/pre_commit_hooks/check_executables_have_shebangs.py b/pre_commit_hooks/check_executables_have_shebangs.py
new file mode 100644
index 0000000..89ac6e5
--- /dev/null
+++ b/pre_commit_hooks/check_executables_have_shebangs.py
@@ -0,0 +1,40 @@
+"""Check that executable text files have a shebang."""
+from __future__ import absolute_import
+from __future__ import print_function
+from __future__ import unicode_literals
+
+import argparse
+import pipes
+import sys
+
+
+def check_has_shebang(path):
+    with open(path, 'rb') as f:
+        first_bytes = f.read(2)
+
+    if first_bytes != b'#!':
+        print(
+            '{path}: marked executable but has no (or invalid) shebang!\n'
+            "  If it isn't supposed to be executable, try: chmod -x {quoted}\n"
+            '  If it is supposed to be executable, double-check its shebang.'.format(
+                path=path,
+                quoted=pipes.quote(path),
+            ),
+            file=sys.stderr,
+        )
+        return 1
+    else:
+        return 0
+
+
+def main(argv=None):
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('filenames', nargs='*')
+    args = parser.parse_args(argv)
+
+    retv = 0
+
+    for filename in args.filenames:
+        retv |= check_has_shebang(filename)
+
+    return retv
diff --git a/setup.py b/setup.py
index f5262b3..bc9c28b 100644
--- a/setup.py
+++ b/setup.py
@@ -39,6 +39,7 @@
             'check-byte-order-marker = pre_commit_hooks.check_byte_order_marker:main',
             'check-case-conflict = pre_commit_hooks.check_case_conflict:main',
             'check-docstring-first = pre_commit_hooks.check_docstring_first:main',
+            'check-executables-have-shebangs = pre_commit_hooks.check_executables_have_shebangs:main',
             'check-json = pre_commit_hooks.check_json:check_json',
             'check-merge-conflict = pre_commit_hooks.check_merge_conflict:detect_merge_conflict',
             'check-symlinks = pre_commit_hooks.check_symlinks:check_symlinks',
diff --git a/tests/check_executables_have_shebangs_test.py b/tests/check_executables_have_shebangs_test.py
new file mode 100644
index 0000000..0e28986
--- /dev/null
+++ b/tests/check_executables_have_shebangs_test.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+from __future__ import unicode_literals
+
+import pytest
+
+from pre_commit_hooks.check_executables_have_shebangs import main
+
+
+@pytest.mark.parametrize('content', (
+    b'#!/bin/bash\nhello world\n',
+    b'#!/usr/bin/env python3.6',
+    b'#!python',
+    '#!☃'.encode('UTF-8'),
+))
+def test_has_shebang(content, tmpdir):
+    path = tmpdir.join('path')
+    path.write(content, 'wb')
+    assert main((path.strpath,)) == 0
+
+
+@pytest.mark.parametrize('content', (
+    b'',
+    b' #!python\n',
+    b'\n#!python\n',
+    b'python\n',
+    '☃'.encode('UTF-8'),
+
+))
+def test_bad_shebang(content, tmpdir, capsys):
+    path = tmpdir.join('path')
+    path.write(content, 'wb')
+    assert main((path.strpath,)) == 1
+    _, stderr = capsys.readouterr()
+    assert stderr.startswith('{}: marked executable but'.format(path.strpath))