Skip to content

Commit

Permalink
feat: accept file pattern arguments as fnmatch globs
Browse files Browse the repository at this point in the history
  • Loading branch information
hallettj committed May 27, 2018
1 parent 90997d8 commit ded6e95
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 39 deletions.
69 changes: 62 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Consider a project where you want all code formatted consistently. So you use
a formatting command. (For example I use [prettier-standard][] in my
Javascript projects.) You want to make sure that everyone working on the
project runs the formatter, so you use a tool like [husky][] to install a git
precommit hook. The naive way to write that hook would be to:
pre-commit hook. The naive way to write that hook would be to:

- get a list of staged files
- run the formatter on those files
Expand All @@ -17,7 +17,7 @@ best it disrupts workflow for contributors who use `git add -p`.
git-format-staged tackles this problem by running the formatter on the staged
version of the file. Staging changes to a file actually produces a new file
that exists in the git object database. git-format-staged uses some git
plumbing commands to content from that file to your formatter. The command
plumbing commands to send content from that file to your formatter. The command
replaces file content in the git index. The process bypasses the working tree,
so any unstaged changes are ignored by the formatter, and remain unstaged.

Expand All @@ -36,11 +36,66 @@ patch step can be disabled with the `--no-update-working-tree` option.

Requires Python version 3 or 2.7.

Run:
Install as a development dependency in a project that uses npm packages:

$ npm install --save-dev git-format-staged

Or install globally:

$ npm install --global git-format-staged

Or you can copy the [`git-format-staged`](./git-format-staged) script from this
repository and place it in your executable path. The script is MIT-licensed -
so you can check the script into version control in your own open source
project if you wish.
If you do not use npm you can copy the
[`git-format-staged`](./git-format-staged) script from this repository and
place it in your executable path. The script is MIT-licensed - so you can check
the script into version control in your own open source project if you wish.


## How to use

For detailed information run:

$ git-format-staged --help

The command expects a shell command to run a formatter, and one or more file
patterns to identify which files should be formatted. For example:

$ git-format-staged --formatter 'prettier --stdin' 'src/*.js'

That will format all files under `src/` and its subdirectories using
`prettier`. The file pattern is tested against staged files using Python's
[`fnmatch`][] function: each `*` will match nested directories in addition to
file names.

[`fnmatch`]: https://docs.python.org/3/library/fnmatch.html#fnmatch.fnmatch

Note that both the formatter command and the file pattern are quoted. If you
prefer you may let your shell expand a file glob for you. This command is
equivalent if your shell supports [globstar][] notation:

$ git-format-staged --formatter 'prettier --stdin' src/**/*.js

Zsh supports globstar by default. Bash only supports globstar if a certain
shell option is set. Do not rely on globstar in npm scripts!

[globstar]: https://www.linuxjournal.com/content/globstar-new-bash-globbing-option

### Set up a pre-commit hook with Husky

Follow these steps to automatically format all Javascript files on commit in
a project that uses npm.

Install git-format-staged, husky, and a formatter (I use prettier-standard):

$ npm install --save-dev git-format-staged husky prettier-standard

Add a `"precommit"` script in `package.json`:

"scripts": {
"precommit": "git-format-staged -f prettier-standard '*.js'"
}

Once again note that the `'*.js'` pattern is quoted! If the formatter command
included arguments it would also need to be quoted.

That's it! Whenever a file is changed as a result of formatting on commit you
will see a message in the output from `git commit`.
19 changes: 10 additions & 9 deletions git-format-staged
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
# ignoring unstaged changes.
#
# Usage: git-format-staged [OPTION]... [FILE]...
# Example: git-format-staged --formatter 'prettier --stdin' src/**/*.js
# Example: git-format-staged --formatter 'prettier --stdin' '*.js'
#
# Tested with Python 3.6 and Python 2.7.
#
# Original author: Jesse Hallett <jesse@sitr.us>

from __future__ import print_function
import argparse
import fnmatch
from gettext import gettext as _
import os
import re
Expand Down Expand Up @@ -47,7 +48,7 @@ def format_staged_files(files, formatter, git_root, update_working_tree=False):
for line in output.splitlines():
entry = parse_diff(line.decode('utf-8'))
entry_path = normalize_path(entry['src_path'], relative_to=git_root)
if not (entry_path in files):
if not (matches_some_path(files, entry_path)):
continue
if format_file_in_index(formatter, entry, update_working_tree=update_working_tree):
info('Reformatted {} with {}'.format(entry['src_path'], formatter))
Expand Down Expand Up @@ -179,10 +180,11 @@ def normalize_path(p, relative_to=None):
os.path.join(relative_to, p) if relative_to else p
)

def validate_files(files):
for file in files:
if not os.path.isfile(file):
fatal('"%s" is not a file in the git repository. Do you need to quote your formatter command?' % file)
def matches_some_path(paths, target):
for path in paths:
if fnmatch.fnmatch(target, path):
return True
return False

class CustomArgumentParser(argparse.ArgumentParser):
def parse_args(self, args=None, namespace=None):
Expand All @@ -197,7 +199,7 @@ class CustomArgumentParser(argparse.ArgumentParser):
if __name__ == '__main__':
parser = CustomArgumentParser(
description='Transform staged files using a formatting command that accepts content via stdin and produces a result via stdout.',
epilog='Example: %(prog)s --formatter "prettier --stdin" src/**/*.js test/**/*.js'
epilog='Example: %(prog)s --formatter "prettier --stdin" "src/*.js" "test/*.js"'
)
parser.add_argument(
'--formatter', '-f',
Expand All @@ -218,11 +220,10 @@ if __name__ == '__main__':
parser.add_argument(
'files',
nargs='+',
help='Files to format. The formatter will only transform staged files that are named here. (Example: src/**/*.js test/**/*.js)'
help='Files to format. The formatter will only transform staged files that are named here. Arguments may be 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. (Example: main.js src/*.js test/*.js)'
)
args = parser.parse_args()
files = vars(args)['files']
validate_files(files)
format_staged_files(
files=normalize_paths(files),
formatter=vars(args)['formatter'],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.0-development",
"description": "Git command to transform staged files according to a command that accepts file content on stdin and produces output on stdout.",
"scripts": {
"precommit": "./git-format-staged --formatter prettier-standard *.js test/**/*.js",
"precommit": "./git-format-staged --formatter prettier-standard '*.js'",
"commitmsg": "commitlint -e $GIT_PARAMS",
"test": "ava",
"prepublishOnly": "sed -i \"s/\\$VERSION/$npm_package_version/\" git-format-staged",
Expand Down
55 changes: 33 additions & 22 deletions test/git-format-staged_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,12 @@ test('fails if no formatter command is given', async t => {
const r = repo(t)
const { exitCode, stderr } = await formatStagedCaptureError(r, '*.js')
t.true(exitCode > 0)
t.regex(stderr, /argument --formatter\/-f is required/)
// The versions of argparse in Python 2 and Python 3 format this error message
// differently.
t.regex(
stderr,
/argument --formatter\/-f is required|the following arguments are required: --formatter\/-f/
)
})

test('fails if formatter command is not quoted', async t => {
Expand Down Expand Up @@ -127,28 +132,12 @@ test('fails if no files are given', async t => {
'-f prettier-standard'
)
t.true(exitCode > 0)
t.regex(stderr, /too few arguments/)
})

test('fails if shell cannot expand glob', async t => {
const r = repo(t)
const { exitCode, stderr } = await formatStagedCaptureError(
r,
'-f prettier-standard *.jx'
)
t.true(exitCode > 0)
t.regex(stderr, /"\*\.jx" is not a file in the git repository/)
})

test('fails if a given file cannot be found in the repo', async t => {
const r = repo(t)
const { exitCode, stderr } = await formatStagedCaptureError(
r,
'-f prettier-standard notthere.js'
// The versions of argparse in Python 2 and Python 3 format this error message
// differently.
t.regex(
stderr,
/too few arguments|the following arguments are required: files/
)
t.true(exitCode > 0)
t.regex(stderr, /"notthere\.js" is not a file in the git repository/)
t.regex(stderr, /Do you need to quote your formatter command\?/)
})

test('can be run in a subdirectory', async t => {
Expand All @@ -168,6 +157,28 @@ test('can be run in a subdirectory', async t => {
)
})

test('expands globs', async t => {
const r = repo(t)

await fileInTree(r, 'test/index.js', '')
await setContent(r, 'test/index.js', 'function test() { }')

await fileInTree(r, 'test/helpers/index.js', '')
await setContent(r, 'test/helpers/index.js', 'function test() { }')

await stage(r, 'test/index.js')
await stage(r, 'test/helpers/index.js')

await formatStaged(r, '-f prettier-standard "test/*.js"')

contentIs(t, await getContent(r, 'test/index.js'), 'function test () {}')
contentIs(
t,
await getContent(r, 'test/helpers/index.js'),
'function test () {}'
)
})

test('displays a message if a file was changed', async t => {
const r = repo(t)
await setContent(
Expand Down

0 comments on commit ded6e95

Please sign in to comment.