forked from discord/pre-commit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patheditor.py
More file actions
206 lines (168 loc) · 8.03 KB
/
editor.py
File metadata and controls
206 lines (168 loc) · 8.03 KB
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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
import argparse
import concurrent.futures
import contextlib
import platform
import shlex
import subprocess
from pathlib import Path
from typing import Any
from typing import Callable
from typing import List
from typing import TYPE_CHECKING
from typing import TypeVar
if TYPE_CHECKING:
from typing import NoReturn
import psutil
from pre_commit import git
from pre_commit import output
from pre_commit.metrics import monitor
T = TypeVar('T')
# Keeping the file name the same as git's makes it more likely that editors will set the file type
# correctly when opening it.
COMMIT_MESSAGE_DRAFT_PATH = Path('.git/pre-commit/COMMIT_EDITMSG')
COMMIT_MESSAGE_EXPIRED_DRAFT_PATH = Path('.git/pre-commit/COMMIT_EDITMSG_OLD')
COMMIT_MESSAGE_HEADER = """
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
"""
def should_run_concurrently(hook_stage: str) -> bool:
return (
hook_stage == 'commit' and
_is_editor_script_configured() and
_should_open_editor() and
platform.system() != 'Windows'
)
def run_concurrently(fun: Callable[..., T], *args: Any) -> T:
# Allow user to enter commit message concurrently with running pre-commit hooks to lower
# wait times.
output.write_line('Waiting for your editor (pre-commit hooks running in background)...')
# Run git commands before starting hooks to avoid race conditions and git lock errors.
commit_message_template = _get_commit_message_template()
with contextlib.ExitStack() as paused_stdout_stack:
paused_stdout_stack.enter_context(output.paused_stdout())
def launch_editor() -> None:
_edit_commit_message(commit_message_template)
paused_stdout_stack.close() # Resume terminal output as soon as the editor closes
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
ex.submit(launch_editor)
retval_future = ex.submit(fun, *args)
return retval_future.result()
raise RuntimeError('unreachable')
def should_clean_draft(hook_stage: str) -> bool:
# We need to clean up the draft if one exists but we're not editing it, otherwise it's at risk
# of being committed without further user interaction.
return (
hook_stage == 'commit' and
_is_editor_script_configured() and
not _should_open_editor() and
COMMIT_MESSAGE_DRAFT_PATH.exists() and
platform.system != 'Windows'
)
def clean_draft() -> None:
COMMIT_MESSAGE_DRAFT_PATH.rename(COMMIT_MESSAGE_EXPIRED_DRAFT_PATH)
class ParseFailed(BaseException):
pass
class NoExitParser(argparse.ArgumentParser):
# exit_on_error option only exists in 3.9+, so we have to do this ourselves.
def error(self, _: str) -> 'NoReturn':
raise ParseFailed()
def _should_open_editor() -> bool:
git_invocation = psutil.Process().parent().cmdline()
if len(git_invocation) < 2:
return False
git_binary = git_invocation[0]
git_command = git_invocation[1]
if Path(git_binary).name != 'git' and git_command != 'commit':
# Some other command is being run; let's be conservative.
return False
try:
# Teach the parser about all the allowable arguments to git commit and have it fail if any
# others are present.
parser = NoExitParser(add_help=False)
def allowed_flag(*args: Any) -> None:
parser.add_argument(*args, action='store_true')
def allowed_option(*args: Any) -> None:
parser.add_argument(*args)
allowed_flag('-a', '--all')
allowed_flag('-p', '--patch')
allowed_flag('--reset-author')
allowed_flag('--branch')
allowed_flag('--allow-empty')
allowed_flag('--allow-empty-message')
allowed_flag('--no-post-rewrite')
allowed_flag('--status') # We always include status.
allowed_flag('-i', '--include')
allowed_flag('-o', '--only')
allowed_flag('-q', '--quiet')
allowed_option('--author')
allowed_option('--date')
allowed_option('--cleanup')
allowed_option('--pathspec-from-file')
allowed_flag('--pathspec-file-nul')
# == Disallowed arguments ==
# -m <msg>, --message=<msg> # Alternate means of supplying a commit message.
# -F <file>, --file=<file> # Alternate means of supplying a commit message.
# -C <commit>, --reuse-message=<commit> # Alternate means of supplying a commit message.
# -c <commit>, --reedit-message=<commit> # Alternate means of supplying a commit message.
# --amend # We don't support showing the correct set of changes or previous commit message.
# --fixup=<commit> # We don't support automatically constructing a fixup commit message.
# --squash=<commit> # We don't support automatically constructing a squash commit message.
# --dry-run # No commit actually made.
# --short # Implies --dry-run.
# --porcelain # Implies --dry-run.
# --long # Implies --dry-run.
# -z, --null # Intended to be used with --short or --porcelain.
# -t <file>, --template=<file> # We don't support template files.
# -s, --signoff # We don't support adding a signoff line.
# -n, --no-verify # We shouldn't be called if this is passed.
# -e, --edit # We don't support editing a commit message supplied from other sources.
# --no-edit # Explicitly requests not launching an editor.
# -u[<mode>], --untracked-files[=<mode>] # We don't support showing untracked files differently.
# -v, --verbose # We don't support verbose git status.
# --no-status # We don't support not including the status.
# -S, --gpg-sign[=<keyid>], --no-gpg-sign # Optional arg is annoying to support; rarely used.
parser.add_argument('pathspec', nargs='+')
parser.parse_args(git_invocation)
# Parse succeeded -- no unknown args.
return True
except ParseFailed:
return False
def _is_editor_script_configured() -> bool:
editor_script_path = git.get_editor_script_path()
local_git_editor = _get_local_git_editor()
return (
bool(local_git_editor) and
local_git_editor[0] == editor_script_path and
Path(editor_script_path).exists()
)
def _edit_commit_message(template: str) -> None:
if not COMMIT_MESSAGE_DRAFT_PATH.exists():
COMMIT_MESSAGE_DRAFT_PATH.parent.mkdir(parents=True, exist_ok=True)
COMMIT_MESSAGE_DRAFT_PATH.write_text(template)
else:
# Update commit draft with new status
commit_draft = COMMIT_MESSAGE_DRAFT_PATH.read_text()
commit_draft = '\n'.join(line for line in commit_draft.splitlines() if not line.startswith('#'))
COMMIT_MESSAGE_DRAFT_PATH.write_text(commit_draft + template)
git_editor = _get_global_git_editor() # Doesn't run in this repo, so the concurrency won't cause git lock errors.
with monitor.trace('precommit.editor'):
# For some editors (e.g. vim), it's important that stdin be an interactive terminal.
# /dev/tty is a synonym for our process's controlling terminal.
with open('/dev/tty') as stdin:
subprocess.call(git_editor + [str(COMMIT_MESSAGE_DRAFT_PATH)], stdin=stdin)
def _get_local_git_editor() -> List[str]:
editor_str = subprocess.run(['git', 'var', 'GIT_EDITOR'], check=True, capture_output=True).stdout.decode('utf-8')
return shlex.split(editor_str)
def _get_global_git_editor() -> List[str]:
# The repo-local editor has been set to a special script. This gets the globally configured
# editor.
editor_str = subprocess.run(
['git', 'var', 'GIT_EDITOR'], cwd='/',
check=True, capture_output=True,
).stdout.decode('utf-8')
return shlex.split(editor_str)
def _get_commit_message_template() -> str:
status = subprocess.run(['git', 'status'], check=True, capture_output=True).stdout.decode('utf-8')
commented_status = '\n'.join('# ' + line for line in status.splitlines())
return COMMIT_MESSAGE_HEADER + commented_status