git-format-staged 3.1.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/git-format-staged +224 -156
  2. package/package.json +1 -8
package/git-format-staged CHANGED
@@ -13,67 +13,100 @@
13
13
  #
14
14
  # Original author: Jesse Hallett <jesse@sitr.us>
15
15
 
16
- from __future__ import print_function
16
+ from __future__ import annotations
17
17
  import argparse
18
+ from collections.abc import Sequence
18
19
  from fnmatch import fnmatch
19
20
  from gettext import gettext as _
20
- import os
21
21
  import re
22
22
  import shlex
23
23
  import subprocess
24
24
  import sys
25
+ from typing import NoReturn, Protocol, cast
25
26
 
26
- # The string 3.1.2 is replaced during the publish process.
27
- VERSION = '3.1.2'
27
+
28
+ # The string 4.0.0 is replaced during the publish process.
29
+ VERSION = "4.0.0"
28
30
  PROG = sys.argv[0]
29
31
 
30
- def info(msg):
32
+
33
+ def info(msg: str):
31
34
  print(msg, file=sys.stdout)
32
35
 
33
- def warn(msg):
34
- print('{}: warning: {}'.format(PROG, msg), file=sys.stderr)
35
36
 
36
- def fatal(msg):
37
- print('{}: error: {}'.format(PROG, msg), file=sys.stderr)
37
+ def warn(msg: str):
38
+ print("{}: warning: {}".format(PROG, msg), file=sys.stderr)
39
+
40
+
41
+ def fatal(msg: str):
42
+ print("{}: error: {}".format(PROG, msg), file=sys.stderr)
38
43
  exit(1)
39
44
 
40
- def format_staged_files(file_patterns, formatter, git_root, update_working_tree=True, write=True, verbose=False):
45
+
46
+ def format_staged_files(
47
+ file_patterns: Sequence[str],
48
+ formatter: str,
49
+ update_working_tree: bool = True,
50
+ write: bool = True,
51
+ verbose: bool = False,
52
+ ):
41
53
  common_opts = [
42
- '--cached',
43
- '--diff-filter=AM', # select only file additions and modifications
44
- '--no-renames',
45
- 'HEAD',
54
+ "--cached",
55
+ "--diff-filter=AM", # select only file additions and modifications
56
+ "--no-renames",
57
+ "HEAD",
46
58
  ]
47
59
 
48
60
  try:
49
61
  staged_files = {
50
- path.decode('utf-8')
51
- for path in subprocess.check_output(['git', 'diff', '--name-only'] + common_opts).splitlines()
62
+ path.decode("utf-8")
63
+ for path in subprocess.check_output(
64
+ ["git", "diff", "--name-only"] + common_opts
65
+ ).splitlines()
52
66
  }
53
67
 
54
- output = subprocess.check_output(['git', 'diff-index'] + common_opts)
68
+ output = subprocess.check_output(["git", "diff-index"] + common_opts)
55
69
  for line in output.splitlines():
56
- entry = parse_diff(line.decode('utf-8'))
57
- entry_path = normalize_path(entry['src_path'], relative_to=git_root)
58
- if entry['dst_mode'] == '120000':
70
+ entry = DiffIndexEntry(line.decode("utf-8"))
71
+ if entry.dst_mode == "120000":
59
72
  # Do not process symlinks
60
73
  continue
61
- if not (matches_some_path(file_patterns, entry_path)):
74
+ if not (matches_some_path(file_patterns, entry.src_path)):
62
75
  continue
63
- if entry['src_mode'] is None and object_is_empty(entry['dst_hash']) and entry['src_path'] not in staged_files:
76
+ if (
77
+ entry.src_mode is None
78
+ and (not entry.dst_hash or object_is_empty(entry.dst_hash))
79
+ and entry.src_path not in staged_files
80
+ ):
64
81
  # File is not staged, it's tracked only with `--intent-to-add` and won't get committed
65
82
  continue
66
- if format_file_in_index(formatter, entry, update_working_tree=update_working_tree, write=write, verbose=verbose):
67
- info('Reformatted {} with {}'.format(entry['src_path'], formatter))
83
+ if format_file_in_index(
84
+ formatter,
85
+ entry,
86
+ update_working_tree=update_working_tree,
87
+ write=write,
88
+ verbose=verbose,
89
+ ):
90
+ info("Reformatted {} with {}".format(entry.src_path, formatter))
68
91
  except Exception as err:
69
92
  fatal(str(err))
70
93
 
94
+
71
95
  # Run formatter on file in the git index. Creates a new git object with the
72
96
  # result, and replaces the content of the file in the index with that object.
73
97
  # Returns hash of the new object if formatting produced any changes.
74
- def format_file_in_index(formatter, diff_entry, update_working_tree=True, write=True, verbose=False):
75
- orig_hash = diff_entry['dst_hash']
76
- new_hash = format_object(formatter, orig_hash, diff_entry['src_path'], verbose=verbose)
98
+ def format_file_in_index(
99
+ formatter: str,
100
+ diff_entry: "DiffIndexEntry",
101
+ update_working_tree: bool = True,
102
+ write: bool = True,
103
+ verbose: bool = False,
104
+ ):
105
+ orig_hash = diff_entry.dst_hash
106
+ if not orig_hash:
107
+ return None
108
+
109
+ new_hash = format_object(formatter, orig_hash, diff_entry.src_path, verbose=verbose)
77
110
 
78
111
  # If the new hash is the same then the formatter did not make any changes.
79
112
  if not write or new_hash == orig_hash:
@@ -89,203 +122,238 @@ def format_file_in_index(formatter, diff_entry, update_working_tree=True, write=
89
122
 
90
123
  if update_working_tree:
91
124
  try:
92
- patch_working_file(diff_entry['src_path'], orig_hash, new_hash)
125
+ patch_working_file(diff_entry.src_path, orig_hash, new_hash)
93
126
  except Exception as err:
94
127
  # Errors patching working tree files are not fatal
95
128
  warn(str(err))
96
129
 
97
130
  return new_hash
98
131
 
132
+
99
133
  # Match {}, and to avoid breaking quoting from shlex also match and remove surrounding quotes. This
100
134
  # is important for backward compatibility because previous version of git-format-staged did not use
101
135
  # shlex quoting, and required manual quoting.
102
136
  file_path_placeholder = re.compile(r"(['\"]?)\{\}(\1)")
103
137
 
138
+
104
139
  # Run formatter on a git blob identified by its hash. Writes output to a new git
105
140
  # blob, and returns the hash of the new blob.
106
- def format_object(formatter, object_hash, file_path, verbose=False):
141
+ def format_object(
142
+ formatter: str, object_hash: str, file_path: str, verbose: bool = False
143
+ ) -> str:
107
144
  get_content = subprocess.Popen(
108
- ['git', 'cat-file', '-p', object_hash],
109
- stdout=subprocess.PIPE
110
- )
145
+ ["git", "cat-file", "-p", object_hash], stdout=subprocess.PIPE
146
+ )
111
147
  command = re.sub(file_path_placeholder, shlex.quote(file_path), formatter)
112
148
  if verbose:
113
149
  info(command)
114
150
  format_content = subprocess.Popen(
115
- command,
116
- shell=True,
117
- stdin=get_content.stdout,
118
- stdout=subprocess.PIPE
119
- )
151
+ command, shell=True, stdin=get_content.stdout, stdout=subprocess.PIPE
152
+ )
120
153
  write_object = subprocess.Popen(
121
- ['git', 'hash-object', '-w', '--stdin'],
122
- stdin=format_content.stdout,
123
- stdout=subprocess.PIPE
124
- )
154
+ ["git", "hash-object", "-w", "--stdin"],
155
+ stdin=format_content.stdout,
156
+ stdout=subprocess.PIPE,
157
+ )
125
158
 
126
159
  # Read output from the final process first to avoid deadlock if intermediate processes produce
127
160
  # large outputs that would fill pipe buffers
128
- new_hash, _ = write_object.communicate()
161
+ new_hash, _err = write_object.communicate()
129
162
 
130
163
  if get_content.wait() != 0:
131
- raise ValueError('unable to read file content from object database: ' + object_hash)
164
+ raise ValueError(
165
+ "unable to read file content from object database: " + object_hash
166
+ )
132
167
 
133
168
  if format_content.wait() != 0:
134
- raise Exception('formatter exited with non-zero status')
169
+ raise Exception("formatter exited with non-zero status")
135
170
 
136
171
  if write_object.returncode != 0:
137
- raise Exception('unable to write formatted content to object database')
172
+ raise Exception("unable to write formatted content to object database")
138
173
 
139
- return new_hash.decode('utf-8').rstrip()
174
+ return new_hash.decode("utf-8").rstrip()
140
175
 
141
- def object_is_empty(object_hash):
176
+
177
+ def object_is_empty(object_hash: str) -> bool:
142
178
  get_content = subprocess.Popen(
143
- ['git', 'cat-file', '-p', object_hash],
144
- stdout=subprocess.PIPE
145
- )
146
- content, err = get_content.communicate()
179
+ ["git", "cat-file", "-p", object_hash], stdout=subprocess.PIPE
180
+ )
181
+ content, _err = get_content.communicate()
147
182
 
148
183
  if get_content.returncode != 0:
149
- raise Exception('unable to verify content of formatted object')
184
+ raise Exception("unable to verify content of formatted object")
150
185
 
151
186
  return not content
152
187
 
153
- def replace_file_in_index(diff_entry, new_object_hash):
154
- subprocess.check_call(['git', 'update-index',
155
- '--cacheinfo', '{},{},{}'.format(
156
- diff_entry['dst_mode'],
157
- new_object_hash,
158
- diff_entry['src_path']
159
- )])
160
188
 
161
- def patch_working_file(path, orig_object_hash, new_object_hash):
189
+ def replace_file_in_index(diff_entry: "DiffIndexEntry", new_object_hash: str):
190
+ _ = subprocess.check_call(
191
+ [
192
+ "git",
193
+ "update-index",
194
+ "--cacheinfo",
195
+ "{},{},{}".format(
196
+ diff_entry.dst_mode, new_object_hash, diff_entry.src_path
197
+ ),
198
+ ]
199
+ )
200
+
201
+
202
+ def patch_working_file(path: str, orig_object_hash: str, new_object_hash: str):
162
203
  patch = subprocess.check_output(
163
- ['git', 'diff', '--no-ext-diff', '--color=never', orig_object_hash, new_object_hash]
164
- )
204
+ [
205
+ "git",
206
+ "diff",
207
+ "--no-ext-diff",
208
+ "--color=never",
209
+ orig_object_hash,
210
+ new_object_hash,
211
+ ]
212
+ )
165
213
 
166
214
  # Substitute object hashes in patch header with path to working tree file
167
- patch_b = patch.replace(orig_object_hash.encode(), path.encode()).replace(new_object_hash.encode(), path.encode())
215
+ patch_b = patch.replace(orig_object_hash.encode(), path.encode()).replace(
216
+ new_object_hash.encode(), path.encode()
217
+ )
168
218
 
169
219
  apply_patch = subprocess.Popen(
170
- ['git', 'apply', '-'],
171
- stdin=subprocess.PIPE,
172
- stdout=subprocess.PIPE,
173
- stderr=subprocess.PIPE
174
- )
220
+ ["git", "apply", "-"],
221
+ stdin=subprocess.PIPE,
222
+ stdout=subprocess.PIPE,
223
+ stderr=subprocess.PIPE,
224
+ )
175
225
 
176
- output, err = apply_patch.communicate(input=patch_b)
226
+ _output, _err = apply_patch.communicate(input=patch_b)
177
227
 
178
228
  if apply_patch.returncode != 0:
179
- raise Exception('could not apply formatting changes to working tree file {}'.format(path))
229
+ raise Exception(
230
+ "could not apply formatting changes to working tree file {}".format(path)
231
+ )
232
+
180
233
 
181
234
  # Format: src_mode dst_mode src_hash dst_hash status/score? src_path dst_path?
182
- diff_pat = re.compile(r'^:(\d+) (\d+) ([a-f0-9]+) ([a-f0-9]+) ([A-Z])(\d+)?\t([^\t]+)(?:\t([^\t]+))?$')
183
-
184
- # Parse output from `git diff-index`
185
- def parse_diff(diff):
186
- m = diff_pat.match(diff)
187
- if not m:
188
- raise ValueError('Failed to parse diff-index line: ' + diff)
189
- return {
190
- 'src_mode': unless_zeroed(m.group(1)),
191
- 'dst_mode': unless_zeroed(m.group(2)),
192
- 'src_hash': unless_zeroed(m.group(3)),
193
- 'dst_hash': unless_zeroed(m.group(4)),
194
- 'status': m.group(5),
195
- 'score': int(m.group(6)) if m.group(6) else None,
196
- 'src_path': m.group(7),
197
- 'dst_path': m.group(8)
198
- }
199
-
200
- zeroed_pat = re.compile(r'^0+$')
235
+ diff_pat = re.compile(
236
+ r"^:(?P<src_mode>\d+) (?P<dst_mode>\d+) (?P<src_hash>[a-f0-9]+) (?P<dst_hash>[a-f0-9]+) (?P<status>[A-Z])(?P<score>\d+)?\t(?P<src_path>[^\t]+)(?:\t(?P<dst_path>[^\t]+))?$"
237
+ )
238
+
239
+
240
+ class DiffIndexEntry:
241
+ src_mode: str | None
242
+ dst_mode: str | None
243
+ src_hash: str | None
244
+ dst_hash: str | None
245
+ status: str
246
+ score: int | None
247
+ src_path: str
248
+ dst_path: str | None
249
+
250
+ def __init__(self, diff_index_output_line: str):
251
+ "Parse a line of output from `git diff-index`"
252
+ m = diff_pat.match(diff_index_output_line)
253
+ if not m:
254
+ raise ValueError(
255
+ "Failed to parse diff-index line: " + diff_index_output_line
256
+ )
257
+ self.src_mode = unless_zeroed(m.group("src_mode"))
258
+ self.dst_mode = unless_zeroed(m.group("dst_mode"))
259
+ self.src_hash = unless_zeroed(m.group("src_hash"))
260
+ self.dst_hash = unless_zeroed(m.group("dst_hash"))
261
+ self.status = m.group("status")
262
+ self.score = int(m.group("score")) if m.group("score") else None
263
+ self.src_path = m.group("src_path")
264
+ self.dst_path = m.group("dst_path")
265
+
266
+
267
+ zeroed_pat = re.compile(r"^0+$")
268
+
201
269
 
202
270
  # Returns the argument unless the argument is a string of zeroes, in which case
203
271
  # returns `None`
204
- def unless_zeroed(s):
272
+ def unless_zeroed(s: str) -> str | None:
205
273
  return s if not zeroed_pat.match(s) else None
206
274
 
207
- def get_git_root():
208
- return subprocess.check_output(
209
- ['git', 'rev-parse', '--show-toplevel']
210
- ).decode('utf-8').rstrip()
211
275
 
212
- def normalize_path(p, relative_to=None):
213
- return os.path.abspath(
214
- os.path.join(relative_to, p) if relative_to else p
215
- )
216
-
217
- def matches_some_path(patterns, target):
276
+ def matches_some_path(patterns: Sequence[str], target: str) -> bool:
218
277
  is_match = False
219
278
  for signed_pattern in patterns:
220
279
  (is_pattern_positive, pattern) = from_signed_pattern(signed_pattern)
221
- if fnmatch(target, normalize_path(pattern)):
280
+ if fnmatch(target, pattern):
222
281
  is_match = is_pattern_positive
223
282
  return is_match
224
283
 
284
+
225
285
  # Checks for a '!' as the first character of a pattern, returns the rest of the
226
286
  # pattern in a tuple. The tuple takes the form (is_pattern_positive, pattern).
227
287
  # For example:
228
288
  # from_signed_pattern('!pat') == (False, 'pat')
229
289
  # from_signed_pattern('pat') == (True, 'pat')
230
- def from_signed_pattern(pattern):
231
- if pattern[0] == '!':
290
+ def from_signed_pattern(pattern: str) -> tuple[bool, str]:
291
+ if pattern[0] == "!":
232
292
  return (False, pattern[1:])
233
293
  else:
234
294
  return (True, pattern)
235
295
 
296
+
236
297
  class CustomArgumentParser(argparse.ArgumentParser):
237
- def parse_args(self, args=None, namespace=None):
238
- args, argv = self.parse_known_args(args, namespace)
239
- if argv:
240
- msg = argparse._(
241
- 'unrecognized arguments: %s. Do you need to quote your formatter command?'
242
- )
243
- self.error(msg % ' '.join(argv))
244
- return args
245
-
246
- if __name__ == '__main__':
298
+ def error( # pyright: ignore[reportImplicitOverride]
299
+ self, message: str
300
+ ) -> NoReturn:
301
+ if message.startswith("unrecognized arguments:"):
302
+ message += " Do you need to quote your formatter command?"
303
+ super().error(message)
304
+
305
+
306
+ class Args(Protocol):
307
+ formatter: str
308
+ no_update_working_tree: bool
309
+ no_write: bool
310
+ verbose: bool
311
+ files: Sequence[str]
312
+
313
+
314
+ if __name__ == "__main__":
247
315
  parser = CustomArgumentParser(
248
- description='Transform staged files using a formatting command that accepts content via stdin and produces a result via stdout.',
249
- epilog='Example: %(prog)s --formatter "prettier --stdin-filepath {}" "src/*.js" "test/*.js"'
250
- )
251
- parser.add_argument(
252
- '--formatter', '-f',
253
- required=True,
254
- help='Shell command to format files, will run once per file. Occurrences of the placeholder `{}` will be replaced with a path to the file being formatted (with appropriate quoting). (Example: "prettier --stdin-filepath {}")'
255
- )
256
- parser.add_argument(
257
- '--no-update-working-tree',
258
- action='store_true',
259
- help='By default formatting changes made to staged file content will also be applied to working tree files via a patch. This option disables that behavior, leaving working tree files untouched.'
260
- )
261
- parser.add_argument(
262
- '--no-write',
263
- action='store_true',
264
- help='Prevents %(prog)s from modifying staged or working tree files. You can use this option to check staged changes with a linter instead of formatting. With this option stdout from the formatter command is ignored. Example: %(prog)s --no-write -f "eslint --stdin --stdin-filename {} >&2" "*.js"'
265
- )
266
- parser.add_argument(
267
- '--version',
268
- action='version',
269
- version='%(prog)s version {}'.format(VERSION),
270
- help='Display version of %(prog)s'
271
- )
272
- parser.add_argument(
273
- '--verbose',
274
- help='Show the formatting commands that are running',
275
- action='store_true'
276
- )
277
- parser.add_argument(
278
- 'files',
279
- nargs='+',
280
- help='Patterns that specify files to format. The formatter will only transform staged files that are given here. Patterns may be literal file paths, or globs which will be tested against staged file paths using Python\'s fnmatch function. For example "src/*.js" will match all files with a .js extension in src/ and its subdirectories. Patterns may be negated to exclude files using a "!" character. Patterns are evaluated left-to-right. (Example: "main.js" "src/*.js" "test/*.js" "!test/todo/*")'
281
- )
282
- args = parser.parse_args()
283
- files = vars(args)['files']
316
+ description="Transform staged files using a formatting command that accepts content via stdin and produces a result via stdout.",
317
+ epilog='Example: %(prog)s --formatter "prettier --stdin-filepath {}" "src/*.js" "test/*.js"',
318
+ )
319
+ _ = parser.add_argument(
320
+ "--formatter",
321
+ "-f",
322
+ required=True,
323
+ help='Shell command to format files, will run once per file. Occurrences of the placeholder `{}` will be replaced with a path to the file being formatted (with appropriate quoting). (Example: "prettier --stdin-filepath {}")',
324
+ )
325
+ _ = parser.add_argument(
326
+ "--no-update-working-tree",
327
+ action="store_true",
328
+ help="By default formatting changes made to staged file content will also be applied to working tree files via a patch. This option disables that behavior, leaving working tree files untouched.",
329
+ )
330
+ _ = parser.add_argument(
331
+ "--no-write",
332
+ action="store_true",
333
+ help='Prevents %(prog)s from modifying staged or working tree files. You can use this option to check staged changes with a linter instead of formatting. With this option stdout from the formatter command is ignored. Example: %(prog)s --no-write -f "eslint --stdin --stdin-filename {} >&2" "*.js"',
334
+ )
335
+ _ = parser.add_argument(
336
+ "--version",
337
+ action="version",
338
+ version="%(prog)s version {}".format(VERSION),
339
+ help="Display version of %(prog)s",
340
+ )
341
+ _ = parser.add_argument(
342
+ "--verbose",
343
+ help="Show the formatting commands that are running",
344
+ action="store_true",
345
+ )
346
+ _ = parser.add_argument(
347
+ "files",
348
+ nargs="+",
349
+ help='Patterns that specify files to format. The formatter will only transform staged files that are given here. Patterns may be literal file paths, or globs which will be tested against staged file paths using Python\'s fnmatch function. Patterns must be relative to the git repository root. For example "src/*.js" will match all files with a .js extension in src/ and its subdirectories. Patterns may be negated to exclude files using a "!" character. Patterns are evaluated left-to-right. (Example: "main.js" "src/*.js" "test/*.js" "!test/todo/*")',
350
+ )
351
+ args = cast(Args, parser.parse_args()) # pyright: ignore[reportInvalidCast]
352
+ files = args.files
284
353
  format_staged_files(
285
- file_patterns=files,
286
- formatter=vars(args)['formatter'],
287
- git_root=get_git_root(),
288
- update_working_tree=not vars(args)['no_update_working_tree'],
289
- write=not vars(args)['no_write'],
290
- verbose=vars(args)['verbose']
291
- )
354
+ file_patterns=files,
355
+ formatter=args.formatter,
356
+ update_working_tree=not args.no_update_working_tree,
357
+ write=not args.no_write,
358
+ verbose=args.verbose,
359
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-format-staged",
3
- "version": "3.1.2",
3
+ "version": "4.0.0",
4
4
  "description": "Git command to transform staged files according to a command that accepts file content on stdin and produces output on stdout.",
5
5
  "scripts": {
6
6
  "test": "ava",
@@ -36,7 +36,6 @@
36
36
  "ava": "^3.8.1",
37
37
  "eslint": "^5.16.0",
38
38
  "fs-extra": "^9.0.0",
39
- "husky": "^9.0.11",
40
39
  "micromatch": "^4.0.2",
41
40
  "prettier-standard": "^9.1.1",
42
41
  "semantic-release": "^23.0.2",
@@ -55,11 +54,5 @@
55
54
  "files": [
56
55
  "test/**/*_test.ts"
57
56
  ]
58
- },
59
- "husky": {
60
- "hooks": {
61
- "commit-msg": "commitlint -e $HUSKY_GIT_PARAMS",
62
- "pre-commit": "./git-format-staged --formatter prettier-standard '*.js'"
63
- }
64
57
  }
65
58
  }