overcommit 0.26.0 → 0.27.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +38 -0
  3. data/config/starter.yml +2 -1
  4. data/lib/overcommit/cli.rb +2 -0
  5. data/lib/overcommit/command_splitter.rb +144 -0
  6. data/lib/overcommit/configuration.rb +45 -10
  7. data/lib/overcommit/exceptions.rb +3 -0
  8. data/lib/overcommit/git_repo.rb +8 -0
  9. data/lib/overcommit/hook/base.rb +16 -2
  10. data/lib/overcommit/hook/post_rewrite/base.rb +1 -1
  11. data/lib/overcommit/hook/pre_commit/case_conflicts.rb +1 -1
  12. data/lib/overcommit/hook/pre_commit/hard_tabs.rb +1 -1
  13. data/lib/overcommit/hook/pre_commit/html_hint.rb +21 -0
  14. data/lib/overcommit/hook/pre_commit/local_paths_in_gemfile.rb +1 -1
  15. data/lib/overcommit/hook/pre_commit/merge_conflicts.rb +1 -1
  16. data/lib/overcommit/hook/pre_commit/semi_standard.rb +2 -2
  17. data/lib/overcommit/hook/pre_commit/standard.rb +2 -2
  18. data/lib/overcommit/hook/pre_commit/trailing_whitespace.rb +1 -1
  19. data/lib/overcommit/hook_context/base.rb +43 -1
  20. data/lib/overcommit/hook_context/commit_msg.rb +2 -2
  21. data/lib/overcommit/hook_context/post_checkout.rb +8 -0
  22. data/lib/overcommit/hook_context/post_rewrite.rb +29 -0
  23. data/lib/overcommit/hook_context/pre_commit.rb +1 -14
  24. data/lib/overcommit/hook_loader/plugin_hook_loader.rb +42 -3
  25. data/lib/overcommit/hook_runner.rb +12 -0
  26. data/lib/overcommit/hook_signer.rb +41 -6
  27. data/lib/overcommit/logger.rb +5 -0
  28. data/lib/overcommit/printer.rb +1 -1
  29. data/lib/overcommit/subprocess.rb +22 -1
  30. data/lib/overcommit/utils.rb +58 -4
  31. data/lib/overcommit/version.rb +1 -1
  32. data/template-dir/hooks/commit-msg +24 -4
  33. data/template-dir/hooks/overcommit-hook +24 -4
  34. data/template-dir/hooks/post-checkout +24 -4
  35. data/template-dir/hooks/post-commit +24 -4
  36. data/template-dir/hooks/post-merge +24 -4
  37. data/template-dir/hooks/post-rewrite +24 -4
  38. data/template-dir/hooks/pre-commit +24 -4
  39. data/template-dir/hooks/pre-push +24 -4
  40. data/template-dir/hooks/pre-rebase +24 -4
  41. metadata +6 -3
@@ -2,7 +2,7 @@ module Overcommit::Hook::PreCommit
2
2
  # Checks for unresolved merge conflicts
3
3
  class MergeConflicts < Base
4
4
  def run
5
- result = execute(command + applicable_files)
5
+ result = execute(command, args: applicable_files)
6
6
 
7
7
  unless result.stdout.empty?
8
8
  return :fail, "Merge conflict markers detected:\n#{result.stdout}"
@@ -5,14 +5,14 @@ module Overcommit::Hook::PreCommit
5
5
  class SemiStandard < Base
6
6
  def run
7
7
  result = execute(command + applicable_files)
8
- output = result.stderr.chomp
8
+ output = result.stdout.chomp
9
9
  return :pass if result.success? && output.empty?
10
10
 
11
11
  # example message:
12
12
  # path/to/file.js:1:1: Error message (ruleName)
13
13
  extract_messages(
14
14
  output.split("\n")[1..-1], # ignore header line
15
- /^(?<file>[^:]+):(?<line>\d+)/
15
+ /^\s*(?<file>[^:]+):(?<line>\d+)/
16
16
  )
17
17
  end
18
18
  end
@@ -5,14 +5,14 @@ module Overcommit::Hook::PreCommit
5
5
  class Standard < Base
6
6
  def run
7
7
  result = execute(command + applicable_files)
8
- output = result.stderr.chomp
8
+ output = result.stdout.chomp
9
9
  return :pass if result.success? && output.empty?
10
10
 
11
11
  # example message:
12
12
  # path/to/file.js:1:1: Error message (ruleName)
13
13
  extract_messages(
14
14
  output.split("\n")[1..-1], # ignore header line
15
- /^(?<file>[^:]+):(?<line>\d+)/
15
+ /^\s*(?<file>[^:]+):(?<line>\d+)/
16
16
  )
17
17
  end
18
18
  end
@@ -2,7 +2,7 @@ module Overcommit::Hook::PreCommit
2
2
  # Checks for trailing whitespace in files.
3
3
  class TrailingWhitespace < Base
4
4
  def run
5
- result = execute(command + applicable_files)
5
+ result = execute(command, args: applicable_files)
6
6
 
7
7
  extract_messages(
8
8
  result.stdout.split("\n"),
@@ -22,6 +22,15 @@ module Overcommit::HookContext
22
22
  @input = input
23
23
  end
24
24
 
25
+ # Executes a command as if it were a regular git hook, passing all
26
+ # command-line arguments and the standard input stream.
27
+ #
28
+ # This is intended to be used by ad hoc hooks so developers can link up
29
+ # their existing git hooks with Overcommit.
30
+ def execute_hook(command)
31
+ Overcommit::Utils.execute(command, args: @args, input: input_string)
32
+ end
33
+
25
34
  # Returns the camel-cased type of this hook (e.g. PreCommit)
26
35
  #
27
36
  # @return [String]
@@ -70,12 +79,45 @@ module Overcommit::HookContext
70
79
  []
71
80
  end
72
81
 
82
+ # Returns the contents of the entire standard input stream that were passed
83
+ # to the hook.
84
+ #
85
+ # @return [String]
86
+ def input_string
87
+ @input_string ||= @input.read
88
+ end
89
+
73
90
  # Returns an array of lines passed to the hook via the standard input
74
91
  # stream.
75
92
  #
76
93
  # @return [Array<String>]
77
94
  def input_lines
78
- @input_lines ||= @input.read.split("\n")
95
+ @input_lines ||= input_string.split("\n")
96
+ end
97
+
98
+ private
99
+
100
+ def filter_modified_files(modified_files)
101
+ filter_directories(filter_nonexistent(modified_files))
102
+ end
103
+
104
+ # Filter out non-existent files (unless it's a broken symlink, in which case
105
+ # it's a file that points to a non-existent file). This could happen if a
106
+ # file was renamed as part of an amendment, leading to the old file no
107
+ # longer existing.
108
+ def filter_nonexistent(modified_files)
109
+ modified_files.select do |file|
110
+ File.exist?(file) || Overcommit::Utils.broken_symlink?(file)
111
+ end
112
+ end
113
+
114
+ # Filter out directories. This could happen when changing a symlink to a
115
+ # directory as part of an amendment, since the symlink will still appear as
116
+ # a file, but the actual working tree will have a directory.
117
+ def filter_directories(modified_files)
118
+ modified_files.reject do |file|
119
+ File.directory?(file) && !File.symlink?(file)
120
+ end
79
121
  end
80
122
  end
81
123
  end
@@ -19,8 +19,8 @@ module Overcommit::HookContext
19
19
 
20
20
  def commit_message_lines
21
21
  raw_commit_message_lines.
22
- reject { |line| line =~ /^#/ }.
23
- take_while { |line| !line.start_with?('diff --git') }
22
+ take_while { |line| !line.start_with?('diff --git') }.
23
+ reject { |line| line =~ /^#/ }
24
24
  end
25
25
 
26
26
  def commit_message_file
@@ -22,5 +22,13 @@ module Overcommit::HookContext
22
22
  def file_checkout?
23
23
  !branch_checkout?
24
24
  end
25
+
26
+ # Get a list of files that have been added or modified between
27
+ # `previous_head` and `new_head`. Renames and deletions are ignored, since
28
+ # there should be nothing to check.
29
+ def modified_files
30
+ @modified_files ||=
31
+ Overcommit::GitRepo.modified_files(refs: "#{previous_head} #{new_head}")
32
+ end
25
33
  end
26
34
  end
@@ -14,5 +14,34 @@ module Overcommit::HookContext
14
14
  def rebase?
15
15
  @args[0] == 'rebase'
16
16
  end
17
+
18
+ # Returns the list of commits rewritten by the action that triggered this
19
+ # hook run.
20
+ #
21
+ # @return [Array<RewrittenCommit>]
22
+ def rewritten_commits
23
+ @rewritten_commits ||= input_lines.map do |line|
24
+ RewrittenCommit.new(*line.split(' '))
25
+ end
26
+ end
27
+
28
+ # Get a list of files that have been added or modified as part of a
29
+ # rewritten commit. Renames and deletions are ignored, since there should be
30
+ # nothing to check.
31
+ def modified_files
32
+ @modified_files ||= begin
33
+ @modified_files = []
34
+
35
+ rewritten_commits.each do |rewritten_commit|
36
+ refs = "#{rewritten_commit.old_hash} #{rewritten_commit.new_hash}"
37
+ @modified_files |= Overcommit::GitRepo.modified_files(refs: refs)
38
+ end
39
+
40
+ filter_modified_files(@modified_files)
41
+ end
42
+ end
43
+
44
+ # Struct encapsulating the old and new SHA1 hashes of a rewritten commit
45
+ RewrittenCommit = Struct.new(:old_hash, :new_hash)
17
46
  end
18
47
  end
@@ -95,20 +95,7 @@ module Overcommit::HookContext
95
95
  if amendment?
96
96
  subcmd = 'show --format=%n'
97
97
  previously_modified = Overcommit::GitRepo.modified_files(subcmd: subcmd)
98
-
99
- # Filter out non-existent files. This could happen if a file was
100
- # renamed as part of the amendment, leading to the old file no longer
101
- # existing.
102
- previously_modified.select! { |file| File.exist?(file) }
103
-
104
- # Filter out directories. This could happen when changing a symlink to
105
- # a directory as part of an amendment, since the symlink will still
106
- # appear as a file, but the actual working tree will have a directory.
107
- previously_modified.reject! do |file|
108
- File.directory?(file) && !File.symlink?(file)
109
- end
110
-
111
- @modified_files |= previously_modified
98
+ @modified_files |= filter_modified_files(previously_modified)
112
99
  end
113
100
  end
114
101
  @modified_files
@@ -7,12 +7,16 @@ module Overcommit::HookLoader
7
7
  def load_hooks
8
8
  check_for_modified_plugins if @config.verify_plugin_signatures?
9
9
 
10
- plugin_paths.map do |plugin_path|
10
+ hooks = plugin_paths.map do |plugin_path|
11
11
  require plugin_path
12
12
 
13
13
  hook_name = Overcommit::Utils.camel_case(File.basename(plugin_path, '.rb'))
14
14
  create_hook(hook_name)
15
15
  end
16
+
17
+ hooks + ad_hoc_hook_names.map do |hook_name|
18
+ create_ad_hoc_hook(hook_name)
19
+ end
16
20
  end
17
21
 
18
22
  def update_signatures
@@ -31,9 +35,19 @@ module Overcommit::HookLoader
31
35
  Dir[File.join(directory, '*.rb')].sort
32
36
  end
33
37
 
38
+ def plugin_hook_names
39
+ plugin_paths.map do |path|
40
+ Overcommit::Utils.camel_case(File.basename(path, '.rb'))
41
+ end
42
+ end
43
+
44
+ def ad_hoc_hook_names
45
+ @config.enabled_ad_hoc_hooks(@context)
46
+ end
47
+
34
48
  def modified_plugins
35
- plugin_paths.
36
- map { |path| Overcommit::HookSigner.new(path, @config, @context) }.
49
+ (plugin_hook_names + ad_hoc_hook_names).
50
+ map { |hook_name| Overcommit::HookSigner.new(hook_name, @config, @context) }.
37
51
  select(&:signature_changed?)
38
52
  end
39
53
 
@@ -57,5 +71,30 @@ module Overcommit::HookLoader
57
71
 
58
72
  raise Overcommit::Exceptions::InvalidHookSignature
59
73
  end
74
+
75
+ def create_ad_hoc_hook(hook_name)
76
+ hook_module = Overcommit::Hook.const_get(@context.hook_class_name)
77
+ hook_base = hook_module.const_get('Base')
78
+
79
+ # Implement a simple class that executes the command and returns pass/fail
80
+ # based on the exit status
81
+ hook_class = Class.new(hook_base) do
82
+ def run # rubocop:disable Lint/NestedMethodDefinition
83
+ result = @context.execute_hook(command)
84
+
85
+ if result.success?
86
+ :pass
87
+ else
88
+ [:fail, result.stdout + result.stderr]
89
+ end
90
+ end
91
+ end
92
+
93
+ hook_module.const_set(hook_name, hook_class).new(@config, @context)
94
+ rescue LoadError, NameError => error
95
+ raise Overcommit::Exceptions::HookLoadError,
96
+ "Unable to load hook '#{hook_name}': #{error}",
97
+ error.backtrace
98
+ end
60
99
  end
61
100
  end
@@ -144,6 +144,18 @@ module Overcommit
144
144
 
145
145
  # Load plugin hooks after so they can subclass existing hooks
146
146
  @hooks += HookLoader::PluginHookLoader.new(@config, @context, @log).load_hooks
147
+ rescue LoadError => ex
148
+ # Include a more helpful message that will probably save some confusion
149
+ message = 'A load error occurred. ' +
150
+ if @config['gemfile']
151
+ "Did you forget to specify a gem in your `#{@config['gemfile']}`?"
152
+ else
153
+ 'Did you forget to install a gem?'
154
+ end
155
+
156
+ raise Overcommit::Exceptions::HookLoadError,
157
+ "#{message}\n#{ex.message}",
158
+ ex.backtrace
147
159
  end
148
160
  end
149
161
  end
@@ -1,21 +1,56 @@
1
1
  module Overcommit
2
2
  # Calculates, stores, and retrieves stored signatures of hook plugins.
3
3
  class HookSigner
4
- attr_reader :hook_path, :hook_name
4
+ attr_reader :hook_name
5
5
 
6
6
  # We don't want to include the skip setting as it is set by Overcommit
7
7
  # itself
8
8
  IGNORED_CONFIG_KEYS = %w[skip]
9
9
 
10
- # @param hook_path [String] path to the actual hook definition
10
+ # @param hook_name [String] name of the hook
11
11
  # @param config [Overcommit::Configuration]
12
12
  # @param context [Overcommit::HookContext]
13
- def initialize(hook_path, config, context)
14
- @hook_path = hook_path
13
+ def initialize(hook_name, config, context)
14
+ @hook_name = hook_name
15
15
  @config = config
16
16
  @context = context
17
+ end
18
+
19
+ # Returns the path of the file that should be incorporated into this hooks
20
+ # signature.
21
+ #
22
+ # @return [String]
23
+ def hook_path
24
+ @hook_path ||= begin
25
+ plugin_path = File.join(@config.plugin_directory,
26
+ @context.hook_type_name,
27
+ "#{Overcommit::Utils.snake_case(@hook_name)}.rb")
28
+
29
+ if File.exist?(plugin_path)
30
+ plugin_path
31
+ else
32
+ # Otherwise this is an ad hoc hook using an existing hook script
33
+ hook_config = @config.for_hook(@hook_name, @context.hook_class_name)
34
+
35
+ command = Array(hook_config['command'] ||
36
+ hook_config['required_executable'])
37
+
38
+ unless !@config.verify_plugin_signatures? ||
39
+ signable_file?(command.first)
40
+ raise Overcommit::Exceptions::InvalidHookDefinition,
41
+ 'Hook must specify a `required_executable` or `command` that ' \
42
+ 'is tracked by git (i.e. is a path relative to the root ' \
43
+ 'of the repository) so that it can be signed'
44
+ end
45
+
46
+ File.join(Overcommit::Utils.repo_root, command.first)
47
+ end
48
+ end
49
+ end
17
50
 
18
- @hook_name = Overcommit::Utils.camel_case(File.basename(@hook_path, '.rb'))
51
+ def signable_file?(file)
52
+ file.start_with?(".#{File::SEPARATOR}") &&
53
+ Overcommit::GitRepo.tracked?(file)
19
54
  end
20
55
 
21
56
  # Return whether the signature for this hook has changed since it was last
@@ -54,7 +89,7 @@ module Overcommit
54
89
  end
55
90
 
56
91
  def hook_contents
57
- File.open(@hook_path, 'r').read
92
+ File.read(hook_path)
58
93
  end
59
94
 
60
95
  def stored_signature
@@ -30,6 +30,11 @@ module Overcommit
30
30
  @out.puts(*args)
31
31
  end
32
32
 
33
+ # Write a line of output if debug mode is enabled.
34
+ def debug(*args)
35
+ color('35', *args) unless ENV.fetch('OVERCOMMIT_DEBUG', '').empty?
36
+ end
37
+
33
38
  # Write a line of output that is intended to be emphasized.
34
39
  def bold(*args)
35
40
  color('1', *args)
@@ -17,7 +17,7 @@ module Overcommit
17
17
  end
18
18
 
19
19
  def nothing_to_run
20
- log.success "✓ No applicable #{hook_script_name} hooks to run"
20
+ log.debug "✓ No applicable #{hook_script_name} hooks to run"
21
21
  end
22
22
 
23
23
  # Executed at the start of an individual hook run.
@@ -6,6 +6,10 @@ module Overcommit
6
6
  # standard out/error output.
7
7
  class Subprocess
8
8
  # Encapsulates the result of a process.
9
+ #
10
+ # @attr_reader status [Integer] exit status code returned by process
11
+ # @attr_reader stdout [String] standard output stream output
12
+ # @attr_reader stderr [String] standard error stream output
9
13
  Result = Struct.new(:status, :stdout, :stderr) do
10
14
  def success?
11
15
  status == 0
@@ -15,12 +19,29 @@ module Overcommit
15
19
  class << self
16
20
  # Spawns a new process using the given array of arguments (the first
17
21
  # element is the command).
18
- def spawn(args)
22
+ #
23
+ # @param args [Array<String>]
24
+ # @param options [Hash]
25
+ # @option options [String] input string to pass via standard input stream
26
+ # @return [Result]
27
+ def spawn(args, options = {})
19
28
  process = ChildProcess.build(*args)
20
29
 
21
30
  out, err = assign_output_streams(process)
22
31
 
32
+ process.duplex = true if options[:input] # Make stdin available if needed
23
33
  process.start
34
+ if options[:input]
35
+ begin
36
+ process.io.stdin.puts(options[:input])
37
+ rescue
38
+ # Silently ignore if the standard input stream of the spawned
39
+ # process is closed before we get a chance to write to it. This
40
+ # happens on JRuby a lot.
41
+ ensure
42
+ process.io.stdin.close
43
+ end
44
+ end
24
45
  process.wait
25
46
 
26
47
  err.rewind
@@ -1,5 +1,7 @@
1
1
  require 'pathname'
2
2
  require 'overcommit/subprocess'
3
+ require 'overcommit/command_splitter'
4
+ require 'tempfile'
3
5
 
4
6
  module Overcommit
5
7
  # Utility functions for general use.
@@ -23,6 +25,9 @@ module Overcommit
23
25
  end
24
26
 
25
27
  class << self
28
+ # @return [Overcommit::Logger] logger with which to send debug output
29
+ attr_accessor :log
30
+
26
31
  def script_path(script)
27
32
  File.join(Overcommit::HOME, 'libexec', script)
28
33
  end
@@ -72,6 +77,16 @@ module Overcommit
72
77
  end
73
78
  end
74
79
 
80
+ # Remove ANSI escape sequences from a string.
81
+ #
82
+ # This is useful for stripping colorized output from external tools.
83
+ #
84
+ # @param text [String]
85
+ # @return [String]
86
+ def strip_color_codes(text)
87
+ text.gsub(/\e\[(\d+)(;\d+)*m/, '')
88
+ end
89
+
75
90
  # Shamelessly stolen from:
76
91
  # stackoverflow.com/questions/1509915/converting-camel-case-to-underscore-case-in-ruby
77
92
  def snake_case(str)
@@ -131,15 +146,40 @@ module Overcommit
131
146
  # This is intended to provide a centralized place to perform any checks or
132
147
  # filtering of the command before executing it.
133
148
  #
134
- # @param args [Array<String>]
149
+ # The `args` option provides a convenient way of splitting up long
150
+ # argument lists which would otherwise exceed the maximum command line
151
+ # length of the OS. It will break up the list into chunks and run the
152
+ # command with the same prefix `initial_args`, finally combining the
153
+ # output together at the end.
154
+ #
155
+ # This requires that the external command you are running can have its
156
+ # work split up in this way and still produce the same resultant output
157
+ # when outputs of the individual commands are concatenated back together.
158
+ #
159
+ # @param initial_args [Array<String>]
160
+ # @param options [Hash]
161
+ # @option options [Array<String>] :args long list of arguments to split up
135
162
  # @return [Overcommit::Subprocess::Result] status, stdout, and stderr
136
- def execute(args)
137
- if args.include?('|')
163
+ def execute(initial_args, options = {})
164
+ if initial_args.include?('|')
138
165
  raise Overcommit::Exceptions::InvalidCommandArgs,
139
166
  'Cannot pipe commands with the `execute` helper'
140
167
  end
141
168
 
142
- Subprocess.spawn(args)
169
+ result =
170
+ if (splittable_args = options.fetch(:args, [])).any?
171
+ debug(initial_args.join(' ') + " ... (#{splittable_args.length} splittable args)")
172
+ Overcommit::CommandSplitter.execute(initial_args, options)
173
+ else
174
+ debug(initial_args.join(' '))
175
+ Overcommit::Subprocess.spawn(initial_args, options)
176
+ end
177
+
178
+ debug("EXIT STATUS: #{result.status}")
179
+ debug("STDOUT: #{result.stdout.inspect}")
180
+ debug("STDERR: #{result.stderr.inspect}")
181
+
182
+ result
143
183
  end
144
184
 
145
185
  # Execute a command in a subprocess, returning immediately.
@@ -155,6 +195,7 @@ module Overcommit
155
195
  'Cannot pipe commands with the `execute_in_background` helper'
156
196
  end
157
197
 
198
+ debug("Spawning background task: #{args.join(' ')}")
158
199
  Subprocess.spawn_detached(args)
159
200
  end
160
201
 
@@ -200,6 +241,19 @@ module Overcommit
200
241
  File::FNM_DOTMATCH # Wildcards match dotfiles
201
242
  )
202
243
  end
244
+
245
+ private
246
+
247
+ # Log debug output.
248
+ #
249
+ # This is necessary since some specs indirectly call utility functions but
250
+ # don't explicitly set the logger for the Utils class, so we do a quick
251
+ # check here to see if it's set before we attempt to log.
252
+ #
253
+ # @param args [Array<String>]
254
+ def debug(*args)
255
+ log.debug(*args) if log
256
+ end
203
257
  end
204
258
  end
205
259
  end