overcommit 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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