summaryrefslogtreecommitdiffstats
path: root/pre_commit/staged_files_only.py
blob: e1f81ba96b0c196022b2d183c3b6e68ace4aeb85 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
from __future__ import annotations

import contextlib
import logging
import os.path
import time
from collections.abc import Generator

from pre_commit import git
from pre_commit.errors import FatalError
from pre_commit.util import CalledProcessError
from pre_commit.util import cmd_output
from pre_commit.util import cmd_output_b
from pre_commit.xargs import xargs


logger = logging.getLogger('pre_commit')

# without forcing submodule.recurse=0, changes in nested submodules will be
# discarded if `submodule.recurse=1` is configured
# we choose this instead of `--no-recurse-submodules` because it works on
# versions of git before that option was added to `git checkout`
_CHECKOUT_CMD = ('git', '-c', 'submodule.recurse=0', 'checkout', '--', '.')


def _git_apply(patch: str) -> None:
    args = ('apply', '--whitespace=nowarn', patch)
    try:
        cmd_output_b('git', *args)
    except CalledProcessError:
        # Retry with autocrlf=false -- see #570
        cmd_output_b('git', '-c', 'core.autocrlf=false', *args)


@contextlib.contextmanager
def _intent_to_add_cleared() -> Generator[None, None, None]:
    intent_to_add = git.intent_to_add_files()
    if intent_to_add:
        logger.warning('Unstaged intent-to-add files detected.')

        xargs(('git', 'rm', '--cached', '--'), intent_to_add)
        try:
            yield
        finally:
            xargs(('git', 'add', '--intent-to-add', '--'), intent_to_add)
    else:
        yield


@contextlib.contextmanager
def _unstaged_changes_cleared(patch_dir: str) -> Generator[None, None, None]:
    tree = cmd_output('git', 'write-tree')[1].strip()
    diff_cmd = (
        'git', 'diff-index', '--ignore-submodules', '--binary',
        '--exit-code', '--no-color', '--no-ext-diff', tree, '--',
    )
    retcode, diff_stdout, diff_stderr = cmd_output_b(*diff_cmd, check=False)
    if retcode == 0:
        # There weren't any staged files so we don't need to do anything
        # special
        yield
    elif retcode == 1 and not diff_stdout.strip():
        # due to behaviour (probably a bug?) in git with crlf endings and
        # autocrlf set to either `true` or `input` sometimes git will refuse
        # to show a crlf-only diff to us :(
        yield
    elif retcode == 1 and diff_stdout.strip():
        patch_filename = f'patch{int(time.time())}-{os.getpid()}'
        patch_filename = os.path.join(patch_dir, patch_filename)
        logger.warning('Unstaged files detected.')
        logger.info(f'Stashing unstaged files to {patch_filename}.')
        # Save the current unstaged changes as a patch
        os.makedirs(patch_dir, exist_ok=True)
        with open(patch_filename, 'wb') as patch_file:
            patch_file.write(diff_stdout)

        # prevent recursive post-checkout hooks (#1418)
        no_checkout_env = dict(os.environ, _PRE_COMMIT_SKIP_POST_CHECKOUT='1')

        try:
            cmd_output_b(*_CHECKOUT_CMD, env=no_checkout_env)
            yield
        finally:
            # Try to apply the patch we saved
            try:
                _git_apply(patch_filename)
            except CalledProcessError:
                logger.warning(
                    'Stashed changes conflicted with hook auto-fixes... '
                    'Rolling back fixes...',
                )
                # We failed to apply the patch, presumably due to fixes made
                # by hooks.
                # Roll back the changes made by hooks.
                cmd_output_b(*_CHECKOUT_CMD, env=no_checkout_env)
                _git_apply(patch_filename)

            logger.info(f'Restored changes from {patch_filename}.')
    else:  # pragma: win32 no cover
        # some error occurred while requesting the diff
        e = CalledProcessError(retcode, diff_cmd, b'', diff_stderr)
        raise FatalError(
            f'pre-commit failed to diff -- perhaps due to permissions?\n\n{e}',
        )


@contextlib.contextmanager
def staged_files_only(patch_dir: str) -> Generator[None, None, None]:
    """Clear any unstaged changes from the git working directory inside this
    context.
    """
    with _intent_to_add_cleared(), _unstaged_changes_cleared(patch_dir):
        yield