git-format-staged 3.1.2 → 3.1.3

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 +242 -125
  2. package/package.json +1 -1
package/git-format-staged CHANGED
@@ -20,60 +20,93 @@ from gettext import gettext as _
20
20
  import os
21
21
  import re
22
22
  import shlex
23
+ import signal
23
24
  import subprocess
24
25
  import sys
25
26
 
26
- # The string 3.1.2 is replaced during the publish process.
27
- VERSION = '3.1.2'
27
+ # The string 3.1.3 is replaced during the publish process.
28
+ VERSION = "3.1.3"
28
29
  PROG = sys.argv[0]
29
30
 
31
+
30
32
  def info(msg):
31
33
  print(msg, file=sys.stdout)
32
34
 
33
- def warn(msg):
34
- print('{}: warning: {}'.format(PROG, msg), file=sys.stderr)
35
+
36
+ def info_stderr(msg: str):
37
+ print(msg, file=sys.stderr)
38
+
39
+
40
+ def warn(msg: str):
41
+ print("{}: warning: {}".format(PROG, msg), file=sys.stderr)
42
+
35
43
 
36
44
  def fatal(msg):
37
- print('{}: error: {}'.format(PROG, msg), file=sys.stderr)
45
+ print("{}: error: {}".format(PROG, msg), file=sys.stderr)
38
46
  exit(1)
39
47
 
40
- def format_staged_files(file_patterns, formatter, git_root, update_working_tree=True, write=True, verbose=False):
48
+
49
+ def format_staged_files(
50
+ file_patterns,
51
+ formatter,
52
+ git_root,
53
+ update_working_tree=True,
54
+ write=True,
55
+ verbose=False,
56
+ ):
41
57
  common_opts = [
42
- '--cached',
43
- '--diff-filter=AM', # select only file additions and modifications
44
- '--no-renames',
45
- 'HEAD',
58
+ "--cached",
59
+ "--diff-filter=AM", # select only file additions and modifications
60
+ "--no-renames",
61
+ "HEAD",
46
62
  ]
47
63
 
48
64
  try:
49
65
  staged_files = {
50
- path.decode('utf-8')
51
- for path in subprocess.check_output(['git', 'diff', '--name-only'] + common_opts).splitlines()
66
+ path.decode("utf-8")
67
+ for path in subprocess.check_output(
68
+ ["git", "diff", "--name-only"] + common_opts
69
+ ).splitlines()
52
70
  }
53
71
 
54
- output = subprocess.check_output(['git', 'diff-index'] + common_opts)
72
+ output = subprocess.check_output(["git", "diff-index"] + common_opts)
55
73
  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':
74
+ entry = parse_diff(line.decode("utf-8"))
75
+ entry_path = normalize_path(entry["src_path"], relative_to=git_root)
76
+ if entry["dst_mode"] == "120000":
59
77
  # Do not process symlinks
60
78
  continue
61
79
  if not (matches_some_path(file_patterns, entry_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 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, diff_entry, update_working_tree=True, write=True, verbose=False
105
+ ):
106
+ orig_hash = diff_entry["dst_hash"]
107
+ new_hash = format_object(
108
+ formatter, orig_hash, diff_entry["src_path"], verbose=verbose
109
+ )
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,130 +122,210 @@ 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
141
  def format_object(formatter, object_hash, file_path, verbose=False):
107
142
  get_content = subprocess.Popen(
108
- ['git', 'cat-file', '-p', object_hash],
109
- stdout=subprocess.PIPE
110
- )
143
+ ["git", "cat-file", "-p", object_hash], stdout=subprocess.PIPE
144
+ )
145
+
111
146
  command = re.sub(file_path_placeholder, shlex.quote(file_path), formatter)
112
147
  if verbose:
113
- info(command)
148
+ info_stderr(command)
114
149
  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
- )
150
+ command, shell=True, stdin=get_content.stdout, stdout=subprocess.PIPE
151
+ )
125
152
 
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()
153
+ write_object = subprocess.Popen(
154
+ ["git", "hash-object", "-w", "--stdin"],
155
+ stdin=format_content.stdout,
156
+ stdout=subprocess.PIPE,
157
+ )
158
+
159
+ # Close the parent process reference to stdout, leaving only references in the child processes.
160
+ # This way if the downstream process terminates while format_content is still running,
161
+ # format_content will be terminated with a SIGPIPE signal.
162
+ format_content.stdout.close() # pyright: ignore[reportOptionalMemberAccess]
163
+
164
+ # On the other hand we don't close get_content.stdout() so that we can check if there is unread
165
+ # data left after the formatter has finished.
166
+
167
+ # Read output from the last process in the pipe, and block until that process has completed.
168
+ # It's important to block on the last process completing before waiting for the other sub
169
+ # processes to finish.
170
+ new_hash, _err = write_object.communicate()
171
+
172
+ # The first two pipe processes should have completed by now. Block to verify that we have exit
173
+ # statuses from them.
174
+ try:
175
+ # Use communicate() to check for any unread output from get_content.
176
+ get_content_unread_stdout, _ = get_content.communicate(timeout=5)
177
+ get_content.stdout.close() # pyright: ignore[reportOptionalMemberAccess]
178
+ format_content_exit_status = format_content.wait(timeout=5)
179
+ except subprocess.TimeoutExpired as exception:
180
+ raise Exception(
181
+ "the formatter command did not terminate as expected"
182
+ ) from exception
183
+
184
+ # An error from format_content is most relevant to the user, so prioritize displaying this error
185
+ # message in case multiple things went wrong.
186
+ if format_content_exit_status != 0:
187
+ raise Exception(
188
+ f"formatter exited with non-zero status ({format_content_exit_status}) while processing {file_path}"
189
+ )
129
190
 
130
- if get_content.wait() != 0:
131
- raise ValueError('unable to read file content from object database: ' + object_hash)
191
+ # If the formatter exited before reading all input then get_content might have been terminated
192
+ # by a SIGPIPE signal. This is probably incorrect behavior from the formatter command, but is
193
+ # allowed by design (for now). So we emit a warning, but will not fail.
194
+
195
+ # If there was unread output from get_content that's an indication that the formatter command is
196
+ # probably not configured correctly. But program design allows the formatter command do do what
197
+ # it wants. So this is a warning, not a hard error.
198
+ #
199
+ # A SIGPIPE termination to get_content would also indicate unread output. This should not
200
+ # happen, but it doesn't hurt to check.
201
+ if len(get_content_unread_stdout) > 0 or get_content.returncode == -signal.SIGPIPE:
202
+ warn(
203
+ f"the formatter command exited before reading all content from {file_path}"
204
+ )
132
205
 
133
- if format_content.wait() != 0:
134
- raise Exception('formatter exited with non-zero status')
206
+ if get_content.returncode != 0 and get_content.returncode != -signal.SIGPIPE:
207
+ if verbose:
208
+ info_stderr(
209
+ f"non-zero exit status from `git cat-file -p {object_hash}`\n"
210
+ + f"exit status: {get_content.returncode}\n"
211
+ + f"file path: {file_path}\n"
212
+ )
213
+ raise ValueError(
214
+ f"unable to read file content for {file_path} from object database."
215
+ )
135
216
 
136
217
  if write_object.returncode != 0:
137
- raise Exception('unable to write formatted content to object database')
218
+ if verbose:
219
+ info_stderr(
220
+ f"non-zero exit status from `git hash-object -w --stdin`\n"
221
+ + f"exit status: {write_object.returncode}\n"
222
+ + f"file path: {file_path}\n"
223
+ )
224
+ raise Exception("unable to write formatted content to object database")
225
+
226
+ return new_hash.decode("utf-8").rstrip()
138
227
 
139
- return new_hash.decode('utf-8').rstrip()
140
228
 
141
229
  def object_is_empty(object_hash):
142
230
  get_content = subprocess.Popen(
143
- ['git', 'cat-file', '-p', object_hash],
144
- stdout=subprocess.PIPE
145
- )
231
+ ["git", "cat-file", "-p", object_hash], stdout=subprocess.PIPE
232
+ )
146
233
  content, err = get_content.communicate()
147
234
 
148
235
  if get_content.returncode != 0:
149
- raise Exception('unable to verify content of formatted object')
236
+ raise Exception("unable to verify content of formatted object")
150
237
 
151
238
  return not content
152
239
 
240
+
153
241
  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
- )])
242
+ subprocess.check_call(
243
+ [
244
+ "git",
245
+ "update-index",
246
+ "--cacheinfo",
247
+ "{},{},{}".format(
248
+ diff_entry["dst_mode"], new_object_hash, diff_entry["src_path"]
249
+ ),
250
+ ]
251
+ )
252
+
160
253
 
161
254
  def patch_working_file(path, orig_object_hash, new_object_hash):
162
255
  patch = subprocess.check_output(
163
- ['git', 'diff', '--no-ext-diff', '--color=never', orig_object_hash, new_object_hash]
164
- )
256
+ [
257
+ "git",
258
+ "diff",
259
+ "--no-ext-diff",
260
+ "--color=never",
261
+ orig_object_hash,
262
+ new_object_hash,
263
+ ]
264
+ )
165
265
 
166
266
  # 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())
267
+ patch_b = patch.replace(orig_object_hash.encode(), path.encode()).replace(
268
+ new_object_hash.encode(), path.encode()
269
+ )
168
270
 
169
271
  apply_patch = subprocess.Popen(
170
- ['git', 'apply', '-'],
171
- stdin=subprocess.PIPE,
172
- stdout=subprocess.PIPE,
173
- stderr=subprocess.PIPE
174
- )
272
+ ["git", "apply", "-"],
273
+ stdin=subprocess.PIPE,
274
+ stdout=subprocess.PIPE,
275
+ stderr=subprocess.PIPE,
276
+ )
175
277
 
176
278
  output, err = apply_patch.communicate(input=patch_b)
177
279
 
178
280
  if apply_patch.returncode != 0:
179
- raise Exception('could not apply formatting changes to working tree file {}'.format(path))
281
+ raise Exception(
282
+ "could not apply formatting changes to working tree file {}".format(path)
283
+ )
284
+
180
285
 
181
286
  # 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]+))?$')
287
+ diff_pat = re.compile(
288
+ r"^:(\d+) (\d+) ([a-f0-9]+) ([a-f0-9]+) ([A-Z])(\d+)?\t([^\t]+)(?:\t([^\t]+))?$"
289
+ )
290
+
183
291
 
184
292
  # Parse output from `git diff-index`
185
293
  def parse_diff(diff):
186
294
  m = diff_pat.match(diff)
187
295
  if not m:
188
- raise ValueError('Failed to parse diff-index line: ' + diff)
296
+ raise ValueError("Failed to parse diff-index line: " + diff)
189
297
  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+$')
298
+ "src_mode": unless_zeroed(m.group(1)),
299
+ "dst_mode": unless_zeroed(m.group(2)),
300
+ "src_hash": unless_zeroed(m.group(3)),
301
+ "dst_hash": unless_zeroed(m.group(4)),
302
+ "status": m.group(5),
303
+ "score": int(m.group(6)) if m.group(6) else None,
304
+ "src_path": m.group(7),
305
+ "dst_path": m.group(8),
306
+ }
307
+
308
+
309
+ zeroed_pat = re.compile(r"^0+$")
310
+
201
311
 
202
312
  # Returns the argument unless the argument is a string of zeroes, in which case
203
313
  # returns `None`
204
314
  def unless_zeroed(s):
205
315
  return s if not zeroed_pat.match(s) else None
206
316
 
317
+
207
318
  def get_git_root():
208
- return subprocess.check_output(
209
- ['git', 'rev-parse', '--show-toplevel']
210
- ).decode('utf-8').rstrip()
319
+ return (
320
+ subprocess.check_output(["git", "rev-parse", "--show-toplevel"])
321
+ .decode("utf-8")
322
+ .rstrip()
323
+ )
324
+
211
325
 
212
326
  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
- )
327
+ return os.path.abspath(os.path.join(relative_to, p) if relative_to else p)
328
+
216
329
 
217
330
  def matches_some_path(patterns, target):
218
331
  is_match = False
@@ -222,70 +335,74 @@ def matches_some_path(patterns, target):
222
335
  is_match = is_pattern_positive
223
336
  return is_match
224
337
 
338
+
225
339
  # Checks for a '!' as the first character of a pattern, returns the rest of the
226
340
  # pattern in a tuple. The tuple takes the form (is_pattern_positive, pattern).
227
341
  # For example:
228
342
  # from_signed_pattern('!pat') == (False, 'pat')
229
343
  # from_signed_pattern('pat') == (True, 'pat')
230
344
  def from_signed_pattern(pattern):
231
- if pattern[0] == '!':
345
+ if pattern[0] == "!":
232
346
  return (False, pattern[1:])
233
347
  else:
234
348
  return (True, pattern)
235
349
 
350
+
236
351
  class CustomArgumentParser(argparse.ArgumentParser):
237
352
  def parse_args(self, args=None, namespace=None):
238
353
  args, argv = self.parse_known_args(args, namespace)
239
354
  if argv:
240
355
  msg = argparse._(
241
- 'unrecognized arguments: %s. Do you need to quote your formatter command?'
242
- )
243
- self.error(msg % ' '.join(argv))
356
+ "unrecognized arguments: %s. Do you need to quote your formatter command?"
357
+ )
358
+ self.error(msg % " ".join(argv))
244
359
  return args
245
360
 
246
- if __name__ == '__main__':
361
+
362
+ if __name__ == "__main__":
247
363
  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
- )
364
+ description="Transform staged files using a formatting command that accepts content via stdin and produces a result via stdout.",
365
+ epilog='Example: %(prog)s --formatter "prettier --stdin-filepath {}" "src/*.js" "test/*.js"',
366
+ )
251
367
  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
- )
368
+ "--formatter",
369
+ "-f",
370
+ required=True,
371
+ 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 {}")',
372
+ )
256
373
  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
- )
374
+ "--no-update-working-tree",
375
+ action="store_true",
376
+ 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.",
377
+ )
261
378
  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
- )
379
+ "--no-write",
380
+ action="store_true",
381
+ 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"',
382
+ )
266
383
  parser.add_argument(
267
- '--version',
268
- action='version',
269
- version='%(prog)s version {}'.format(VERSION),
270
- help='Display version of %(prog)s'
271
- )
384
+ "--version",
385
+ action="version",
386
+ version="%(prog)s version {}".format(VERSION),
387
+ help="Display version of %(prog)s",
388
+ )
272
389
  parser.add_argument(
273
- '--verbose',
274
- help='Show the formatting commands that are running',
275
- action='store_true'
276
- )
390
+ "--verbose",
391
+ help="Show the formatting commands that are running",
392
+ action="store_true",
393
+ )
277
394
  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
- )
395
+ "files",
396
+ nargs="+",
397
+ 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/*")',
398
+ )
282
399
  args = parser.parse_args()
283
- files = vars(args)['files']
400
+ files = vars(args)["files"]
284
401
  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
- )
402
+ file_patterns=files,
403
+ formatter=vars(args)["formatter"],
404
+ git_root=get_git_root(),
405
+ update_working_tree=not vars(args)["no_update_working_tree"],
406
+ write=not vars(args)["no_write"],
407
+ verbose=vars(args)["verbose"],
408
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-format-staged",
3
- "version": "3.1.2",
3
+ "version": "3.1.3",
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",