Compare commits

..

71 commits
v0.0.2 ... main

Author SHA1 Message Date
a8afb17e70
Merge pull request #11 from 5sw/dependabot/bundler/rexml-3.3.9
Bump rexml from 3.3.6 to 3.3.9
2024-12-01 12:49:36 +01:00
dependabot[bot]
04c2430096
Bump rexml from 3.3.6 to 3.3.9
Bumps [rexml](https://github.com/ruby/rexml) from 3.3.6 to 3.3.9.
- [Release notes](https://github.com/ruby/rexml/releases)
- [Changelog](https://github.com/ruby/rexml/blob/master/NEWS.md)
- [Commits](https://github.com/ruby/rexml/compare/v3.3.6...v3.3.9)

---
updated-dependencies:
- dependency-name: rexml
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-28 18:52:26 +00:00
8bfdecec21
Merge pull request #10 from 5sw/dependabot/bundler/rexml-3.3.6
Bump rexml from 3.3.4 to 3.3.6
2024-09-23 20:10:04 +02:00
dependabot[bot]
0af937edac
Bump rexml from 3.3.4 to 3.3.6
Bumps [rexml](https://github.com/ruby/rexml) from 3.3.4 to 3.3.6.
- [Release notes](https://github.com/ruby/rexml/releases)
- [Changelog](https://github.com/ruby/rexml/blob/master/NEWS.md)
- [Commits](https://github.com/ruby/rexml/compare/v3.3.4...v3.3.6)

---
updated-dependencies:
- dependency-name: rexml
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-22 20:55:01 +00:00
e894f65f30 Remove rubocop-rspec for now 2024-08-03 19:43:09 +02:00
567198c182 add dev gems to Gemfile instead of gemspec 2024-08-03 19:28:01 +02:00
604fe35be6 autocorrect 2024-08-03 19:23:03 +02:00
120bb4b79a Update dependencies 2024-08-03 19:21:16 +02:00
dependabot[bot]
8fa719df7a
Bump rexml from 3.3.0 to 3.3.3
Bumps [rexml](https://github.com/ruby/rexml) from 3.3.0 to 3.3.3.
- [Release notes](https://github.com/ruby/rexml/releases)
- [Changelog](https://github.com/ruby/rexml/blob/master/NEWS.md)
- [Commits](https://github.com/ruby/rexml/compare/v3.3.0...v3.3.3)

---
updated-dependencies:
- dependency-name: rexml
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-02 16:42:11 +00:00
24f5c56ce7
Merge pull request #8 from 5sw/dependabot/bundler/rexml-3.2.8
Bump rexml from 3.2.5 to 3.2.8
2024-06-16 17:56:47 +02:00
aa72af8c5a
Merge branch 'main' into dependabot/bundler/rexml-3.2.8 2024-06-16 17:54:52 +02:00
40fe55854a Update dependencies 2024-06-16 17:51:45 +02:00
dependabot[bot]
672ad20150
Bump rexml from 3.2.5 to 3.2.8
Bumps [rexml](https://github.com/ruby/rexml) from 3.2.5 to 3.2.8.
- [Release notes](https://github.com/ruby/rexml/releases)
- [Changelog](https://github.com/ruby/rexml/blob/master/NEWS.md)
- [Commits](https://github.com/ruby/rexml/compare/v3.2.5...v3.2.8)

---
updated-dependencies:
- dependency-name: rexml
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-16 19:55:42 +00:00
d092c3650f
Remove schedule from Rubocop workflow 2022-08-07 11:18:22 +02:00
6171a51720 Add explanation why Ruby 2022-06-07 10:34:12 +02:00
bfc0eb7e45 Bump version 2022-06-07 10:16:36 +02:00
f5ce138392 [#5] Load version file 2022-06-07 10:15:40 +02:00
32dc629db3 Update readme file.
Content stolen from the original git-format-staged repo
2022-06-07 10:14:43 +02:00
496cb08324 Bump version 2022-06-07 09:48:45 +02:00
477cc57f80 Pin commit for action-junit-report 2022-06-07 09:33:41 +02:00
667931bfc2 Strings for ruby versions 2022-06-07 09:31:58 +02:00
2dd04cac7b Run tests with ruby 2.7 and 3.0 2022-06-07 09:29:46 +02:00
7254cc338c Allow ruby >= 2.7 2022-06-07 09:25:41 +02:00
cd2dddcb3e Bump version 2022-06-06 11:56:22 +02:00
c738bc5eba Print exception message and exit 1 2022-06-06 11:55:43 +02:00
3c7bfbc437 Verify error messages 2022-06-06 11:50:57 +02:00
95633bfd9f Don’t write formatter output to object database if not updating the repo 2022-06-06 11:43:44 +02:00
aad741f8cc Only check for staged changes if writing output 2022-06-06 11:40:40 +02:00
7da4a0e0f0 Check that files stay unchanged in write mode. 2022-06-06 11:38:37 +02:00
9a18902495 Allow additional arguments to run_formatter 2022-06-06 11:34:19 +02:00
159c0ac25b Verify that a failing formatter fails the whole process 2022-06-06 11:32:59 +02:00
8d8471951a Add fail mode to test_hook.rb 2022-06-06 11:30:55 +02:00
2e59062454 Use git to check for empty index. 2022-06-06 11:27:39 +02:00
47363ad14b Fail if test_hook cannot be run 2022-06-06 10:58:40 +02:00
f4657ba967 Expand test hook to return empty output if line #clear is in input + write spec for test hook. 2022-06-06 10:42:09 +02:00
ba54c407cb [#1] Fail if all files are reset to committed version. 2022-06-06 10:18:05 +02:00
d00760732d Specify branch name with git init 2022-06-05 13:19:08 +02:00
b138a43b80 Gemfile.lock 2022-06-05 13:03:58 +02:00
c76200e4a6 Configure git 2022-06-05 13:03:51 +02:00
6eab549d8a Fix yaml 2022-06-05 13:00:07 +02:00
b1c6f0bff7 Ignore rspec.xml 2022-06-05 12:57:42 +02:00
64d2eb9a43 Add github action for rspec 2022-06-05 12:57:27 +02:00
3711acbec3 Test basic functionality 2022-06-05 12:40:48 +02:00
736f394281 Set tab size 2022-05-29 12:51:43 +02:00
d52e6ea10e Prepare for testing 2022-05-29 12:17:28 +02:00
29f580b431 Prepare for rspec tests 2022-05-29 11:14:54 +02:00
9fd78ac2e1 Create top-level module 2022-05-29 10:47:07 +02:00
6fd8bb9ce7 Error out if negative pattern is specified 2022-05-29 10:34:50 +02:00
9339382f59 Formatting 2022-05-29 10:24:55 +02:00
948ab8604b Run rubocop via Rakefile 2022-05-29 10:20:33 +02:00
2b26a3aa0e Add code-scanning-rubocop to Gemfile 2022-05-29 10:05:04 +02:00
664cb4d690 Rubocop config 2022-05-29 09:54:52 +02:00
98302d88ea Single quotes 2022-05-29 09:54:43 +02:00
5f8af000ee More refactoring 2022-05-29 09:45:13 +02:00
d933f690d4 Split run into smaller methods 2022-05-29 09:41:43 +02:00
f41d9dbb39 Initialize using options ahsh 2022-05-29 09:40:39 +02:00
3ca8d24b88
Merge pull request #4 from 5sw/colors
Color output
2022-05-29 09:31:37 +02:00
2b75e3b608 Don’t split lines where not needed 2022-05-29 09:29:08 +02:00
77bab64a12 Message for updating the working copy 2022-05-29 09:28:16 +02:00
4fea7d525e Colour output 2022-05-29 09:25:09 +02:00
b8b7cc8d12 Cleanup 2022-05-26 09:32:49 +02:00
eb0aa2105c Lint 2022-05-26 09:29:09 +02:00
bb0a3a80c4 Add colorize gem + cli option 2022-05-26 09:16:14 +02:00
33c5566838 Merge branch 'main' of github.com:5sw/format-staged 2022-05-26 08:56:36 +02:00
d2a41716b2 Add rdoc comments to classes. 2022-05-25 22:02:48 +02:00
c534bb04f6
Create GitHub action to run rubocop 2022-05-25 21:32:21 +02:00
19703bfaa1 Bump version 2022-05-25 21:10:50 +02:00
ccd69238c7 Add .ruby-version 2022-05-25 21:10:08 +02:00
d594c5239e Don’t dump patch in verbose mode 2022-05-25 21:03:26 +02:00
19bea3495c Read output from git patch 2022-05-25 21:02:05 +02:00
721ded5e42 Add example pre-commit hook 2022-05-25 20:38:40 +02:00
22 changed files with 798 additions and 160 deletions

40
.github/workflows/rspec.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: "RSpec"
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
rspec:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby:
- '2.7'
- '3.0'
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Ruby
uses: ruby/setup-ruby@f20f1eae726df008313d2e0d78c5e602562a1bcf
with:
ruby-version: ${{ matrix.ruby }}
- name: Install dependencies
run: bundle install --with=ci
- name: Run tests
run: bundle exec rake spec_github
- name: Publish Test Report
uses: mikepenz/action-junit-report@41a3188dde10229782fd78cd72fc574884dd7686
if: always() # always run even if the previous step fails
with:
report_paths: rspec.xml
check_name: Rspec for Ruby ${{ matrix.ruby }}
fail_on_failure: true

44
.github/workflows/rubocop.yml vendored Normal file
View file

@ -0,0 +1,44 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# pulled from repo
name: "Rubocop"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
jobs:
rubocop:
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Ruby
uses: ruby/setup-ruby@f20f1eae726df008313d2e0d78c5e602562a1bcf
with:
ruby-version: 2.7
- name: Install dependencies
run: bundle install --with=ci
- name: Rubocop run
run: |
bash -c "
bundle exec rake lint_github
[[ $? -ne 2 ]]
"
- name: Upload Sarif output
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: rubocop.sarif

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
vendor/
.bundle/
pkg/
rspec.xml

View file

@ -1,5 +1,16 @@
require:
- rubocop-rake
AllCops:
NewCops: enable
TargetRubyVersion: 2.7
Exclude:
- "vendor/**/*"
Metrics/MethodLength:
Max: 15
Metrics/BlockLength:
Exclude:
- bin/git-format-staged # Long block for option parser is ok
- spec/**/*.rb

1
.ruby-version Normal file
View file

@ -0,0 +1 @@
2.7.5

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"editor.tabSize": 2
}

11
Gemfile
View file

@ -3,3 +3,14 @@
source 'http://rubygems.org'
gemspec
group :ci do
gem 'code-scanning-rubocop'
gem 'rspec_junit_formatter'
end
gem 'rake', '~> 13.0'
gem 'rspec'
gem 'rubocop', '~> 1.29'
gem 'rubocop-rake', '~> 0.6'

View file

@ -1,41 +1,70 @@
PATH
remote: .
specs:
format-staged (0.0.2)
format-staged (0.1.2)
colorize
GEM
remote: http://rubygems.org/
specs:
ast (2.4.2)
parallel (1.22.1)
parser (3.1.2.0)
code-scanning-rubocop (0.6.1)
rubocop (~> 1.0)
colorize (1.1.0)
diff-lcs (1.5.1)
json (2.7.2)
language_server-protocol (3.17.0.3)
parallel (1.25.1)
parser (3.3.4.0)
ast (~> 2.4.1)
racc
racc (1.8.1)
rainbow (3.1.1)
rake (13.0.6)
regexp_parser (2.4.0)
rexml (3.2.5)
rubocop (1.29.1)
rake (13.2.1)
regexp_parser (2.9.2)
rexml (3.3.9)
rspec (3.13.0)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.1)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.65.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.1.0.0)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
regexp_parser (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.17.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.18.0)
parser (>= 3.1.1.0)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3)
parser (>= 3.3.1.0)
rubocop-rake (0.6.0)
rubocop (~> 1.0)
ruby-progressbar (1.11.0)
unicode-display_width (2.1.0)
ruby-progressbar (1.13.0)
unicode-display_width (2.5.0)
PLATFORMS
ruby
DEPENDENCIES
code-scanning-rubocop
format-staged!
rake (~> 13.0)
rspec
rspec_junit_formatter
rubocop (~> 1.29)
rubocop-rake (~> 0.6)

108
README.md
View file

@ -1,3 +1,109 @@
# git-format-staged
Port of [hallettj/git-format-staged](https://github.com/hallettj/git-format-staged) to Ruby.
Port of [hallettj/git-format-staged](https://github.com/hallettj/git-format-staged)
to Ruby.
Consider a project where you want all code formatted consistently. So you use
a formatter and/or linter. (For example [SwiftFormat][]) You want to make sure
that everyone working on the project runs the formatter, add a git pre-commit
hook to run it. The naive way to write that hook would be to:
- get a list of staged files
- run the formatter on those files
- run `git add` to stage the results of formatting
The problem with that solution is it forces you to commit entire files. At
worst this will lead to contributors to unwittingly committing changes. At
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 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.
After formatting a staged file git-format-staged computes a patch which it
attempts to apply to the working tree file to keep the working tree in sync
with staged changes. If patching fails you will see a warning message. The
version of the file that is committed will be formatted properly - the warning
just means that working tree copy of the file has been left unformatted. The
patch step can be disabled with the `--no-update-working-tree` option.
[SwiftFormat]: https://github.com/nicklockwood/SwiftFormat
## How to install
Requires Ruby 2.7 or newer. Tests run on 2.7 and 3.0.
Install as a development dependency in a project that uses bundle to manage
Ruby dependencies:
$ bundle add format-staged
Or install globally:
$ gem install format-staged
## How to use
For detailed information run:
$ [bundle exec] 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-filepath "{}"' '*.js'
That will format all `.js` files using `prettier`.
The formatter command must read file content from `stdin`, and output formatted
content to `stdout`.
Patterns are evaluated from left-to-right: if a file matches multiple patterns
the right-most pattern determines whether the file is included or excluded.
git-format-staged never operates on files that are excluded from version
control. So it is not necessary to explicitly exclude stuff like
`vendor/`.
The formatter command may include a placeholder, `{}`, which will be replaced
with the path of the file that is being formatted. This is useful if your
formatter needs to know the file extension to determine how to format or to
lint each file. For example:
$ git-format-staged -f 'prettier --stdin-filepath "{}"' '*.js' '*.css'
Do not attempt to read or write to `{}` in your formatter command! The
placeholder exists only for referencing the file name and path.
### Check staged changes with a linter without formatting
Perhaps you do not want to reformat files automatically; but you do want to
prevent files from being committed if they do not conform to style rules. You
can use git-format-staged with the `--no-write` option, and supply a lint
command instead of a format command. Here is an example using ESLint:
$ git-format-staged --no-write -f 'eslint --stdin --stdin-filename "{}" >&2' 'src/*.js'
If this command is run in a pre-commit hook, and the lint command fails the
commit will be aborted and error messages will be displayed. The lint command
must read file content via `stdin`. Anything that the lint command outputs to
`stdout` will be ignored. In the example above `eslint` is given the `--stdin`
option to tell it to read content from `stdin` instead of reading files from
disk, and messages from `eslint` are redirected to `stderr` (using the `>&2`
notation) so that you can see them.
### Why the Ruby port if there already is a fine Python implementation?
I dont like Python ;)
But jokes aside, I am already setting up a Ruby environment (using [rbenv][]) for my
projects to run [cocoapods][] and [fastlane][] and our git hooks. By using this port
we dont need to ensure to have python available as well.
[rbenv]: https://github.com/rbenv/rbenv/
[cocoapods]: https://cocoapods.org/
[fastlane]: https://fastlane.tools/

View file

@ -14,3 +14,17 @@ require 'rubocop/rake_task'
desc 'Run RuboCop'
RuboCop::RakeTask.new(:lint)
desc 'Run RuboCop for GitHub'
RuboCop::RakeTask.new(:lint_github) do |t|
t.requires << 'code_scanning'
t.formatters << 'CodeScanning::SarifFormatter'
t.options << '-o' << 'rubocop.sarif'
end
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec)
RSpec::Core::RakeTask.new(:spec_github) do |t|
t.rspec_opts = '--format RspecJunitFormatter --out rspec.xml'
end

View file

@ -17,7 +17,7 @@ parser = OptionParser.new do |opt|
<<~DOC
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-filepath \'{}\'")
(Example: "prettier --stdin-filepath '{}'")
DOC
) do |o|
parameters[:formatter] = o
@ -57,6 +57,10 @@ parser = OptionParser.new do |opt|
puts FormatStaged::VERSION
exit
end
opt.on('--[no-]color', 'Colorizes output') do |value|
parameters[:color_output] = value
end
end
parser.parse!
@ -69,5 +73,10 @@ if !parameters[:formatter] || parameters[:patterns].empty?
exit
end
formatter = FormatStaged.new(**parameters)
formatter.run
begin
success = FormatStaged.run(**parameters)
exit success ? 0 : 1
rescue RuntimeError => e
puts "💣 Error: #{e.message}".red
exit 1
end

2
examples/pre-commit.rubocop Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
bundle exec git-format-staged -f "bundle exec rubocop -x -s {} --stderr" "*.rb" --verbose

View file

@ -15,11 +15,9 @@ Gem::Specification.new do |s|
s.executables << 'git-format-staged'
s.homepage = 'https://github.com/5sw/format-staged'
s.license = 'MIT'
s.required_ruby_version = '~> 2.7'
s.required_ruby_version = '>= 2.7'
s.add_development_dependency 'rake', '~> 13.0'
s.add_development_dependency 'rubocop', '~> 1.29'
s.add_development_dependency 'rubocop-rake', '~> 0.6'
s.add_dependency 'colorize'
s.metadata = {
'rubygems_mfa_required' => 'true'

View file

@ -1,6 +1,10 @@
# frozen_string_literal: true
class FormatStaged
module FormatStaged
##
# Entry in the git index.
#
# Data as produced by `git diff-index`
class Entry
PATTERN = /^
:(?<src_mode>\d+)\s

View file

@ -1,43 +1,77 @@
# frozen_string_literal: true
require 'English'
class FormatStaged
def get_output(*args, lines: true)
puts "> #{args.join(' ')}" if @verbose
output = IO.popen(args, err: :err) do |io|
if lines
io.readlines.map(&:chomp)
else
io.read
end
module FormatStaged
##
# Mixin that provides IO methods
module IOMixin
def get_output(*args, lines: true, silent: false)
puts "> #{args.join(' ')}" if @verbose
r = IO.popen(args, err: :err)
output = read_output(r, lines: lines, silent: silent)
raise 'Failed to run command' unless $CHILD_STATUS.success?
output
end
if @verbose && lines
output.each do |line|
puts "< #{line}"
end
def get_status(*args)
puts "> #{args.join(' ')}" if @verbose
result = system(*args)
raise 'Failed to run command' if result.nil?
puts "? #{$CHILD_STATUS.exitstatus}" if @verbose
$CHILD_STATUS.exitstatus
end
raise 'Failed to run command' unless $CHILD_STATUS.success?
def pipe_command(*args, source: nil)
puts (source.nil? ? '> ' : '| ') + args.join(' ') if @verbose
r, w = IO.pipe
output
end
opts = {}
opts[:in] = source unless source.nil?
opts[:out] = w
opts[:err] = :err
def pipe_command(*args, source: nil)
puts (source.nil? ? '> ' : '| ') + args.join(' ') if @verbose
r, w = IO.pipe
pid = spawn(*args, **opts)
opts = {}
opts[:in] = source unless source.nil?
opts[:out] = w
opts[:err] = :err
w.close
source&.close
pid = spawn(*args, **opts)
[pid, r]
end
w.close
source&.close
def read_output(output, lines: true, silent: false)
result = output.read
splits = result.split("\n")
if @verbose && !silent
splits.each do |line|
puts "< #{line}"
end
end
output.close
[pid, r]
lines ? splits : result
end
def fail!(message)
abort "💣 #{message.red}"
end
def warning(message)
warn "⚠️ #{message.yellow}"
end
def info(message)
puts message.blue
end
def verbose_info(message)
puts " #{message}" if verbose
end
end
end

157
lib/format-staged/job.rb Normal file
View file

@ -0,0 +1,157 @@
# frozen_string_literal: true
require_relative 'entry'
require_relative 'io'
require 'shellwords'
require 'colorize'
require 'English'
module FormatStaged
##
# Runs staged changes through a formatting tool
class Job
include IOMixin
attr_reader :formatter, :patterns, :update, :write, :verbose
def initialize(formatter:, patterns:, **options)
validate_patterns patterns
@formatter = formatter
@patterns = patterns
@update = options.fetch(:update, true)
@write = options.fetch(:write, true)
@verbose = options.fetch(:verbose, true)
String.disable_colorization = !options.fetch(:color_output, $stdout.isatty)
end
def run
files = matching_files(repo_root)
if files.empty?
info 'No staged file matching pattern. Done'
return true
end
formatted = files.filter { |file| format_file file }
return false unless formatted.size == files.size
quiet = @verbose ? [] : ['--quiet']
!write || get_status('git', 'diff-index', '--cached', '--exit-code', *quiet, 'HEAD') != 0
end
def repo_root
verbose_info 'Finding repository root'
root = get_output('git', 'rev-parse', '--show-toplevel', lines: false).chomp
verbose_info "Repo at #{root}"
root
end
def matching_files(root)
verbose_info 'Listing staged files'
get_output('git', 'diff-index', '--cached', '--diff-filter=AM', '--no-renames', 'HEAD')
.map { |line| Entry.new(line, root: root) }
.reject(&:symlink?)
.filter { |entry| entry.matches?(@patterns) }
end
def format_file(file)
new_hash = format_object file
return true unless write
if new_hash == file.dst_hash
info "Unchanged #{file.src_path}"
return true
end
if object_is_empty new_hash
info "Skipping #{file.src_path}, formatted file is empty"
return false
end
replace_file_in_index file, new_hash
update_working_copy file, new_hash
if new_hash == file.src_hash
info "File #{file.src_path} equal to comitted"
return true
end
true
end
def update_working_copy(file, new_hash)
return unless update
begin
patch_working_file file, new_hash
rescue StandardError => e
warning "failed updating #{file.src_path} in working copy: #{e}"
end
end
def format_object(file)
info "Formatting #{file.src_path}"
format_command = formatter.sub('{}', file.src_path.shellescape)
pid1, r = pipe_command 'git', 'cat-file', '-p', file.dst_hash
pid2, r = pipe_command format_command, source: r
write_args = write ? ['-w'] : []
pid3, r = pipe_command 'git', 'hash-object', *write_args, '--stdin', source: r
result = read_output(r, lines: false).chomp
Process.wait pid1
raise "Cannot read #{file.dst_hash} from object database" unless $CHILD_STATUS.success?
Process.wait pid2
raise "Error formatting #{file.src_path}" unless $CHILD_STATUS.success?
Process.wait pid3
raise 'Error writing formatted file back to object database' unless $CHILD_STATUS.success? && !result.empty?
result
end
def object_is_empty(hash)
size = get_output('git', 'cat-file', '-s', hash).first.to_i
size.zero?
end
def patch_working_file(file, new_hash)
info 'Updating working copy'
patch = get_output 'git', 'diff', file.dst_hash, new_hash, lines: false, silent: true
patch.gsub! "a/#{file.dst_hash}", "a/#{file.src_path}"
patch.gsub! "b/#{new_hash}", "b/#{file.src_path}"
input, patch_out = IO.pipe
pid, r = pipe_command 'git', 'apply', '-', source: input
patch_out.write patch
patch_out.close
read_output r
Process.wait pid
raise 'Error applying patch' unless $CHILD_STATUS.success?
end
def replace_file_in_index(file, new_hash)
get_output 'git', 'update-index', '--cacheinfo', "#{file.dst_mode},#{new_hash},#{file.src_path}"
end
def validate_patterns(patterns)
patterns.each do |pattern|
fail! "Negative pattern '#{pattern}' is not yet supported" if pattern.start_with? '!'
end
end
end
end

View file

@ -1,5 +1,5 @@
# frozen_string_literal: true
class FormatStaged
VERSION = '0.0.2'
module FormatStaged
VERSION = '0.1.2'
end

View file

@ -1,112 +1,12 @@
# frozen_string_literal: true
require 'English'
require 'format-staged/version'
require 'format-staged/entry'
require 'format-staged/io'
require 'shellwords'
require_relative 'format-staged/job'
require_relative 'format-staged/version'
class FormatStaged
attr_reader :formatter, :patterns, :update, :write, :verbose
def initialize(formatter:, patterns:, update: true, write: true, verbose: true)
@formatter = formatter
@patterns = patterns
@update = update
@write = write
@verbose = verbose
end
def run
root = get_output('git', 'rev-parse', '--show-toplevel').first
files = get_output('git', 'diff-index', '--cached', '--diff-filter=AM', '--no-renames', 'HEAD')
.map { |line| Entry.new(line, root: root) }
.reject(&:symlink?)
.filter { |entry| entry.matches?(@patterns) }
files.each do |file|
format_file(file)
end
end
def format_file(file)
new_hash = format_object file
return true unless write
if new_hash == file.dst_hash
puts "Unchanged #{file.src_path}"
return false
end
if object_is_empty new_hash
puts "Skipping #{file.src_path}, formatted file is empty"
return false
end
replace_file_in_index file, new_hash
if update
begin
patch_working_file file, new_hash
rescue StandardError => e
puts "Warning: failed updating #{file.src_path} in working copy: #{e}"
end
end
true
end
def format_object(file)
puts "Formatting #{file.src_path}"
format_command = formatter.sub('{}', file.src_path.shellescape)
pid1, r = pipe_command 'git', 'cat-file', '-p', file.dst_hash
pid2, r = pipe_command format_command, source: r
pid3, r = pipe_command 'git', 'hash-object', '-w', '--stdin', source: r
result = r.readlines.map(&:chomp)
if @verbose
result.each do |line|
puts "< #{line}"
end
end
Process.wait pid1
raise "Cannot read #{file.dst_hash} from object database" unless $CHILD_STATUS.success?
Process.wait pid2
raise "Error formatting #{file.src_path}" unless $CHILD_STATUS.success?
Process.wait pid3
raise 'Error writing formatted file back to object database' unless $CHILD_STATUS.success? && !result.empty?
result.first
end
def object_is_empty(hash)
size = get_output('git', 'cat-file', '-s', hash).first.to_i
size.zero?
end
def patch_working_file(file, new_hash)
patch = get_output 'git', 'diff', file.dst_hash, new_hash, lines: false
patch.gsub! "a/#{file.dst_hash}", "a/#{file.src_path}"
patch.gsub! "b/#{new_hash}", "b/#{file.src_path}"
input, patch_out = IO.pipe
pid, r = pipe_command 'git', 'apply', '-', source: input
patch_out.write patch
patch_out.close
Process.wait pid
raise 'Error applying patch' unless $CHILD_STATUS.success?
end
def replace_file_in_index(file, new_hash)
get_output 'git', 'update-index', '--cacheinfo', "#{file.dst_mode},#{new_hash},#{file.src_path}"
##
# FormatStaged module
module FormatStaged
def self.run(**options)
Job.new(**options).run
end
end

88
spec/git.rb Normal file
View file

@ -0,0 +1,88 @@
# frozen_string_literal: true
require 'tmpdir'
require 'fileutils'
require 'English'
##
# Test helpers for managing a git repository
module Git
##
# A git repository
class Repo
attr_reader :path
def initialize
@path = Dir.mktmpdir
git 'init', '-b', 'main'
git 'config', 'user.name', 'Test User'
git 'config', 'user.email', 'test@example.com'
end
def file_in_tree(name, content)
set_content name, content
stage name
commit "Add #{name}"
end
def set_content(name, content)
absolute = Pathname.new(path) + name
FileUtils.mkdir_p absolute.dirname
File.write absolute, content.end_with?("\n") ? content : "#{content}\n"
end
def get_content(name)
File.read(Pathname.new(path) + name).chomp
end
def get_staged(name)
git 'show', ":#{name}"
end
def stage(file)
git 'add', file
end
def commit(message)
git 'commit', '-m', message
end
def content(file)
git 'show', ":#{file}"
end
def cleanup
FileUtils.remove_entry path
end
def git(*cmd)
in_repo do
output = IO.popen(['git'] + cmd) do |io|
io.read.chomp
end
raise 'Failed to run git' unless $CHILD_STATUS.success?
output
end
end
def in_repo(&block)
Dir.chdir path, &block
end
def run_formatter(**arguments)
in_repo do
FormatStaged.run(**arguments, formatter: "#{__dir__}/test_hook.rb {}", patterns: ['*.test'])
end
end
end
def self.new_repo
repo = Repo.new
yield repo if block_given?
repo
end
end

13
spec/test_hook.rb Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
output = []
$stdin.readlines.each do |line|
exit 0 if line.chomp == '#clear'
exit 1 if line.chomp == '#fail'
output << line.gsub(/([^\s]*)\s*=\s*(.*)/, '\1 = \2')
end
output.each do |line|
puts line
end

34
spec/test_hook_spec.rb Normal file
View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
require 'English'
describe 'Test Hook' do
it 'adds spaces around =' do
out = run 'a=b'
expect(out).to eq('a = b')
end
it 'returns empty output if it as a #clear line' do
out = run "a=b\n#clear\nc = d"
expect(out).to eq ''
end
it 'fails with a #fail line' do
expect { run '#fail' }.to raise_error(RuntimeError) { |error|
expect(error.message).to eq('Cannot run test_hook.rb')
}
end
def run(input)
result = IO.popen ['ruby', "#{__dir__}/test_hook.rb"], mode: File::RDWR do |io|
io.write input
io.close_write
io.read.chomp
end
raise 'Cannot run test_hook.rb' unless $CHILD_STATUS.success?
result
end
end

139
spec/test_spec.rb Normal file
View file

@ -0,0 +1,139 @@
# frozen_string_literal: true
require_relative 'git'
require 'format_staged'
describe FormatStaged do
def repo
@repo ||= Git.new_repo do |repo|
repo.file_in_tree 'origin.test', 'x = y'
end
end
after :each do
@repo&.cleanup
@repo = nil
end
it 'updates staged file and working copy' do
repo.set_content 'test.test', 'a=b'
repo.stage 'test.test'
success = repo.run_formatter
expect(success).to be_truthy
expect(repo.get_staged('test.test')).to eq('a = b')
expect(repo.get_content('test.test')).to eq('a = b')
end
it 'leaves other changes in working copy' do
repo.set_content 'test.test', "x=y\na=b\n"
repo.stage 'test.test'
repo.set_content 'test.test', 'abc'
success = repo.run_formatter
expect(success).to be_truthy
expect(repo.get_content('test.test')).to eq('abc')
expect(repo.get_staged('test.test')).to eq("x = y\na = b")
end
it 'merges update to working copy' do
repo.set_content 'test.test', "x=y\n#stuff\n"
repo.stage 'test.test'
repo.set_content 'test.test', "x=y\n#stuff\nmore stuff\n"
success = repo.run_formatter
expect(success).to be_truthy
expect(repo.get_content('test.test')).to eq("x = y\n#stuff\nmore stuff")
expect(repo.get_staged('test.test')).to eq("x = y\n#stuff")
end
it 'only touches files matching the given pattern' do
repo.set_content 'test.other', 'x=y'
repo.stage 'test.other'
success = repo.run_formatter
expect(success).to be_truthy
expect(repo.get_content('test.other')).to eq('x=y')
end
it 'fails if all files are changed to already comitted version' do
repo.file_in_tree 'test.test', 'x = y'
repo.set_content 'test.test', 'x=y'
repo.stage 'test.test'
success = repo.run_formatter
expect(success).to be_falsy
expect(repo.get_content('test.test')).to eq('x = y')
end
it 'succeeds if there are excluded files to commit' do
repo.file_in_tree 'test.test', 'x = y'
repo.set_content 'test.test', 'x=y'
repo.stage 'test.test'
repo.set_content 'test.other', 'abc'
repo.stage 'test.other'
success = repo.run_formatter
expect(success).to be_truthy
expect(repo.get_content('test.test')).to eq('x = y')
end
it 'succeeds if there are no staged files' do
expect(repo.run_formatter).to be_truthy
end
it 'succeeds if only excluded files are changed' do
repo.set_content 'test.other', 'abc'
repo.stage 'test.other'
expect(repo.run_formatter).to be_truthy
end
it 'succeeds if one file is changed' do
repo.file_in_tree 'test.test', 'x = y'
repo.set_content 'test.test', 'x=y'
repo.stage 'test.test'
repo.set_content 'other.test', 'a=b'
repo.stage 'other.test'
success = repo.run_formatter
expect(success).to be_truthy
expect(repo.get_content('test.test')).to eq('x = y')
expect(repo.get_content('other.test')).to eq('a = b')
end
it 'fails if a single file becomes empty' do
repo.file_in_tree 'test.test', 'x = y'
repo.set_content 'test.test', '#clear'
repo.stage 'test.test'
repo.set_content 'other.test', 'a=b'
repo.stage 'other.test'
success = repo.run_formatter
expect(success).to be_falsy
expect(repo.get_content('test.test')).to eq('#clear')
expect(repo.get_content('other.test')).to eq('a = b')
end
it 'fails if the hook returns a nonzero status' do
repo.set_content 'test.test', '#fail'
repo.stage 'test.test'
expect { repo.run_formatter }.to raise_error(RuntimeError) { |error|
expect(error.message).to eq 'Error formatting test.test'
}
end
it 'leaves files alone when write is false' do
repo.set_content 'test.test', 'a=b'
repo.stage 'test.test'
expect(repo.run_formatter(write: false)).to be_truthy
expect(repo.get_content('test.test')).to eq 'a=b'
expect(repo.get_staged('test.test')).to eq 'a=b'
end
end