diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml deleted file mode 100644 index 2ddcae6..0000000 --- a/.github/workflows/rspec.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml deleted file mode 100644 index 732cf2b..0000000 --- a/.github/workflows/rubocop.yml +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index e54ca87..0000000 --- a/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -vendor/ -.bundle/ -pkg/ -rspec.xml diff --git a/.rubocop.yml b/.rubocop.yml deleted file mode 100644 index f3d5561..0000000 --- a/.rubocop.yml +++ /dev/null @@ -1,16 +0,0 @@ -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 diff --git a/.ruby-version b/.ruby-version deleted file mode 100644 index a603bb5..0000000 --- a/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -2.7.5 diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index ff30c44..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "editor.tabSize": 2 -} \ No newline at end of file diff --git a/Gemfile b/Gemfile deleted file mode 100644 index c57d2a7..0000000 --- a/Gemfile +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -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' diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 9344382..0000000 --- a/Gemfile.lock +++ /dev/null @@ -1,72 +0,0 @@ -PATH - remote: . - specs: - format-staged (0.1.2) - colorize - -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) - 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) - parallel (~> 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) - ruby-progressbar (~> 1.7) - 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.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) - -BUNDLED WITH - 2.1.4 diff --git a/README.md b/README.md index bcf923c..6bfa56d 100644 --- a/README.md +++ b/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. diff --git a/Rakefile b/Rakefile deleted file mode 100644 index f0ec11c..0000000 --- a/Rakefile +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require 'bundler/gem_tasks' - -begin - Bundler.setup(:default, :development) -rescue Bundler::BundlerError => e - warn e.message - warn 'Run `bundle install` to install missing gems' - exit e.status_code -end - -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 diff --git a/bin/git-format-staged b/bin/git-format-staged index 2111bcc..7a35a41 100755 --- a/bin/git-format-staged +++ b/bin/git-format-staged @@ -1,82 +1,54 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -require 'format_staged' +require 'format-staged' require 'optparse' parameters = { - update: true, - write: true, - verbose: false + :update => true, + :write => true, + :verbose => false, } parser = OptionParser.new do |opt| opt.banner = "Usage: #{opt.program_name} [options] [patterns]" - opt.separator '' - opt.on('-f', '--formatter COMMAND', - <<~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 '{}'") - DOC - ) do |o| + opt.separator "" + opt.on('-f', '--formatter COMMAND', '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 \'{}\'")') do |o| parameters[:formatter] = o end - - opt.on('--[no-]update-working-tree', - <<~DOC - 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.#{' '} - DOC - ) do |value| + + opt.on('--[no-]update-working-tree', '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.') do |value| parameters[:update] = value end - - opt.on('--[no-]write', - <<~DOC - "Prevents #{opt.program_name} 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." - DOC - ) do |value| + + opt.on('--[no-]write', "Prevents #{opt.program_name} 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.") do |value| parameters[:write] = value end - - opt.on('-v', '--[no-]verbose', 'Shows commands being run') do |value| + + opt.on("-v", "--[no-]verbose", 'Shows commands being run') do |value| parameters[:verbose] = value end - opt.separator '' - + opt.separator "" + opt.on_tail('-h', '--help', 'Prints this help') do puts opt exit end - - opt.on_tail('--version', 'Prints the version number and exits') do + + opt.on_tail('--version', "Prints the version number and exits") do puts FormatStaged::VERSION exit end - - opt.on('--[no-]color', 'Colorizes output') do |value| - parameters[:color_output] = value - end end parser.parse! parameters[:patterns] = ARGV -if !parameters[:formatter] || parameters[:patterns].empty? - puts 'Missing formatter or file patterns!' - +if !parameters[:formatter] or parameters[:patterns].empty? + puts "Missing formatter or file patterns!" + puts parser 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 diff --git a/examples/pre-commit.rubocop b/examples/pre-commit.rubocop deleted file mode 100755 index c5ffa7a..0000000 --- a/examples/pre-commit.rubocop +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -bundle exec git-format-staged -f "bundle exec rubocop -x -s {} --stderr" "*.rb" --verbose \ No newline at end of file diff --git a/format-staged.gemspec b/format-staged.gemspec index ddde6c8..df36505 100644 --- a/format-staged.gemspec +++ b/format-staged.gemspec @@ -1,25 +1,16 @@ -# frozen_string_literal: true - -lib = File.expand_path('lib', __dir__) +lib = File.expand_path("lib", __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'format-staged/version' Gem::Specification.new do |s| s.name = 'format-staged' s.version = FormatStaged::VERSION - s.summary = 'git format staged!' - s.description = 'git format staged' - s.authors = ['Sven Weidauer'] + s.summary = "git format staged!" + s.description = "git format staged" + s.authors = ["Sven Weidauer"] s.email = 'sven@5sw.de' s.files = Dir['lib/**/*.rb'] s.executables << 'git-format-staged' s.homepage = 'https://github.com/5sw/format-staged' s.license = 'MIT' - s.required_ruby_version = '>= 2.7' - - s.add_dependency 'colorize' - - s.metadata = { - 'rubygems_mfa_required' => 'true' - } end diff --git a/lib/format-staged.rb b/lib/format-staged.rb new file mode 100644 index 0000000..69e7c48 --- /dev/null +++ b/lib/format-staged.rb @@ -0,0 +1,109 @@ +require 'format-staged/version' +require 'format-staged/entry' +require 'format-staged/io' +require 'shellwords' + +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 { |entry| entry.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 if not 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 => error + puts "Warning: failed updating #{file.src_path} in working copy: #{error}" + 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 { |it| it.chomp } + if @verbose + result.each do |line| + puts "< #{line}" + end + end + + Process.wait pid1 + raise "Cannot read #{file.dst_hash} from object database" unless $?.success? + + Process.wait pid2 + raise "Error formatting #{file.src_path}" unless $?.success? + + Process.wait pid3 + raise "Error writing formatted file back to object database" unless $?.success? && !result.empty? + + result.first + end + + def object_is_empty(hash) + size = get_output("git", "cat-file", "-s", hash).first.to_i + size == 0 + 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 $?.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 diff --git a/lib/format-staged/entry.rb b/lib/format-staged/entry.rb index 3828903..5f58847 100644 --- a/lib/format-staged/entry.rb +++ b/lib/format-staged/entry.rb @@ -1,23 +1,9 @@ -# frozen_string_literal: true - -module FormatStaged - ## - # Entry in the git index. - # - # Data as produced by `git diff-index` +class FormatStaged class Entry - PATTERN = /^ - :(?\d+)\s - (?\d+)\s - (?[a-f0-9]+)\s - (?[a-f0-9]+)\s - (?[A-Z])(?\d+)?\t - (?[^\t]+) - (?:\t(?[^\t]+))? - $/x.freeze + PATTERN = /^:(?\d+) (?\d+) (?[a-f0-9]+) (?[a-f0-9]+) (?[A-Z])(?\d+)?\t(?[^\t]+)(?:\t(?[^\t]+))?$/ attr_reader :src_mode, :dst_mode, :src_hash, :dst_hash, :status, :score, :src_path, :dst_path, :path, :root - + def initialize(line, root:) matches = line.match(PATTERN) or raise "Cannot parse output #{line}" @src_mode = matches[:src_mode] @@ -31,15 +17,17 @@ module FormatStaged @path = File.expand_path(@src_path, root) @root = root end - + def symlink? @dst_mode == '120000' end - + def matches?(patterns) result = false patterns.each do |pattern| - result = true if File.fnmatch? pattern, path, File::FNM_EXTGLOB + if File.fnmatch? pattern, path, File::FNM_EXTGLOB + result = true + end end result end diff --git a/lib/format-staged/io.rb b/lib/format-staged/io.rb index 8d3bf5d..44a447b 100644 --- a/lib/format-staged/io.rb +++ b/lib/format-staged/io.rb @@ -1,77 +1,40 @@ -# frozen_string_literal: true - -require 'English' - -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 +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 { |l| l.chomp } + else + io.read end - output.close - - 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 + + if @verbose and lines + output.each do |line| + puts "< #{line}" + end end + + raise "Failed to run command" unless $?.success? + + output 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 + + return [pid, r] + end end diff --git a/lib/format-staged/job.rb b/lib/format-staged/job.rb deleted file mode 100644 index 176b099..0000000 --- a/lib/format-staged/job.rb +++ /dev/null @@ -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 diff --git a/lib/format-staged/version.rb b/lib/format-staged/version.rb index d9e035e..dc206b1 100644 --- a/lib/format-staged/version.rb +++ b/lib/format-staged/version.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - -module FormatStaged - VERSION = '0.1.2' -end +class FormatStaged + VERSION = "0.0.1" +end \ No newline at end of file diff --git a/lib/format_staged.rb b/lib/format_staged.rb deleted file mode 100644 index 683244f..0000000 --- a/lib/format_staged.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -require_relative 'format-staged/job' -require_relative 'format-staged/version' - -## -# FormatStaged module -module FormatStaged - def self.run(**options) - Job.new(**options).run - end -end diff --git a/spec/git.rb b/spec/git.rb deleted file mode 100644 index 7ffa1e3..0000000 --- a/spec/git.rb +++ /dev/null @@ -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 diff --git a/spec/test_hook.rb b/spec/test_hook.rb deleted file mode 100755 index 595050e..0000000 --- a/spec/test_hook.rb +++ /dev/null @@ -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 diff --git a/spec/test_hook_spec.rb b/spec/test_hook_spec.rb deleted file mode 100644 index 236279d..0000000 --- a/spec/test_hook_spec.rb +++ /dev/null @@ -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 diff --git a/spec/test_spec.rb b/spec/test_spec.rb deleted file mode 100644 index 26fc0ff..0000000 --- a/spec/test_spec.rb +++ /dev/null @@ -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