Repository: pre-commit/pre-commit-hooks
Pull request #202 (2017-06-23 20:36:48)

File contents sorter hook
(this is my first github pull request, so please let me know if there's some etiquette or anything I should know about)

This branch adds a new precommit hook file-contents-sorter that does just what it sounds like: sorts the lines in files provided to it as arguments. It's not meant to run on any files by default; it must have filenames provided to it by the user in .pre-commit-config.yaml.

I want this for sorting one-thing-per-line kind of files in our services like deploy-whitelist.txt or .gitignore. 


Patch:
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
index e7f433b..c681c45 100644
--- a/.pre-commit-hooks.yaml
+++ b/.pre-commit-hooks.yaml
@@ -105,6 +105,12 @@
     entry: end-of-file-fixer
     language: python
     files: \.(asciidoc|adoc|coffee|cpp|css|c|ejs|erb|groovy|h|haml|hh|hpp|hxx|html|in|j2|jade|json|js|less|markdown|md|ml|mli|pp|py|rb|rs|R|scala|scss|sh|slim|tex|tmpl|ts|txt|yaml|yml)$
+-   id: file-contents-sorter
+    name: File Contents Sorter
+    description: Sort the lines in specified files (defaults to alphabetical). You must provide list of target files as input in your .pre-commit-config.yaml file.
+    entry: file-contents-sorter
+    language: python
+    files: '^$'
 -   id: fix-encoding-pragma
     name: Fix python encoding pragma
     language: python
diff --git a/README.md b/README.md
index 8db7eef..92fb408 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,7 @@ Add this to your `.pre-commit-config.yaml`
   with single quoted strings.
 - `end-of-file-fixer` - Makes sure files end in a newline and only a newline.
 - `fix-encoding-pragma` - Add `# -*- coding: utf-8 -*-` to the top of python files.
+- `file-contents-sorter` - Sort the lines in specified files (defaults to alphabetical). You must provide list of target files as input to it.
     - To remove the coding pragma pass `--remove` (useful in a python3-only codebase)
 - `flake8` - Run flake8 on your python files.
 - `forbid-new-submodules` - Prevent addition of new git submodules.
diff --git a/hooks.yaml b/hooks.yaml
index e7f433b..c681c45 100644
--- a/hooks.yaml
+++ b/hooks.yaml
@@ -105,6 +105,12 @@
     entry: end-of-file-fixer
     language: python
     files: \.(asciidoc|adoc|coffee|cpp|css|c|ejs|erb|groovy|h|haml|hh|hpp|hxx|html|in|j2|jade|json|js|less|markdown|md|ml|mli|pp|py|rb|rs|R|scala|scss|sh|slim|tex|tmpl|ts|txt|yaml|yml)$
+-   id: file-contents-sorter
+    name: File Contents Sorter
+    description: Sort the lines in specified files (defaults to alphabetical). You must provide list of target files as input in your .pre-commit-config.yaml file.
+    entry: file-contents-sorter
+    language: python
+    files: '^$'
 -   id: fix-encoding-pragma
     name: Fix python encoding pragma
     language: python
diff --git a/pre_commit_hooks/file_contents_sorter.py b/pre_commit_hooks/file_contents_sorter.py
new file mode 100644
index 0000000..6fa3bc0
--- /dev/null
+++ b/pre_commit_hooks/file_contents_sorter.py
@@ -0,0 +1,52 @@
+"""
+A very simple pre-commit hook that, when passed one or more filenames
+as arguments, will sort the lines in those files.
+
+An example use case for this: you have a deploy-whitelist.txt file
+in a repo that contains a list of filenames that is used to specify
+files to be included in a docker container. This file has one filename
+per line. Various users are adding/removing lines from this file; using
+this hook on that file should reduce the instances of git merge
+conflicts and keep the file nicely ordered.
+"""
+from __future__ import print_function
+
+import argparse
+
+PASS = 0
+FAIL = 1
+
+
+def sort_file_contents(f):
+    before = tuple(f)
+    after = sorted(before)
+
+    before_string = b''.join(before)
+    after_string = b''.join(after)
+
+    if before_string == after_string:
+        return PASS
+    else:
+        f.seek(0)
+        f.write(after_string)
+        f.truncate()
+        return FAIL
+
+
+def main(argv=None):
+    parser = argparse.ArgumentParser()
+    parser.add_argument('filenames', nargs='+', help='Files to sort')
+    args = parser.parse_args(argv)
+
+    retv = PASS
+
+    for arg in args.filenames:
+        with open(arg, 'rb+') as file_obj:
+            ret_for_file = sort_file_contents(file_obj)
+
+            if ret_for_file:
+                print('Sorting {}'.format(arg))
+
+            retv |= ret_for_file
+
+    return retv
diff --git a/setup.py b/setup.py
index af21e16..c5cceb7 100644
--- a/setup.py
+++ b/setup.py
@@ -49,6 +49,7 @@ setup(
             'detect-private-key = pre_commit_hooks.detect_private_key:detect_private_key',
             'double-quote-string-fixer = pre_commit_hooks.string_fixer:main',
             'end-of-file-fixer = pre_commit_hooks.end_of_file_fixer:end_of_file_fixer',
+            'file-contents-sorter = pre_commit_hooks.file_contents_sorter:main',
             'fix-encoding-pragma = pre_commit_hooks.fix_encoding_pragma:main',
             'forbid-new-submodules = pre_commit_hooks.forbid_new_submodules:main',
             'name-tests-test = pre_commit_hooks.tests_should_end_in_test:validate_files',


Test patch:
diff --git a/tests/file_contents_sorter_test.py b/tests/file_contents_sorter_test.py
new file mode 100644
index 0000000..5f4dc5b
--- /dev/null
+++ b/tests/file_contents_sorter_test.py
@@ -0,0 +1,29 @@
+import pytest
+
+from pre_commit_hooks.file_contents_sorter import FAIL
+from pre_commit_hooks.file_contents_sorter import main
+from pre_commit_hooks.file_contents_sorter import PASS
+
+
+@pytest.mark.parametrize(
+    ('input_s', 'expected_retval', 'output'),
+    (
+        (b'', PASS, b''),
+        (b'lonesome\n', PASS, b'lonesome\n'),
+        (b'missing_newline', PASS, b'missing_newline'),
+        (b'alpha\nbeta\n', PASS, b'alpha\nbeta\n'),
+        (b'beta\nalpha\n', FAIL, b'alpha\nbeta\n'),
+        (b'C\nc\n', PASS, b'C\nc\n'),
+        (b'c\nC\n', FAIL, b'C\nc\n'),
+        (b'mag ical \n tre vor\n', FAIL, b' tre vor\nmag ical \n'),
+        (b'@\n-\n_\n#\n', FAIL, b'#\n-\n@\n_\n'),
+    )
+)
+def test_integration(input_s, expected_retval, output, tmpdir):
+    path = tmpdir.join('file.txt')
+    path.write_binary(input_s)
+
+    output_retval = main([path.strpath])
+
+    assert path.read_binary() == output
+    assert output_retval == expected_retval