Black as a Git pre-commit hook (Version control integration)

Description

Git can run special scripts at various places in the Git workflow (which the system calls “hooks”).

These scripts can do whatever you want and, in theory, can help a team with their development flow.

I used to think these hooks were not very useful to teams. My belief stemmed from a team’s inability to add these hook scripts to version control in a way that would apply the scripts for every team member.

Then I found pre-commit. pre-commit makes hook scripts extremely accessible to teams. How?

pre-commit install

With that one command, pre-commit installs everything a developer needs to make hooks accessible at a team level.

pre-commit install is a bootstrapping command that inserts all the necessary scripts to let pre-commit manage it all.

├── .git
│   ├── branches
│   ├── COMMIT_EDITMSG
│   ├── config
│   ├── description
│   ├── HEAD
│   ├── hooks
│   │   ├── applypatch-msg.sample
│   │   ├── commit-msg.sample
│   │   ├── fsmonitor-watchman.sample
│   │   ├── post-update.sample
│   │   ├── pre-applypatch.sample
│   │   ├── pre-commit
│   │   ├── pre-commit.sample
│   │   ├── prepare-commit-msg.sample
│   │   ├── pre-push.sample
│   │   ├── pre-rebase.sample
│   │   ├── pre-receive.sample
│   │   └── update.sample
│   ├── index
#!/usr/bin/env python
"""File generated by pre-commit: https://pre-commit.com"""
from __future__ import print_function

import distutils.spawn
import os
import subprocess
import sys

# work around https://github.com/Homebrew/homebrew-core/issues/30445
os.environ.pop('__PYVENV_LAUNCHER__', None)

HERE = os.path.dirname(os.path.abspath(__file__))
Z40 = '0' * 40
ID_HASH = '138fd403232d2ddd5efb44317e38bf03'
# start templated
CONFIG = '.pre-commit-config.yaml'
HOOK_TYPE = 'pre-commit'
INSTALL_PYTHON = '/home/pvergain/.local/share/virtualenvs/log_face-AdGfjjyy/bin/python3.6m'
SKIP_ON_MISSING_CONFIG = False
# end templated


class EarlyExit(RuntimeError):
    pass


class FatalError(RuntimeError):
    pass


def _norm_exe(exe):
    """Necessary for shebang support on windows.

    roughly lifted from `identify.identify.parse_shebang`
    """
    with open(exe, 'rb') as f:
        if f.read(2) != b'#!':
            return ()
        try:
            first_line = f.readline().decode('UTF-8')
        except UnicodeDecodeError:
            return ()

        cmd = first_line.split()
        if cmd[0] == '/usr/bin/env':
            del cmd[0]
        return tuple(cmd)


def _run_legacy():
    if HOOK_TYPE == 'pre-push':
        stdin = getattr(sys.stdin, 'buffer', sys.stdin).read()
    else:
        stdin = None

    legacy_hook = os.path.join(HERE, '{}.legacy'.format(HOOK_TYPE))
    if os.access(legacy_hook, os.X_OK):
        cmd = _norm_exe(legacy_hook) + (legacy_hook,) + tuple(sys.argv[1:])
        proc = subprocess.Popen(cmd, stdin=subprocess.PIPE if stdin else None)
        proc.communicate(stdin)
        return proc.returncode, stdin
    else:
        return 0, stdin


def _validate_config():
    cmd = ('git', 'rev-parse', '--show-toplevel')
    top_level = subprocess.check_output(cmd).decode('UTF-8').strip()
    cfg = os.path.join(top_level, CONFIG)
    if os.path.isfile(cfg):
        pass
    elif SKIP_ON_MISSING_CONFIG or os.getenv('PRE_COMMIT_ALLOW_NO_CONFIG'):
        print(
            '`{}` config file not found. '
            'Skipping `pre-commit`.'.format(CONFIG),
        )
        raise EarlyExit()
    else:
        raise FatalError(
            'No {} file was found\n'
            '- To temporarily silence this, run '
            '`PRE_COMMIT_ALLOW_NO_CONFIG=1 git ...`\n'
            '- To permanently silence this, install pre-commit with the '
            '--allow-missing-config option\n'
            '- To uninstall pre-commit run '
            '`pre-commit uninstall`'.format(CONFIG),
        )


def _exe():
    with open(os.devnull, 'wb') as devnull:
        for exe in (INSTALL_PYTHON, sys.executable):
            try:
                if not subprocess.call(
                        (exe, '-c', 'import pre_commit.main'),
                        stdout=devnull, stderr=devnull,
                ):
                    return (exe, '-m', 'pre_commit.main', 'run')
            except OSError:
                pass

    if distutils.spawn.find_executable('pre-commit'):
        return ('pre-commit', 'run')

    raise FatalError(
        '`pre-commit` not found.  Did you forget to activate your virtualenv?',
    )


def _rev_exists(rev):
    return not subprocess.call(('git', 'rev-list', '--quiet', rev))


def _pre_push(stdin):
    remote = sys.argv[1]

    opts = ()
    for line in stdin.decode('UTF-8').splitlines():
        _, local_sha, _, remote_sha = line.split()
        if local_sha == Z40:
            continue
        elif remote_sha != Z40 and _rev_exists(remote_sha):
            opts = ('--origin', local_sha, '--source', remote_sha)
        else:
            # ancestors not found in remote
            ancestors = subprocess.check_output((
                'git', 'rev-list', local_sha, '--topo-order', '--reverse',
                '--not', '--remotes={}'.format(remote),
            )).decode().strip()
            if not ancestors:
                continue
            else:
                first_ancestor = ancestors.splitlines()[0]
                cmd = ('git', 'rev-list', '--max-parents=0', local_sha)
                roots = set(subprocess.check_output(cmd).decode().splitlines())
                if first_ancestor in roots:
                    # pushing the whole tree including root commit
                    opts = ('--all-files',)
                else:
                    cmd = ('git', 'rev-parse', '{}^'.format(first_ancestor))
                    source = subprocess.check_output(cmd).decode().strip()
                    opts = ('--origin', local_sha, '--source', source)

    if opts:
        return opts
    else:
        # An attempt to push an empty changeset
        raise EarlyExit()


def _opts(stdin):
    fns = {
        'commit-msg': lambda _: ('--commit-msg-filename', sys.argv[1]),
        'pre-commit': lambda _: (),
        'pre-push': _pre_push,
    }
    stage = HOOK_TYPE.replace('pre-', '')
    return ('--config', CONFIG, '--hook-stage', stage) + fns[HOOK_TYPE](stdin)


def main():
    retv, stdin = _run_legacy()
    try:
        _validate_config()
        return retv | subprocess.call(_exe() + _opts(stdin))
    except EarlyExit:
        return retv
    except FatalError as e:
        print(e.args[0])
        return 1


if __name__ == '__main__':
    exit(main())

.pre-commit-config.yaml file

With that one command, pre-commit installs everything a developer needs to make hooks accessible at a team level. pre-commit install is a bootstrapping command that inserts all the necessary scripts to let pre-commit manage it all.

After the install command executes, pre-commit will use a .pre-commit-config.yaml file to make decisions about what to do at the various Git hook points.

This gives power to a team since .pre-commit-config.yaml can be added to source control and the team can make shared configuration choices.

One of those configurations choices is… drumroll please… Black configuration!

The details of this setup are explained in the Version control integration section of the documentation, but the short version is to add the following to .pre-commit-config.yaml

repos:
  - repo: https://github.com/ambv/black
    rev: stable
    hooks:
    - id: black
      language_version: python3.7

For any developer with pre-commit installed, commiting with Git will generate output like:

$ git commit
black....................................................................Failed
hookid: black

Files were modified by this hook. Additional output:

reformatted my_package/some_file.py
All done! ✨ 🍰 ✨
1 file reformatted.

It’s nice to catch code formatting problems in CI, and it’s even nicer to catch them before your code is committed to source control !

Version control integration

Use pre-commit. Once you have it installed, add this to the .pre-commit-config.yaml in your repository

repos:
  - repo: https://github.com/ambv/black
    rev: stable
    hooks:
    - id: black
      language_version: python3.6

Then run pre-commit install and you’re ready to go.