git-format-staged 3.1.2 → 4.0.1

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 (3) hide show
  1. package/README.md +40 -15
  2. package/git-format-staged +289 -162
  3. package/package.json +2 -8
package/README.md CHANGED
@@ -128,30 +128,55 @@ option to tell it to read content from `stdin` instead of reading files from
128
128
  disk, and messages from `eslint` are redirected to `stderr` (using the `>&2`
129
129
  notation) so that you can see them.
130
130
 
131
- ### Set up a pre-commit hook with Husky
131
+ ### Set up a pre-commit hook
132
132
 
133
- Follow these steps to automatically format all Javascript files on commit in
134
- a project that uses npm.
133
+ A git pre-commit hook runs automatically whenever you make a commit, before your
134
+ editor opens to write a commit message. If formatting fails with a non-zero exit
135
+ status it will cancel the commit, requiring you to fix the problem before trying
136
+ again. (You can always skip a pre-commit hook by running `git commit
137
+ --no-verify`.) But in most cases you will have correctly formatted files at all
138
+ times, without having to think about it again after setup. Whenever a file is
139
+ changed as a result of formatting on commit you will see a message in the output
140
+ from `git commit`.
135
141
 
136
- Install git-format-staged, husky, and a formatter (I use `prettier`):
142
+ There are a number of options for setting up a git pre-commit hook depending on
143
+ the software ecosystem you are working with.
137
144
 
138
- $ npm install --save-dev git-format-staged husky prettier
145
+ #### Write a pre-commit hook manually
139
146
 
140
- Add a `prepare` script to install husky when running `npm install`:
147
+ You can write a hook manually by creating the file `.git/hooks/pre-commit`, and
148
+ making it executable. A hook set up this way will _not_ automatically propagate
149
+ to other clones, so every collaborator will have to do this themselves.
141
150
 
142
- $ npm set-script prepare "husky install"
143
- $ npm run prepare
151
+ #### Nix devShell
144
152
 
145
- Add the pre-commit hook:
153
+ If you use a Nix devShell you can use [nix-git-hooks][] to help set up
154
+ a pre-commit hook automatically. You can either automatically install hooks for
155
+ everyone who uses the devShell, or provide a command that collaborators can run
156
+ to easily opt in to hooks. Take a look at how this repo uses nix-git-hooks in
157
+ [flake.nix](./flake.nix).
146
158
 
147
- $ npx husky add .husky/pre-commit "git-format-staged --formatter 'prettier --stdin-filepath {}' '*.js' '*.ts'"
148
- $ git add .husky/pre-commit
159
+ [nix-git-hooks]: https://github.com/ysndr/nix-git-hooks
149
160
 
150
- Once again note that the formatter command and the `'*.js'` and `'*.ts'`
151
- patterns are quoted!
161
+ #### Npm, yarn, pnpm, or bun
152
162
 
153
- That's it! Whenever a file is changed as a result of formatting on commit you
154
- will see a message in the output from `git commit`.
163
+ [Husky][] will automatically install git hooks for all project collaborators
164
+ after running package manager commands.
165
+
166
+ [Husky]: https://typicode.github.io/husky/get-started.html
167
+
168
+ #### Rust with Cargo
169
+
170
+ [cargo-husky][] is like Husky, but for Rust projects.
171
+
172
+ [cargo-husky]: https://crates.io/crates/cargo-husky
173
+
174
+ #### and more!
175
+
176
+ See more hook management options in [awesome-git][] and [awesome-git-hooks][].
177
+
178
+ [awesome-git]: https://github.com/dictcp/awesome-git?tab=readme-ov-file#hook-management.
179
+ [awesome-git-hooks]: https://github.com/compscilauren/awesome-git-hooks?tab=readme-ov-file#tools
155
180
 
156
181
  ## Comparisons to similar utilities
157
182
 
package/git-format-staged CHANGED
@@ -13,67 +13,105 @@
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
+ import signal
23
24
  import subprocess
24
25
  import sys
26
+ from typing import NoReturn, Protocol, cast
25
27
 
26
- # The string 3.1.2 is replaced during the publish process.
27
- VERSION = '3.1.2'
28
+
29
+ # The string 4.0.1 is replaced during the publish process.
30
+ VERSION = "4.0.1"
28
31
  PROG = sys.argv[0]
29
32
 
30
- def info(msg):
33
+
34
+ def info(msg: str):
31
35
  print(msg, file=sys.stdout)
32
36
 
33
- def warn(msg):
34
- print('{}: warning: {}'.format(PROG, msg), file=sys.stderr)
35
37
 
36
- def fatal(msg):
37
- print('{}: error: {}'.format(PROG, msg), file=sys.stderr)
38
+ def info_stderr(msg: str):
39
+ print(msg, file=sys.stderr)
40
+
41
+
42
+ def warn(msg: str):
43
+ print("{}: warning: {}".format(PROG, msg), file=sys.stderr)
44
+
45
+
46
+ def fatal(msg: str):
47
+ print("{}: error: {}".format(PROG, msg), file=sys.stderr)
38
48
  exit(1)
39
49
 
40
- def format_staged_files(file_patterns, formatter, git_root, update_working_tree=True, write=True, verbose=False):
50
+
51
+ def format_staged_files(
52
+ file_patterns: Sequence[str],
53
+ formatter: str,
54
+ update_working_tree: bool = True,
55
+ write: bool = True,
56
+ verbose: bool = False,
57
+ ):
41
58
  common_opts = [
42
- '--cached',
43
- '--diff-filter=AM', # select only file additions and modifications
44
- '--no-renames',
45
- 'HEAD',
59
+ "--cached",
60
+ "--diff-filter=AM", # select only file additions and modifications
61
+ "--no-renames",
62
+ "HEAD",
46
63
  ]
47
64
 
48
65
  try:
49
66
  staged_files = {
50
- path.decode('utf-8')
51
- for path in subprocess.check_output(['git', 'diff', '--name-only'] + common_opts).splitlines()
67
+ path.decode("utf-8")
68
+ for path in subprocess.check_output(
69
+ ["git", "diff", "--name-only"] + common_opts
70
+ ).splitlines()
52
71
  }
53
72
 
54
- output = subprocess.check_output(['git', 'diff-index'] + common_opts)
73
+ output = subprocess.check_output(["git", "diff-index"] + common_opts)
55
74
  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':
75
+ entry = DiffIndexEntry(line.decode("utf-8"))
76
+ if entry.dst_mode == "120000":
59
77
  # Do not process symlinks
60
78
  continue
61
- if not (matches_some_path(file_patterns, entry_path)):
79
+ if not (matches_some_path(file_patterns, entry.src_path)):
62
80
  continue
63
- if entry['src_mode'] is None and object_is_empty(entry['dst_hash']) and entry['src_path'] not in staged_files:
81
+ if (
82
+ entry.src_mode is None
83
+ and (not entry.dst_hash or object_is_empty(entry.dst_hash))
84
+ and entry.src_path not in staged_files
85
+ ):
64
86
  # File is not staged, it's tracked only with `--intent-to-add` and won't get committed
65
87
  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))
88
+ if format_file_in_index(
89
+ formatter,
90
+ entry,
91
+ update_working_tree=update_working_tree,
92
+ write=write,
93
+ verbose=verbose,
94
+ ):
95
+ info("Reformatted {} with {}".format(entry.src_path, formatter))
68
96
  except Exception as err:
69
97
  fatal(str(err))
70
98
 
99
+
71
100
  # Run formatter on file in the git index. Creates a new git object with the
72
101
  # result, and replaces the content of the file in the index with that object.
73
102
  # 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)
103
+ def format_file_in_index(
104
+ formatter: str,
105
+ diff_entry: "DiffIndexEntry",
106
+ update_working_tree: bool = True,
107
+ write: bool = True,
108
+ verbose: bool = False,
109
+ ):
110
+ orig_hash = diff_entry.dst_hash
111
+ if not orig_hash:
112
+ return None
113
+
114
+ new_hash = format_object(formatter, orig_hash, diff_entry.src_path, verbose=verbose)
77
115
 
78
116
  # If the new hash is the same then the formatter did not make any changes.
79
117
  if not write or new_hash == orig_hash:
@@ -89,203 +127,292 @@ def format_file_in_index(formatter, diff_entry, update_working_tree=True, write=
89
127
 
90
128
  if update_working_tree:
91
129
  try:
92
- patch_working_file(diff_entry['src_path'], orig_hash, new_hash)
130
+ patch_working_file(diff_entry.src_path, orig_hash, new_hash)
93
131
  except Exception as err:
94
132
  # Errors patching working tree files are not fatal
95
133
  warn(str(err))
96
134
 
97
135
  return new_hash
98
136
 
137
+
99
138
  # Match {}, and to avoid breaking quoting from shlex also match and remove surrounding quotes. This
100
139
  # is important for backward compatibility because previous version of git-format-staged did not use
101
140
  # shlex quoting, and required manual quoting.
102
141
  file_path_placeholder = re.compile(r"(['\"]?)\{\}(\1)")
103
142
 
143
+
104
144
  # Run formatter on a git blob identified by its hash. Writes output to a new git
105
145
  # blob, and returns the hash of the new blob.
106
- def format_object(formatter, object_hash, file_path, verbose=False):
146
+ def format_object(
147
+ formatter: str, object_hash: str, file_path: str, verbose: bool = False
148
+ ) -> str:
107
149
  get_content = subprocess.Popen(
108
- ['git', 'cat-file', '-p', object_hash],
109
- stdout=subprocess.PIPE
110
- )
150
+ ["git", "cat-file", "-p", object_hash], stdout=subprocess.PIPE
151
+ )
152
+
111
153
  command = re.sub(file_path_placeholder, shlex.quote(file_path), formatter)
112
154
  if verbose:
113
- info(command)
155
+ info_stderr(command)
114
156
  format_content = subprocess.Popen(
115
- command,
116
- shell=True,
117
- stdin=get_content.stdout,
118
- stdout=subprocess.PIPE
119
- )
120
- write_object = subprocess.Popen(
121
- ['git', 'hash-object', '-w', '--stdin'],
122
- stdin=format_content.stdout,
123
- stdout=subprocess.PIPE
124
- )
157
+ command, shell=True, stdin=get_content.stdout, stdout=subprocess.PIPE
158
+ )
125
159
 
126
- # Read output from the final process first to avoid deadlock if intermediate processes produce
127
- # large outputs that would fill pipe buffers
128
- new_hash, _ = write_object.communicate()
160
+ write_object = subprocess.Popen(
161
+ ["git", "hash-object", "-w", "--stdin"],
162
+ stdin=format_content.stdout,
163
+ stdout=subprocess.PIPE,
164
+ )
165
+
166
+ # Close the parent process reference to stdout, leaving only references in the child processes.
167
+ # This way if the downstream process terminates while format_content is still running,
168
+ # format_content will be terminated with a SIGPIPE signal.
169
+ format_content.stdout.close() # pyright: ignore[reportOptionalMemberAccess]
170
+
171
+ # On the other hand we don't close get_content.stdout() so that we can check if there is unread
172
+ # data left after the formatter has finished.
173
+
174
+ # Read output from the last process in the pipe, and block until that process has completed.
175
+ # It's important to block on the last process completing before waiting for the other sub
176
+ # processes to finish.
177
+ new_hash, _err = write_object.communicate()
178
+
179
+ # The first two pipe processes should have completed by now. Block to verify that we have exit
180
+ # statuses from them.
181
+ try:
182
+ # Use communicate() to check for any unread output from get_content.
183
+ get_content_unread_stdout, _ = get_content.communicate(timeout=5)
184
+ get_content.stdout.close() # pyright: ignore[reportOptionalMemberAccess]
185
+ format_content_exit_status = format_content.wait(timeout=5)
186
+ except subprocess.TimeoutExpired as exception:
187
+ raise Exception(
188
+ "the formatter command did not terminate as expected"
189
+ ) from exception
190
+
191
+ # An error from format_content is most relevant to the user, so prioritize displaying this error
192
+ # message in case multiple things went wrong.
193
+ if format_content_exit_status != 0:
194
+ raise Exception(
195
+ f"formatter exited with non-zero status ({format_content_exit_status}) while processing {file_path}"
196
+ )
129
197
 
130
- if get_content.wait() != 0:
131
- raise ValueError('unable to read file content from object database: ' + object_hash)
198
+ # If the formatter exited before reading all input then get_content might have been terminated
199
+ # by a SIGPIPE signal. This is probably incorrect behavior from the formatter command, but is
200
+ # allowed by design (for now). So we emit a warning, but will not fail.
201
+
202
+ # If there was unread output from get_content that's an indication that the formatter command is
203
+ # probably not configured correctly. But program design allows the formatter command do do what
204
+ # it wants. So this is a warning, not a hard error.
205
+ #
206
+ # A SIGPIPE termination to get_content would also indicate unread output. This should not
207
+ # happen, but it doesn't hurt to check.
208
+ if len(get_content_unread_stdout) > 0 or get_content.returncode == -signal.SIGPIPE:
209
+ warn(
210
+ f"the formatter command exited before reading all content from {file_path}"
211
+ )
132
212
 
133
- if format_content.wait() != 0:
134
- raise Exception('formatter exited with non-zero status')
213
+ if get_content.returncode != 0 and get_content.returncode != -signal.SIGPIPE:
214
+ if verbose:
215
+ info_stderr(
216
+ f"non-zero exit status from `git cat-file -p {object_hash}`\n"
217
+ + f"exit status: {get_content.returncode}\n"
218
+ + f"file path: {file_path}\n"
219
+ )
220
+ raise ValueError(
221
+ f"unable to read file content for {file_path} from object database."
222
+ )
135
223
 
136
224
  if write_object.returncode != 0:
137
- raise Exception('unable to write formatted content to object database')
225
+ if verbose:
226
+ info_stderr(
227
+ f"non-zero exit status from `git hash-object -w --stdin`\n"
228
+ + f"exit status: {write_object.returncode}\n"
229
+ + f"file path: {file_path}\n"
230
+ )
231
+ raise Exception("unable to write formatted content to object database")
138
232
 
139
- return new_hash.decode('utf-8').rstrip()
233
+ return new_hash.decode("utf-8").rstrip()
140
234
 
141
- def object_is_empty(object_hash):
235
+
236
+ def object_is_empty(object_hash: str) -> bool:
142
237
  get_content = subprocess.Popen(
143
- ['git', 'cat-file', '-p', object_hash],
144
- stdout=subprocess.PIPE
145
- )
146
- content, err = get_content.communicate()
238
+ ["git", "cat-file", "-p", object_hash], stdout=subprocess.PIPE
239
+ )
240
+ content, _err = get_content.communicate()
147
241
 
148
242
  if get_content.returncode != 0:
149
- raise Exception('unable to verify content of formatted object')
243
+ raise Exception("unable to verify content of formatted object")
150
244
 
151
245
  return not content
152
246
 
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
247
 
161
- def patch_working_file(path, orig_object_hash, new_object_hash):
248
+ def replace_file_in_index(diff_entry: "DiffIndexEntry", new_object_hash: str):
249
+ _ = subprocess.check_call(
250
+ [
251
+ "git",
252
+ "update-index",
253
+ "--cacheinfo",
254
+ "{},{},{}".format(
255
+ diff_entry.dst_mode, new_object_hash, diff_entry.src_path
256
+ ),
257
+ ]
258
+ )
259
+
260
+
261
+ def patch_working_file(path: str, orig_object_hash: str, new_object_hash: str):
162
262
  patch = subprocess.check_output(
163
- ['git', 'diff', '--no-ext-diff', '--color=never', orig_object_hash, new_object_hash]
164
- )
263
+ [
264
+ "git",
265
+ "diff",
266
+ "--no-ext-diff",
267
+ "--color=never",
268
+ orig_object_hash,
269
+ new_object_hash,
270
+ ]
271
+ )
165
272
 
166
273
  # 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())
274
+ patch_b = patch.replace(orig_object_hash.encode(), path.encode()).replace(
275
+ new_object_hash.encode(), path.encode()
276
+ )
168
277
 
169
278
  apply_patch = subprocess.Popen(
170
- ['git', 'apply', '-'],
171
- stdin=subprocess.PIPE,
172
- stdout=subprocess.PIPE,
173
- stderr=subprocess.PIPE
174
- )
279
+ ["git", "apply", "-"],
280
+ stdin=subprocess.PIPE,
281
+ stdout=subprocess.PIPE,
282
+ stderr=subprocess.PIPE,
283
+ )
175
284
 
176
- output, err = apply_patch.communicate(input=patch_b)
285
+ _output, _err = apply_patch.communicate(input=patch_b)
177
286
 
178
287
  if apply_patch.returncode != 0:
179
- raise Exception('could not apply formatting changes to working tree file {}'.format(path))
288
+ raise Exception(
289
+ "could not apply formatting changes to working tree file {}".format(path)
290
+ )
291
+
180
292
 
181
293
  # 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+$')
294
+ diff_pat = re.compile(
295
+ 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]+))?$"
296
+ )
297
+
298
+
299
+ class DiffIndexEntry:
300
+ src_mode: str | None
301
+ dst_mode: str | None
302
+ src_hash: str | None
303
+ dst_hash: str | None
304
+ status: str
305
+ score: int | None
306
+ src_path: str
307
+ dst_path: str | None
308
+
309
+ def __init__(self, diff_index_output_line: str):
310
+ "Parse a line of output from `git diff-index`"
311
+ m = diff_pat.match(diff_index_output_line)
312
+ if not m:
313
+ raise ValueError(
314
+ "Failed to parse diff-index line: " + diff_index_output_line
315
+ )
316
+ self.src_mode = unless_zeroed(m.group("src_mode"))
317
+ self.dst_mode = unless_zeroed(m.group("dst_mode"))
318
+ self.src_hash = unless_zeroed(m.group("src_hash"))
319
+ self.dst_hash = unless_zeroed(m.group("dst_hash"))
320
+ self.status = m.group("status")
321
+ self.score = int(m.group("score")) if m.group("score") else None
322
+ self.src_path = m.group("src_path")
323
+ self.dst_path = m.group("dst_path")
324
+
325
+
326
+ zeroed_pat = re.compile(r"^0+$")
327
+
201
328
 
202
329
  # Returns the argument unless the argument is a string of zeroes, in which case
203
330
  # returns `None`
204
- def unless_zeroed(s):
331
+ def unless_zeroed(s: str) -> str | None:
205
332
  return s if not zeroed_pat.match(s) else None
206
333
 
207
- def get_git_root():
208
- return subprocess.check_output(
209
- ['git', 'rev-parse', '--show-toplevel']
210
- ).decode('utf-8').rstrip()
211
334
 
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):
335
+ def matches_some_path(patterns: Sequence[str], target: str) -> bool:
218
336
  is_match = False
219
337
  for signed_pattern in patterns:
220
338
  (is_pattern_positive, pattern) = from_signed_pattern(signed_pattern)
221
- if fnmatch(target, normalize_path(pattern)):
339
+ if fnmatch(target, pattern):
222
340
  is_match = is_pattern_positive
223
341
  return is_match
224
342
 
343
+
225
344
  # Checks for a '!' as the first character of a pattern, returns the rest of the
226
345
  # pattern in a tuple. The tuple takes the form (is_pattern_positive, pattern).
227
346
  # For example:
228
347
  # from_signed_pattern('!pat') == (False, 'pat')
229
348
  # from_signed_pattern('pat') == (True, 'pat')
230
- def from_signed_pattern(pattern):
231
- if pattern[0] == '!':
349
+ def from_signed_pattern(pattern: str) -> tuple[bool, str]:
350
+ if pattern[0] == "!":
232
351
  return (False, pattern[1:])
233
352
  else:
234
353
  return (True, pattern)
235
354
 
355
+
236
356
  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__':
357
+ def error( # pyright: ignore[reportImplicitOverride]
358
+ self, message: str
359
+ ) -> NoReturn:
360
+ if message.startswith("unrecognized arguments:"):
361
+ message += " Do you need to quote your formatter command?"
362
+ super().error(message)
363
+
364
+
365
+ class Args(Protocol):
366
+ formatter: str
367
+ no_update_working_tree: bool
368
+ no_write: bool
369
+ verbose: bool
370
+ files: Sequence[str]
371
+
372
+
373
+ if __name__ == "__main__":
247
374
  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']
375
+ description="Transform staged files using a formatting command that accepts content via stdin and produces a result via stdout.",
376
+ epilog='Example: %(prog)s --formatter "prettier --stdin-filepath {}" "src/*.js" "test/*.js"',
377
+ )
378
+ _ = parser.add_argument(
379
+ "--formatter",
380
+ "-f",
381
+ required=True,
382
+ 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 {}")',
383
+ )
384
+ _ = parser.add_argument(
385
+ "--no-update-working-tree",
386
+ action="store_true",
387
+ 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.",
388
+ )
389
+ _ = parser.add_argument(
390
+ "--no-write",
391
+ action="store_true",
392
+ 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"',
393
+ )
394
+ _ = parser.add_argument(
395
+ "--version",
396
+ action="version",
397
+ version="%(prog)s version {}".format(VERSION),
398
+ help="Display version of %(prog)s",
399
+ )
400
+ _ = parser.add_argument(
401
+ "--verbose",
402
+ help="Show the formatting commands that are running",
403
+ action="store_true",
404
+ )
405
+ _ = parser.add_argument(
406
+ "files",
407
+ nargs="+",
408
+ 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/*")',
409
+ )
410
+ args = cast(Args, parser.parse_args()) # pyright: ignore[reportInvalidCast]
411
+ files = args.files
284
412
  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
- )
413
+ file_patterns=files,
414
+ formatter=args.formatter,
415
+ update_working_tree=not args.no_update_working_tree,
416
+ write=not args.no_write,
417
+ verbose=args.verbose,
418
+ )
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "git-format-staged",
3
- "version": "3.1.2",
3
+ "version": "4.0.1",
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",
7
+ "prepare": "update-npm-deps-hash || true",
7
8
  "prepublishOnly": "sed -i \"s/\\$VERSION/$npm_package_version/\" git-format-staged",
8
9
  "semantic-release": "semantic-release"
9
10
  },
@@ -36,7 +37,6 @@
36
37
  "ava": "^3.8.1",
37
38
  "eslint": "^5.16.0",
38
39
  "fs-extra": "^9.0.0",
39
- "husky": "^9.0.11",
40
40
  "micromatch": "^4.0.2",
41
41
  "prettier-standard": "^9.1.1",
42
42
  "semantic-release": "^23.0.2",
@@ -55,11 +55,5 @@
55
55
  "files": [
56
56
  "test/**/*_test.ts"
57
57
  ]
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
58
  }
65
59
  }