Compare commits
No commits in common. "main" and "v0.0.2" have entirely different histories.
22 changed files with 163 additions and 801 deletions
40
.github/workflows/rspec.yml
vendored
40
.github/workflows/rspec.yml
vendored
|
@ -1,40 +0,0 @@
|
|||
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
44
.github/workflows/rubocop.yml
vendored
|
@ -1,44 +0,0 @@
|
|||
# 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
1
.gitignore
vendored
|
@ -1,4 +1,3 @@
|
|||
vendor/
|
||||
.bundle/
|
||||
pkg/
|
||||
rspec.xml
|
||||
|
|
11
.rubocop.yml
11
.rubocop.yml
|
@ -1,16 +1,5 @@
|
|||
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 +0,0 @@
|
|||
2.7.5
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"editor.tabSize": 2
|
||||
}
|
11
Gemfile
11
Gemfile
|
@ -3,14 +3,3 @@
|
|||
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'
|
||||
|
|
59
Gemfile.lock
59
Gemfile.lock
|
@ -1,70 +1,41 @@
|
|||
PATH
|
||||
remote: .
|
||||
specs:
|
||||
format-staged (0.1.2)
|
||||
colorize
|
||||
format-staged (0.0.2)
|
||||
|
||||
GEM
|
||||
remote: http://rubygems.org/
|
||||
specs:
|
||||
ast (2.4.2)
|
||||
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)
|
||||
parallel (1.22.1)
|
||||
parser (3.1.2.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
racc (1.8.1)
|
||||
rainbow (3.1.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)
|
||||
rake (13.0.6)
|
||||
regexp_parser (2.4.0)
|
||||
rexml (3.2.5)
|
||||
rubocop (1.29.1)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
parser (>= 3.1.0.0)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.4, < 3.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-ast (>= 1.17.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.31.3)
|
||||
parser (>= 3.3.1.0)
|
||||
unicode-display_width (>= 1.4.0, < 3.0)
|
||||
rubocop-ast (1.18.0)
|
||||
parser (>= 3.1.1.0)
|
||||
rubocop-rake (0.6.0)
|
||||
rubocop (~> 1.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
unicode-display_width (2.5.0)
|
||||
ruby-progressbar (1.11.0)
|
||||
unicode-display_width (2.1.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
108
README.md
|
@ -1,109 +1,3 @@
|
|||
# git-format-staged
|
||||
|
||||
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 don’t 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 don’t need to ensure to have python available as well.
|
||||
|
||||
|
||||
[rbenv]: https://github.com/rbenv/rbenv/
|
||||
[cocoapods]: https://cocoapods.org/
|
||||
[fastlane]: https://fastlane.tools/
|
||||
Port of [hallettj/git-format-staged](https://github.com/hallettj/git-format-staged) to Ruby.
|
||||
|
|
14
Rakefile
14
Rakefile
|
@ -14,17 +14,3 @@ 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
|
||||
|
|
|
@ -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,10 +57,6 @@ 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!
|
||||
|
@ -73,10 +69,5 @@ if !parameters[:formatter] || parameters[:patterns].empty?
|
|||
exit
|
||||
end
|
||||
|
||||
begin
|
||||
success = FormatStaged.run(**parameters)
|
||||
exit success ? 0 : 1
|
||||
rescue RuntimeError => e
|
||||
puts "💣 Error: #{e.message}".red
|
||||
exit 1
|
||||
end
|
||||
formatter = FormatStaged.new(**parameters)
|
||||
formatter.run
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
#!/bin/sh
|
||||
bundle exec git-format-staged -f "bundle exec rubocop -x -s {} --stderr" "*.rb" --verbose
|
|
@ -15,9 +15,11 @@ 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_dependency 'colorize'
|
||||
s.add_development_dependency 'rake', '~> 13.0'
|
||||
s.add_development_dependency 'rubocop', '~> 1.29'
|
||||
s.add_development_dependency 'rubocop-rake', '~> 0.6'
|
||||
|
||||
s.metadata = {
|
||||
'rubygems_mfa_required' => 'true'
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module FormatStaged
|
||||
##
|
||||
# Entry in the git index.
|
||||
#
|
||||
# Data as produced by `git diff-index`
|
||||
class FormatStaged
|
||||
class Entry
|
||||
PATTERN = /^
|
||||
:(?<src_mode>\d+)\s
|
||||
|
|
|
@ -1,77 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'English'
|
||||
class FormatStaged
|
||||
def get_output(*args, lines: true)
|
||||
puts "> #{args.join(' ')}" if @verbose
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def pipe_command(*args, source: nil)
|
||||
puts (source.nil? ? '> ' : '| ') + args.join(' ') if @verbose
|
||||
r, w = IO.pipe
|
||||
|
||||
opts = {}
|
||||
opts[:in] = source unless source.nil?
|
||||
opts[:out] = w
|
||||
opts[:err] = :err
|
||||
|
||||
pid = spawn(*args, **opts)
|
||||
|
||||
w.close
|
||||
source&.close
|
||||
|
||||
[pid, r]
|
||||
end
|
||||
|
||||
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
|
||||
output = IO.popen(args, err: :err) do |io|
|
||||
if lines
|
||||
io.readlines.map(&:chomp)
|
||||
else
|
||||
io.read
|
||||
end
|
||||
output.close
|
||||
|
||||
lines ? splits : result
|
||||
end
|
||||
|
||||
def fail!(message)
|
||||
abort "💣 #{message.red}"
|
||||
if @verbose && lines
|
||||
output.each do |line|
|
||||
puts "< #{line}"
|
||||
end
|
||||
end
|
||||
|
||||
def warning(message)
|
||||
warn "⚠️ #{message.yellow}"
|
||||
end
|
||||
raise 'Failed to run command' unless $CHILD_STATUS.success?
|
||||
|
||||
def info(message)
|
||||
puts message.blue
|
||||
end
|
||||
output
|
||||
end
|
||||
|
||||
def verbose_info(message)
|
||||
puts "ℹ️ #{message}" if verbose
|
||||
end
|
||||
def pipe_command(*args, source: nil)
|
||||
puts (source.nil? ? '> ' : '| ') + args.join(' ') if @verbose
|
||||
r, w = IO.pipe
|
||||
|
||||
opts = {}
|
||||
opts[:in] = source unless source.nil?
|
||||
opts[:out] = w
|
||||
opts[:err] = :err
|
||||
|
||||
pid = spawn(*args, **opts)
|
||||
|
||||
w.close
|
||||
source&.close
|
||||
|
||||
[pid, r]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,157 +0,0 @@
|
|||
# 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
|
|
@ -1,5 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module FormatStaged
|
||||
VERSION = '0.1.2'
|
||||
class FormatStaged
|
||||
VERSION = '0.0.2'
|
||||
end
|
||||
|
|
|
@ -1,12 +1,112 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'format-staged/job'
|
||||
require_relative 'format-staged/version'
|
||||
require 'English'
|
||||
require 'format-staged/version'
|
||||
require 'format-staged/entry'
|
||||
require 'format-staged/io'
|
||||
require 'shellwords'
|
||||
|
||||
##
|
||||
# FormatStaged module
|
||||
module FormatStaged
|
||||
def self.run(**options)
|
||||
Job.new(**options).run
|
||||
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}"
|
||||
end
|
||||
end
|
||||
|
|
88
spec/git.rb
88
spec/git.rb
|
@ -1,88 +0,0 @@
|
|||
# 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
|
|
@ -1,13 +0,0 @@
|
|||
#!/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
|
|
@ -1,34 +0,0 @@
|
|||
# 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
|
|
@ -1,139 +0,0 @@
|
|||
# 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
|
Loading…
Add table
Reference in a new issue