From 4598a41859ee46690668da150c5413bcedfe9525 Mon Sep 17 00:00:00 2001 From: Sven Weidauer Date: Sun, 22 May 2022 20:07:57 +0200 Subject: [PATCH] Initial commit --- LICENSE | 2 +- README.md | 3 + bin/git-format-staged | 54 +++++++++++++++++ format-staged.gemspec | 16 +++++ lib/format-staged.rb | 109 +++++++++++++++++++++++++++++++++++ lib/format-staged/entry.rb | 35 +++++++++++ lib/format-staged/io.rb | 40 +++++++++++++ lib/format-staged/version.rb | 3 + 8 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 README.md create mode 100755 bin/git-format-staged create mode 100644 format-staged.gemspec create mode 100644 lib/format-staged.rb create mode 100644 lib/format-staged/entry.rb create mode 100644 lib/format-staged/io.rb create mode 100644 lib/format-staged/version.rb diff --git a/LICENSE b/LICENSE index 3057d3c..cbb1936 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Sven +Copyright (c) 2022 Sven Weidauer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bfa56d --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# git-format-staged + +Port of [hallettj/git-format-staged](https://github.com/hallettj/git-format-staged) to Ruby. diff --git a/bin/git-format-staged b/bin/git-format-staged new file mode 100755 index 0000000..7a35a41 --- /dev/null +++ b/bin/git-format-staged @@ -0,0 +1,54 @@ +#!/usr/bin/env ruby +require 'format-staged' +require 'optparse' + +parameters = { + :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', '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', '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', "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| + parameters[:verbose] = value + end + + 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 + puts FormatStaged::VERSION + exit + end +end + +parser.parse! +parameters[:patterns] = ARGV + +if !parameters[:formatter] or parameters[:patterns].empty? + puts "Missing formatter or file patterns!" + + puts parser + exit +end + +formatter = FormatStaged.new(**parameters) +formatter.run diff --git a/format-staged.gemspec b/format-staged.gemspec new file mode 100644 index 0000000..df36505 --- /dev/null +++ b/format-staged.gemspec @@ -0,0 +1,16 @@ +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.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' +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 new file mode 100644 index 0000000..5f58847 --- /dev/null +++ b/lib/format-staged/entry.rb @@ -0,0 +1,35 @@ +class FormatStaged + class Entry + 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] + @dst_mode = matches[:dst_mode] + @src_hash = matches[:src_hash] + @dst_hash = matches[:dst_hash] + @status = matches[:status] + @score = matches[:score]&.to_i + @src_path = matches[:src_path] + @dst_path = matches[:dst_path] + @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| + if File.fnmatch? pattern, path, File::FNM_EXTGLOB + result = true + end + end + result + end + end +end diff --git a/lib/format-staged/io.rb b/lib/format-staged/io.rb new file mode 100644 index 0000000..44a447b --- /dev/null +++ b/lib/format-staged/io.rb @@ -0,0 +1,40 @@ +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 + end + + 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/version.rb b/lib/format-staged/version.rb new file mode 100644 index 0000000..dc206b1 --- /dev/null +++ b/lib/format-staged/version.rb @@ -0,0 +1,3 @@ +class FormatStaged + VERSION = "0.0.1" +end \ No newline at end of file