git_stage_formatter 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e24f5f48a24491053f2e2220ce055df6f61c6207a750f5a8ff6098afbde4120a
4
+ data.tar.gz: 54553233ebdb102c78ab79d8a660c12f8df10647f3cfb65c22397516d8f0b965
5
+ SHA512:
6
+ metadata.gz: 20911e08c4169554122387169899c2d93f9b0be2ef7d305370c059dba9be6ab88a8553efdbf0fbb74598a31ea62cc7945a04dcb416379fb7abef8f1d677700f4
7
+ data.tar.gz: d86c46ed60800b49fd8ee4132226e97483a1fb15060d08642984d55fc23096f2d8147ea04a835763d48ef8c25ff1e05d7afd87472f6a8a745039b41872bc0b9c
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ node_modules/
2
+ npm-debug.log
3
+ yarn-error.log
4
+
5
+ # Ruby
6
+ .bundle
7
+ Gemfile.lock
8
+
9
+ # Misc
10
+ .DS_Store
data/.travis.yml ADDED
@@ -0,0 +1,28 @@
1
+ language: python
2
+ python:
3
+ - 3.8
4
+ - 3.6
5
+ - 2.7
6
+
7
+ cache:
8
+ directories:
9
+ - ~/.npm
10
+
11
+ before_install:
12
+ - nvm install v14
13
+
14
+ install:
15
+ - npm install
16
+
17
+ script:
18
+ - npm test
19
+
20
+ jobs:
21
+ include:
22
+ - stage: release
23
+ deploy:
24
+ provider: script
25
+ skip_cleanup: true
26
+ script: "npm run semantic-release"
27
+ on:
28
+ all_branches: true
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Gem dependencies specified in git_stage_formatter.gemspec
6
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2018 Jesse Hallett
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # git-format-staged
2
+
3
+ [![Build Status](https://travis-ci.org/hallettj/git-format-staged.svg?branch=master)](https://travis-ci.org/hallettj/git-format-staged)
4
+
5
+ Consider a project where you want all code formatted consistently. So you use
6
+ a formatting command. (For example I use [prettier-standard][] in my
7
+ Javascript projects.) You want to make sure that everyone working on the
8
+ project runs the formatter, so you use a tool like [husky][] to install a git
9
+ pre-commit hook. The naive way to write that hook would be to:
10
+
11
+ - get a list of staged files
12
+ - run the formatter on those files
13
+ - run `git add` to stage the results of formatting
14
+
15
+ The problem with that solution is it forces you to commit entire files. At
16
+ worst this will lead to contributors to unwittingly committing changes. At
17
+ best it disrupts workflow for contributors who use `git add -p`.
18
+
19
+ git-format-staged tackles this problem by running the formatter on the staged
20
+ version of the file. Staging changes to a file actually produces a new file
21
+ that exists in the git object database. git-format-staged uses some git
22
+ plumbing commands to send content from that file to your formatter. The command
23
+ replaces file content in the git index. The process bypasses the working tree,
24
+ so any unstaged changes are ignored by the formatter, and remain unstaged.
25
+
26
+ After formatting a staged file git-format-staged computes a patch which it
27
+ attempts to apply to the working tree file to keep the working tree in sync
28
+ with staged changes. If patching fails you will see a warning message. The
29
+ version of the file that is committed will be formatted properly - the warning
30
+ just means that working tree copy of the file has been left unformatted. The
31
+ patch step can be disabled with the `--no-update-working-tree` option.
32
+
33
+ [prettier-standard]: https://www.npmjs.com/package/prettier-standard
34
+ [husky]: https://www.npmjs.com/package/husky
35
+
36
+
37
+ ## How to install
38
+
39
+ Requires Python version 3 or 2.7.
40
+
41
+ Install as a development dependency in a project that uses npm packages:
42
+
43
+ $ npm install --save-dev git-format-staged
44
+
45
+ Or install globally:
46
+
47
+ $ npm install --global git-format-staged
48
+
49
+ If you do not use npm you can copy the
50
+ [`git-format-staged`](./git-format-staged) script from this repository and
51
+ place it in your executable path. The script is MIT-licensed - so you can check
52
+ the script into version control in your own open source project if you wish.
53
+
54
+
55
+ ## How to use
56
+
57
+ For detailed information run:
58
+
59
+ $ git-format-staged --help
60
+
61
+ The command expects a shell command to run a formatter, and one or more file
62
+ patterns to identify which files should be formatted. For example:
63
+
64
+ $ git-format-staged --formatter 'prettier --stdin --stdin-filepath "{}"' 'src/*.js'
65
+
66
+ That will format all files under `src/` and its subdirectories using
67
+ `prettier`. The file pattern is tested against staged files using Python's
68
+ [`fnmatch`][] function: each `*` will match nested directories in addition to
69
+ file names.
70
+
71
+ [`fnmatch`]: https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch
72
+
73
+ The formatter command must read file content from `stdin`, and output formatted
74
+ content to `stdout`.
75
+
76
+ Files can be excluded by prefixing a pattern with `!`. For example:
77
+
78
+ $ git-format-staged --formatter 'prettier --stdin' '*.js' '!flow-typed/*'
79
+
80
+ Patterns are evaluated from left-to-right: if a file matches multiple patterns
81
+ the right-most pattern determines whether the file is included or excluded.
82
+
83
+ git-format-staged never operates on files that are excluded from version
84
+ control. So it is not necessary to explicitly exclude stuff like
85
+ `node_modules/`.
86
+
87
+ The formatter command may include a placeholder, `{}`, which will be replaced
88
+ with the path of the file that is being formatted. This is useful if your
89
+ formatter needs to know the file extension to determine how to format or to
90
+ lint each file. For example:
91
+
92
+ $ git-format-staged -f 'prettier --stdin --stdin-filepath "{}"' '*.js' '*.css'
93
+
94
+ Do not attempt to read or write to `{}` in your formatter command! The
95
+ placeholder exists only for referencing the file name and path.
96
+
97
+ ### Check staged changes with a linter without formatting
98
+
99
+ Perhaps you do not want to reformat files automatically; but you do want to
100
+ prevent files from being committed if they do not conform to style rules. You
101
+ can use git-format-staged with the `--no-write` option, and supply a lint
102
+ command instead of a format command. Here is an example using ESLint:
103
+
104
+ $ git-format-staged --no-write -f 'eslint --stdin --stdin-filename "{}" >&2' 'src/*.js'
105
+
106
+ If this command is run in a pre-commit hook, and the lint command fails the
107
+ commit will be aborted and error messages will be displayed. The lint command
108
+ must read file content via `stdin`. Anything that the lint command outputs to
109
+ `stdout` will be ignored. In the example above `eslint` is given the `--stdin`
110
+ option to tell it to read content from `stdin` instead of reading files from
111
+ disk, and messages from `eslint` are redirected to `stderr` (using the `>&2`
112
+ notation) so that you can see them.
113
+
114
+ ### Set up a pre-commit hook with Husky
115
+
116
+ Follow these steps to automatically format all Javascript files on commit in
117
+ a project that uses npm.
118
+
119
+ Install git-format-staged, husky, and a formatter (I use prettier-standard):
120
+
121
+ $ npm install --save-dev git-format-staged husky prettier-standard
122
+
123
+ Add a `"precommit"` script in `package.json`:
124
+
125
+ "scripts": {
126
+ "precommit": "git-format-staged -f prettier-standard '*.js'"
127
+ }
128
+
129
+ Once again note that the `'*.js'` pattern is quoted! If the formatter command
130
+ included arguments it would also need to be quoted.
131
+
132
+ That's it! Whenever a file is changed as a result of formatting on commit you
133
+ will see a message in the output from `git commit`.
134
+
135
+
136
+ ## Comparisons to similar utilities
137
+
138
+ There are other tools that will format or lint staged files. What distinguishes
139
+ git-format-staged is that when a file has both staged and unstaged changes
140
+ git-format-staged ignores the unstaged changes; and it leaves unstaged changes
141
+ unstaged when applying formatting.
142
+
143
+ Some linters (such as [precise-commits][]) have an option to restrict linting
144
+ to certain lines or character ranges in files, which is one way to ignore
145
+ unstaged changes while linting. The author is not aware of a utility other than
146
+ git-format-staged that can apply any arbitrary linter so that it ignores
147
+ unstaged changes.
148
+
149
+ Some other formatting utilities (such as [pre-commit][])
150
+ use a different strategy to keep unstaged changes unstaged:
151
+
152
+ 1. stash unstaged changes
153
+ 2. apply the formatter to working tree files
154
+ 3. stage any resulting changes
155
+ 4. reapply stashed changes to the working tree.
156
+
157
+ The problem is that you may get a conflict where stashed changes cannot be
158
+ automatically merged after formatting has been applied. In those cases the user
159
+ has to do some manual fixing to retrieve unstaged changes. As far as the author
160
+ is aware git-format-staged is the only utility that applies a formatter without
161
+ touching working tree files, and then merges formatting changes to the working
162
+ tree. The advantage of merging formatting changes into unstaged changes (as
163
+ opposed to merging unstaged changes into formatting changes) is that
164
+ git-format-staged is non-lossy: if there are conflicts between unstaged changes
165
+ and formatting the unstaged changes win, and are kept in the working tree,
166
+ while staged/committed files are formatted properly.
167
+
168
+ Another advantage of git-format-staged is that it has no dependencies beyond
169
+ Python and git, and can be dropped into any programming language ecosystem.
170
+
171
+ Some more comparisons:
172
+
173
+ - [lint-staged][] lints and formats staged files. At the time of this writing
174
+ it does not have an official strategy for ignoring unstaged changes when
175
+ linting, or for keeping unstaged changes unstaged when formatting. But
176
+ lint-staged does provide powerful configuration options around which files
177
+ should be linted or formatted, what should happen before and after linting,
178
+ and so on.
179
+ - [pretty-quick][] formats staged files with prettier. By default pretty-quick
180
+ will abort the commit if files are partially staged to allow the user to
181
+ decide how to re-stage changes from formatting. The result is more manual
182
+ effort compared to git-format-staged.
183
+ - the one-liner
184
+ `git diff --diff-filter=d --cached | grep '^[+-]' | grep -Ev '^(--- a/|\+\+\+ b/)' | LINT_COMMAND`
185
+ (described [here][lint changed hunks]) extracts changed hunks and feeds them
186
+ to a linter. But linting will fail if the linter requires the entire file for
187
+ context. For example a linter might report errors if it cannot see import
188
+ lines.
189
+
190
+ [precise-commits]: https://github.com/nrwl/precise-commits
191
+ [pre-commit]: https://pre-commit.com/#pre-commit-during-commits
192
+ [pretty-quick]: https://www.npmjs.com/package/pretty-quick
193
+ [lint-staged]: https://github.com/okonet/lint-staged
194
+ [lint changed hunks]: https://github.com/okonet/lint-staged/issues/62#issuecomment-383217916
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "git_stage_formatter"
4
+
5
+ GitStageFormatter.run(ARGV)
@@ -0,0 +1,5 @@
1
+ 'use strict'
2
+
3
+ module.exports = {
4
+ extends: ['@commitlint/config-conventional']
5
+ }
data/git-format-staged ADDED
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env python
2
+ #
3
+ # Git command to transform staged files according to a command that accepts file
4
+ # content on stdin and produces output on stdout. This command is useful in
5
+ # combination with `git add -p` which allows you to stage specific changes in
6
+ # a file. This command runs a formatter on the file with staged changes while
7
+ # ignoring unstaged changes.
8
+ #
9
+ # Usage: git-format-staged [OPTION]... [FILE]...
10
+ # Example: git-format-staged --formatter 'prettier --stdin' '*.js'
11
+ #
12
+ # Tested with Python 3.6 and Python 2.7.
13
+ #
14
+ # Original author: Jesse Hallett <jesse@sitr.us>
15
+
16
+ from __future__ import print_function
17
+ import argparse
18
+ from fnmatch import fnmatch
19
+ from gettext import gettext as _
20
+ import os
21
+ import re
22
+ import subprocess
23
+ import sys
24
+
25
+ # The string $VERSION is replaced during the publish process.
26
+ VERSION = '$VERSION'
27
+ PROG = sys.argv[0]
28
+
29
+ def info(msg):
30
+ print(msg, file=sys.stderr)
31
+
32
+ def warn(msg):
33
+ print('{}: warning: {}'.format(PROG, msg), file=sys.stderr)
34
+
35
+ def fatal(msg):
36
+ print('{}: error: {}'.format(PROG, msg), file=sys.stderr)
37
+ exit(1)
38
+
39
+ def format_staged_files(file_patterns, formatter, git_root, update_working_tree=True, write=True):
40
+ try:
41
+ output = subprocess.check_output([
42
+ 'git', 'diff-index',
43
+ '--cached',
44
+ '--diff-filter=AM', # select only file additions and modifications
45
+ '--no-renames',
46
+ 'HEAD'
47
+ ])
48
+ for line in output.splitlines():
49
+ entry = parse_diff(line.decode('utf-8'))
50
+ entry_path = normalize_path(entry['src_path'], relative_to=git_root)
51
+ if not (matches_some_path(file_patterns, entry_path)):
52
+ continue
53
+ if format_file_in_index(formatter, entry, update_working_tree=update_working_tree, write=write):
54
+ info('Reformatted {} with {}'.format(entry['src_path'], formatter))
55
+ except Exception as err:
56
+ fatal(str(err))
57
+
58
+ # Run formatter on file in the git index. Creates a new git object with the
59
+ # result, and replaces the content of the file in the index with that object.
60
+ # Returns hash of the new object if formatting produced any changes.
61
+ def format_file_in_index(formatter, diff_entry, update_working_tree=True, write=True):
62
+ orig_hash = diff_entry['dst_hash']
63
+ new_hash = format_object(formatter, orig_hash, diff_entry['src_path'])
64
+
65
+ # If the new hash is the same then the formatter did not make any changes.
66
+ if not write or new_hash == orig_hash:
67
+ return None
68
+
69
+ # If the content of the new object is empty then the formatter did not
70
+ # produce any output. We want to abort instead of replacing the file with an
71
+ # empty one.
72
+ if object_is_empty(new_hash):
73
+ return None
74
+
75
+ replace_file_in_index(diff_entry, new_hash)
76
+
77
+ if update_working_tree:
78
+ try:
79
+ patch_working_file(diff_entry['src_path'], orig_hash, new_hash)
80
+ except Exception as err:
81
+ # Errors patching working tree files are not fatal
82
+ warn(str(err))
83
+
84
+ return new_hash
85
+
86
+ file_path_placeholder = re.compile('\{\}')
87
+
88
+ # Run formatter on a git blob identified by its hash. Writes output to a new git
89
+ # blob, and returns the hash of the new blob.
90
+ def format_object(formatter, object_hash, file_path):
91
+ get_content = subprocess.Popen(
92
+ ['git', 'cat-file', '-p', object_hash],
93
+ stdout=subprocess.PIPE
94
+ )
95
+ format_content = subprocess.Popen(
96
+ re.sub(file_path_placeholder, file_path, formatter),
97
+ shell=True,
98
+ stdin=get_content.stdout,
99
+ stdout=subprocess.PIPE
100
+ )
101
+ write_object = subprocess.Popen(
102
+ ['git', 'hash-object', '-w', '--stdin'],
103
+ stdin=format_content.stdout,
104
+ stdout=subprocess.PIPE
105
+ )
106
+
107
+ get_content.stdout.close()
108
+ format_content.stdout.close()
109
+
110
+ if get_content.wait() != 0:
111
+ raise ValueError('unable to read file content from object database: ' + object_hash)
112
+
113
+ if format_content.wait() != 0:
114
+ raise Exception('formatter exited with non-zero status') # TODO: capture stderr from format command
115
+
116
+ new_hash, err = write_object.communicate()
117
+
118
+ if write_object.returncode != 0:
119
+ raise Exception('unable to write formatted content to object database')
120
+
121
+ return new_hash.decode('utf-8').rstrip()
122
+
123
+ def object_is_empty(object_hash):
124
+ get_content = subprocess.Popen(
125
+ ['git', 'cat-file', '-p', object_hash],
126
+ stdout=subprocess.PIPE
127
+ )
128
+ content, err = get_content.communicate()
129
+
130
+ if get_content.returncode != 0:
131
+ raise Exception('unable to verify content of formatted object')
132
+
133
+ return not content
134
+
135
+ def replace_file_in_index(diff_entry, new_object_hash):
136
+ subprocess.check_call(['git', 'update-index',
137
+ '--cacheinfo', '{},{},{}'.format(
138
+ diff_entry['dst_mode'],
139
+ new_object_hash,
140
+ diff_entry['src_path']
141
+ )])
142
+
143
+ def patch_working_file(path, orig_object_hash, new_object_hash):
144
+ patch = subprocess.check_output(
145
+ ['git', 'diff', orig_object_hash, new_object_hash]
146
+ )
147
+
148
+ # Substitute object hashes in patch header with path to working tree file
149
+ patch_b = patch.replace(orig_object_hash.encode(), path.encode()).replace(new_object_hash.encode(), path.encode())
150
+
151
+ apply_patch = subprocess.Popen(
152
+ ['git', 'apply', '-'],
153
+ stdin=subprocess.PIPE,
154
+ stdout=subprocess.PIPE,
155
+ stderr=subprocess.PIPE
156
+ )
157
+
158
+ output, err = apply_patch.communicate(input=patch_b)
159
+
160
+ if apply_patch.returncode != 0:
161
+ raise Exception('could not apply formatting changes to working tree file {}'.format(path))
162
+
163
+ # Format: src_mode dst_mode src_hash dst_hash status/score? src_path dst_path?
164
+ diff_pat = re.compile('^:(\d+) (\d+) ([a-f0-9]+) ([a-f0-9]+) ([A-Z])(\d+)?\t([^\t]+)(?:\t([^\t]+))?$')
165
+
166
+ # Parse output from `git diff-index`
167
+ def parse_diff(diff):
168
+ m = diff_pat.match(diff)
169
+ if not m:
170
+ raise ValueError('Failed to parse diff-index line: ' + diff)
171
+ return {
172
+ 'src_mode': unless_zeroed(m.group(1)),
173
+ 'dst_mode': unless_zeroed(m.group(2)),
174
+ 'src_hash': unless_zeroed(m.group(3)),
175
+ 'dst_hash': unless_zeroed(m.group(4)),
176
+ 'status': m.group(5),
177
+ 'score': int(m.group(6)) if m.group(6) else None,
178
+ 'src_path': m.group(7),
179
+ 'dst_path': m.group(8)
180
+ }
181
+
182
+ zeroed_pat = re.compile('^0+$')
183
+
184
+ # Returns the argument unless the argument is a string of zeroes, in which case
185
+ # returns `None`
186
+ def unless_zeroed(s):
187
+ return s if not zeroed_pat.match(s) else None
188
+
189
+ def get_git_root():
190
+ return subprocess.check_output(
191
+ ['git', 'rev-parse', '--show-toplevel']
192
+ ).decode('utf-8').rstrip()
193
+
194
+ def normalize_path(p, relative_to=None):
195
+ return os.path.abspath(
196
+ os.path.join(relative_to, p) if relative_to else p
197
+ )
198
+
199
+ def matches_some_path(patterns, target):
200
+ is_match = False
201
+ for signed_pattern in patterns:
202
+ (is_pattern_positive, pattern) = from_signed_pattern(signed_pattern)
203
+ if fnmatch(target, normalize_path(pattern)):
204
+ is_match = is_pattern_positive
205
+ return is_match
206
+
207
+ # Checks for a '!' as the first character of a pattern, returns the rest of the
208
+ # pattern in a tuple. The tuple takes the form (is_pattern_positive, pattern).
209
+ # For example:
210
+ # from_signed_pattern('!pat') == (False, 'pat')
211
+ # from_signed_pattern('pat') == (True, 'pat')
212
+ def from_signed_pattern(pattern):
213
+ if pattern[0] == '!':
214
+ return (False, pattern[1:])
215
+ else:
216
+ return (True, pattern)
217
+
218
+ class CustomArgumentParser(argparse.ArgumentParser):
219
+ def parse_args(self, args=None, namespace=None):
220
+ args, argv = self.parse_known_args(args, namespace)
221
+ if argv:
222
+ msg = argparse._(
223
+ 'unrecognized arguments: %s. Do you need to quote your formatter command?'
224
+ )
225
+ self.error(msg % ' '.join(argv))
226
+ return args
227
+
228
+ if __name__ == '__main__':
229
+ parser = CustomArgumentParser(
230
+ description='Transform staged files using a formatting command that accepts content via stdin and produces a result via stdout.',
231
+ epilog='Example: %(prog)s --formatter "prettier --stdin" "src/*.js" "test/*.js"'
232
+ )
233
+ parser.add_argument(
234
+ '--formatter', '-f',
235
+ required=True,
236
+ 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. (Example: "prettier --stdin --stdin-filepath \'{}\'")'
237
+ )
238
+ parser.add_argument(
239
+ '--no-update-working-tree',
240
+ action='store_true',
241
+ 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.'
242
+ )
243
+ parser.add_argument(
244
+ '--no-write',
245
+ action='store_true',
246
+ 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"'
247
+ )
248
+ parser.add_argument(
249
+ '--version',
250
+ action='version',
251
+ version='%(prog)s version {}'.format(VERSION),
252
+ help='Display version of %(prog)s'
253
+ )
254
+ parser.add_argument(
255
+ 'files',
256
+ nargs='+',
257
+ 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/*")'
258
+ )
259
+ args = parser.parse_args()
260
+ files = vars(args)['files']
261
+ format_staged_files(
262
+ file_patterns=files,
263
+ formatter=vars(args)['formatter'],
264
+ git_root=get_git_root(),
265
+ update_working_tree=not vars(args)['no_update_working_tree'],
266
+ write=not vars(args)['no_write']
267
+ )