overcommit 0.75.0 → 0.76.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.
@@ -17,6 +17,28 @@ module Overcommit::Hook::PrepareCommitMsg
17
17
  #
18
18
  # Similarly, a replacement text of `[\1][\2]` would result in `[123][topic]`.
19
19
  #
20
+ # The hook does not prepend the text if the commit message already starts
21
+ # with it, or with an autosquash prefix (`fixup! `, `squash! `, `amend! `).
22
+ # This matters when you remove `message` or `commit` from
23
+ # `skipped_commit_types`: `git commit -m '[#123] Fix'`, `--amend`, and
24
+ # `--fixup` then keep one prefix.
25
+ #
26
+ # === Ticket IDs
27
+ #
28
+ # This config prepends `[PROJ-123] ` on a branch such as
29
+ # `task/PROJ-123/fix-login`, also for `-m` and `--amend` commits:
30
+ #
31
+ # PrepareCommitMsg:
32
+ # ReplaceBranch:
33
+ # enabled: true
34
+ # branch_pattern: '\A.*?([A-Z][A-Z0-9]+-\d+).*\z'
35
+ # replacement_text: '[\1] '
36
+ # skipped_commit_types: ['template', 'merge', 'squash']
37
+ # on_warn: pass # do not warn on branches without a ticket ID
38
+ #
39
+ # It keeps `template` skipped, because a commit template can start with a
40
+ # `#` comment line, and the prefix would turn that line into the subject.
41
+ #
20
42
  # == When to run this hook
21
43
  #
22
44
  # You can configure this to run only for specific types of commits by setting
@@ -31,6 +53,10 @@ module Overcommit::Hook::PrepareCommitMsg
31
53
  class ReplaceBranch < Base
32
54
  DEFAULT_BRANCH_PATTERN = /\A(\d+)-(\w+).*\z/.freeze
33
55
 
56
+ # `git rebase --autosquash` finds these commits only when the prefix starts
57
+ # the subject.
58
+ AUTOSQUASH_PREFIX = /\A(?:fixup|squash|amend)! /.freeze
59
+
34
60
  def run
35
61
  return :pass if skip?
36
62
 
@@ -40,10 +66,14 @@ module Overcommit::Hook::PrepareCommitMsg
40
66
 
41
67
  return :warn unless branch_pattern.match?(Overcommit::GitRepo.current_branch)
42
68
 
43
- Overcommit::Utils.log.debug("Writing #{commit_message_filename} with #{new_template}")
44
-
45
69
  modify_commit_message do |old_contents|
46
- "#{new_template}#{old_contents}"
70
+ if keep_message?(old_contents)
71
+ Overcommit::Utils.log.debug("Keeping #{commit_message_filename} unchanged")
72
+ old_contents
73
+ else
74
+ Overcommit::Utils.log.debug("Writing #{commit_message_filename} with #{new_template}")
75
+ "#{new_template}#{old_contents}"
76
+ end
47
77
  end
48
78
 
49
79
  :pass
@@ -57,6 +87,25 @@ module Overcommit::Hook::PrepareCommitMsg
57
87
  end
58
88
  end
59
89
 
90
+ # Compare bytes: String methods such as `lstrip` raise on a message that is
91
+ # not valid in the default encoding, for example an amended Latin-1 commit.
92
+ def keep_message?(contents)
93
+ message = contents.b.lstrip
94
+ message.match?(AUTOSQUASH_PREFIX) || starts_with_new_template?(message)
95
+ end
96
+
97
+ # Strip the template, because users type the prefix with or without the
98
+ # trailing space of `replacement_text`. Without that space, a template that
99
+ # ends in a word character, such as `PROJ-12`, must not match `PROJ-123`.
100
+ # A blank template never matches, so a whitespace-only `replacement_text`
101
+ # is still prepended as before.
102
+ def starts_with_new_template?(message)
103
+ template = new_template.b.strip
104
+ return false if template.empty? || !message.start_with?(template)
105
+
106
+ !(template.match?(/\w\z/) && message[template.size]&.match?(/\w/))
107
+ end
108
+
60
109
  def branch_pattern
61
110
  @branch_pattern ||=
62
111
  begin
@@ -99,7 +99,10 @@ module Overcommit::HookContext
99
99
  return @input_string if defined?(@input_string)
100
100
 
101
101
  @input_mutex.synchronize do
102
- @input_string = @input.read unless defined?(@input_string)
102
+ unless defined?(@input_string)
103
+ @input.binmode if @input.respond_to?(:binmode)
104
+ @input_string = @input.read
105
+ end
103
106
  end
104
107
 
105
108
  @input_string
@@ -14,7 +14,7 @@ module Overcommit::HookContext
14
14
  commit_message.strip.empty?
15
15
  end
16
16
 
17
- # User commit message stripped of comments and diff (from verbose output).
17
+ # User commit message with Git's comment handling and no verbose diff.
18
18
  def commit_message
19
19
  commit_message_lines.join
20
20
  end
@@ -27,9 +27,11 @@ module Overcommit::HookContext
27
27
  end
28
28
 
29
29
  def commit_message_lines
30
- raw_commit_message_lines.
31
- take_while { |line| !line.start_with?('diff --git') }.
32
- reject { |line| line.start_with?(comment_character) }
30
+ lines = raw_commit_message_lines.take_while { |line| !line.start_with?('diff --git') }
31
+ if cleanup_mode == 'strip'
32
+ lines.reject! { |line| line.start_with?(comment_character) }
33
+ end
34
+ lines
33
35
  end
34
36
 
35
37
  def comment_character
@@ -48,6 +50,10 @@ module Overcommit::HookContext
48
50
 
49
51
  private
50
52
 
53
+ def cleanup_mode
54
+ @cleanup_mode ||= Overcommit::GitConfig.commit_cleanup_mode
55
+ end
56
+
51
57
  def raw_commit_message_lines
52
58
  ::IO.readlines(commit_message_file)
53
59
  end
@@ -17,6 +17,7 @@ module Overcommit::HookContext
17
17
  # editors/tools which watch for file changes.
18
18
  if !initial_commit? && unstaged_changes?
19
19
  stash_changes
20
+ restore_staged_changes if @changes_stashed
20
21
 
21
22
  # While running hooks make it appear as if nothing changed
22
23
  restore_modified_times
@@ -82,7 +83,7 @@ module Overcommit::HookContext
82
83
  stash_message = "Overcommit: Stash of repo state before hook run at #{Time.now}"
83
84
  result = Overcommit::Utils.with_environment('GIT_LITERAL_PATHSPECS' => '0') do
84
85
  Overcommit::Utils.execute(
85
- %w[git -c commit.gpgsign=false stash save --keep-index --quiet] + [stash_message]
86
+ %w[git -c commit.gpgsign=false stash save --quiet] + [stash_message]
86
87
  )
87
88
  end
88
89
 
@@ -95,6 +96,19 @@ module Overcommit::HookContext
95
96
  end
96
97
 
97
98
  @changes_stashed = `git stash list -1`.include?(stash_message)
99
+ @stash_commit = `git rev-parse stash@{0}`.strip if @changes_stashed
100
+ end
101
+
102
+ # Restore the staged tree explicitly: older Git versions' --keep-index
103
+ # leave deleted files and the old paths of renamed files in the working tree.
104
+ # read-tree updates both the index and working tree without moving HEAD.
105
+ def restore_staged_changes
106
+ result = Overcommit::Utils.execute(%w[git read-tree --reset -u stash@{0}^2])
107
+ unless result.success?
108
+ raise Overcommit::Exceptions::HookSetupFailed,
109
+ "Unable to restore staged changes for #{hook_script_name} hook run:" \
110
+ "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}#{stash_recovery_message}"
111
+ end
98
112
  end
99
113
 
100
114
  # Restores the file modification times for all modified files to make it
@@ -116,7 +130,7 @@ module Overcommit::HookContext
116
130
  unless result.success?
117
131
  raise Overcommit::Exceptions::HookCleanupFailed,
118
132
  "Unable to cleanup working tree after #{hook_script_name} hooks run:" \
119
- "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}"
133
+ "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}#{stash_recovery_message}"
120
134
  end
121
135
 
122
136
  # Hard-resetting a staged submodule removal results in the index being
@@ -136,9 +150,17 @@ module Overcommit::HookContext
136
150
  unless result.success?
137
151
  raise Overcommit::Exceptions::HookCleanupFailed,
138
152
  "Unable to restore working tree after #{hook_script_name} hooks run:" \
139
- "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}"
153
+ "\nSTDOUT:#{result.stdout}\nSTDERR:#{result.stderr}#{stash_recovery_message}"
140
154
  end
141
155
  end
156
+
157
+ def stash_recovery_message
158
+ return '' unless @stash_commit
159
+
160
+ "\nYour changes are preserved in Git stash #{@stash_commit}.\n" \
161
+ "Release any file locks and use a clean working tree to recover on a new branch:\n" \
162
+ " git stash branch overcommit-recovery #{@stash_commit}\n"
163
+ end
142
164
  end
143
165
  end
144
166
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'overcommit/utils/messages_utils'
4
+
3
5
  module Overcommit::HookLoader
4
6
  # Responsible for loading hooks from a file.
5
7
  class Base
@@ -22,27 +24,57 @@ module Overcommit::HookLoader
22
24
 
23
25
  private
24
26
 
27
+ def validate_line_aware_config(hook_config)
28
+ pattern = hook_config['message_pattern']
29
+ unless pattern.is_a?(Regexp) && pattern.names.include?('file')
30
+ raise Overcommit::Exceptions::HookLoadError,
31
+ '`message_pattern` must be a Regexp with a named `file` capture group'
32
+ end
33
+
34
+ warning_pattern = hook_config['warning_message_type_pattern']
35
+ unless warning_pattern.nil? || warning_pattern.is_a?(Regexp) || warning_pattern.is_a?(String)
36
+ raise Overcommit::Exceptions::HookLoadError,
37
+ '`warning_message_type_pattern` must be a Regexp or String'
38
+ end
39
+ end
40
+
41
+ def create_line_aware_command_hook_class(hook_base)
42
+ Class.new(hook_base) do
43
+ def run
44
+ result = execute(command, args: applicable_files)
45
+ return :pass if result.success?
46
+
47
+ output = result.stdout.split("\n")
48
+ return [:fail, result.stderr] if output.empty?
49
+
50
+ Overcommit::Utils::MessagesUtils.extract_messages(
51
+ output,
52
+ @config['message_pattern'],
53
+ Overcommit::Utils::MessagesUtils.create_type_categorizer(
54
+ @config['warning_message_type_pattern']
55
+ )
56
+ )
57
+ end
58
+ end
59
+ end
60
+
25
61
  attr_reader :log
26
62
 
27
63
  # Load and return a {Hook} from a CamelCase hook name.
28
64
  def create_hook(hook_name)
29
65
  hook_type_class = Overcommit::Hook.const_get(@context.hook_class_name)
30
66
  hook_base_class = hook_type_class.const_get(:Base)
31
- hook_class = hook_type_class.const_get(hook_name)
32
- unless hook_class < hook_base_class
67
+ hook_class = hook_type_class.const_get(hook_name, false)
68
+ unless hook_class.is_a?(Class) && hook_class < hook_base_class
33
69
  raise Overcommit::Exceptions::HookLoadError,
34
70
  "Class #{hook_name} is not a subclass of #{hook_base_class}."
35
71
  end
36
72
 
37
- begin
38
- Overcommit::Hook.const_get(@context.hook_class_name).
39
- const_get(hook_name).
40
- new(@config, @context)
41
- rescue LoadError, NameError => e
42
- raise Overcommit::Exceptions::HookLoadError,
43
- "Unable to load hook '#{hook_name}': #{e}",
44
- e.backtrace
45
- end
73
+ hook_class.new(@config, @context)
74
+ rescue LoadError, NameError => e
75
+ raise Overcommit::Exceptions::HookLoadError,
76
+ "Unable to load hook '#{hook_name}': #{e}",
77
+ e.backtrace
46
78
  end
47
79
  end
48
80
  end
@@ -6,7 +6,8 @@ module Overcommit::HookLoader
6
6
  def load_hooks
7
7
  @config.enabled_builtin_hooks(@context).map do |hook_name|
8
8
  underscored_hook_name = Overcommit::Utils.snake_case(hook_name)
9
- require "overcommit/hook/#{@context.hook_type_name}/#{underscored_hook_name}"
9
+ require File.join(Overcommit::HOOK_DIRECTORY, @context.hook_type_name,
10
+ "#{underscored_hook_name}.rb")
10
11
  create_hook(hook_name)
11
12
  end
12
13
  end
@@ -5,11 +5,11 @@ require 'digest'
5
5
  module Overcommit::HookLoader
6
6
  # Responsible for loading hooks that are specific to the repository Overcommit
7
7
  # is running in.
8
- class PluginHookLoader < Base
8
+ class PluginHookLoader < Base # rubocop:disable Metrics/ClassLength
9
9
  def load_hooks
10
- check_for_modified_plugins if @config.verify_signatures?
10
+ check_for_unverified_plugins if @config.verify_signatures?
11
11
 
12
- hooks = plugin_paths.map do |plugin_path|
12
+ hooks = load_gem_hooks + plugin_paths.map do |plugin_path|
13
13
  require plugin_path
14
14
 
15
15
  hook_name = Overcommit::Utils.camel_case(File.basename(plugin_path, '.rb'))
@@ -22,9 +22,9 @@ module Overcommit::HookLoader
22
22
  end
23
23
 
24
24
  def update_signatures
25
- log.success('No plugin signatures have changed') if modified_plugins.empty?
25
+ log.success('All plugin signatures have been verified') if unverified_plugins.empty?
26
26
 
27
- modified_plugins.each do |plugin|
27
+ unverified_plugins.each do |plugin|
28
28
  plugin.update_signature!
29
29
  log.warning "Updated signature of plugin #{plugin.hook_name}"
30
30
  end
@@ -32,6 +32,34 @@ module Overcommit::HookLoader
32
32
 
33
33
  private
34
34
 
35
+ def gem_plugins
36
+ @gem_plugins ||= @config.enabled_gem_hooks(@context).
37
+ map { |name| name.split('::').first }.uniq.sort.map do |namespace|
38
+ Overcommit::GemPlugin.new(namespace, @config, @context)
39
+ end
40
+ end
41
+
42
+ def load_gem_hooks
43
+ sources = gem_plugins.map { |plugin| [plugin, plugin.hook_paths] }
44
+ sources.flat_map do |plugin, paths|
45
+ hook_module = Overcommit::Hook.const_get(@context.hook_class_name)
46
+ if hook_module.const_defined?(plugin.namespace, false)
47
+ namespace = hook_module.const_get(plugin.namespace, false)
48
+ unless namespace.instance_of?(Module)
49
+ raise Overcommit::Exceptions::HookLoadError,
50
+ "Gem namespace #{plugin.namespace} conflicts with an existing hook"
51
+ end
52
+ else
53
+ hook_module.const_set(plugin.namespace, Module.new)
54
+ end
55
+ plugin.activate
56
+ paths.zip(plugin.hook_names).map do |path, name|
57
+ require path
58
+ create_hook(name)
59
+ end
60
+ end
61
+ end
62
+
35
63
  def plugin_paths
36
64
  directory = File.join(@config.plugin_directory, @context.hook_type_name)
37
65
  Dir[File.join(directory, '*.rb')].sort
@@ -47,20 +75,20 @@ module Overcommit::HookLoader
47
75
  @config.enabled_ad_hoc_hooks(@context)
48
76
  end
49
77
 
50
- def modified_plugins
51
- (plugin_hook_names + ad_hoc_hook_names).
52
- map { |hook_name| Overcommit::HookSigner.new(hook_name, @config, @context) }.
53
- select(&:signature_changed?)
78
+ def unverified_plugins
79
+ signers = (plugin_hook_names + ad_hoc_hook_names).
80
+ map { |hook_name| Overcommit::HookSigner.new(hook_name, @config, @context) }
81
+ (gem_plugins + signers).reject(&:signature_verified?)
54
82
  end
55
83
 
56
- def check_for_modified_plugins
57
- return if modified_plugins.empty?
84
+ def check_for_unverified_plugins
85
+ return if unverified_plugins.empty?
58
86
 
59
87
  log.bold_warning "The following #{@context.hook_script_name} plugins " \
60
88
  'have been added, changed, or had their configuration modified:'
61
89
  log.newline
62
90
 
63
- modified_plugins.each do |signer|
91
+ unverified_plugins.each do |signer|
64
92
  log.warning " * #{signer.hook_name} in #{signer.hook_path}"
65
93
  end
66
94
 
@@ -74,16 +102,12 @@ module Overcommit::HookLoader
74
102
  raise Overcommit::Exceptions::InvalidHookSignature
75
103
  end
76
104
 
77
- def create_ad_hoc_hook(hook_name)
78
- hook_module = Overcommit::Hook.const_get(@context.hook_class_name)
79
- hook_base = hook_module.const_get('Base')
80
-
105
+ def create_git_hook_class(hook_base)
81
106
  # Implement a simple class that executes the command and returns pass/fail
82
107
  # based on the exit status
83
- hook_class = Class.new(hook_base) do
108
+ Class.new(hook_base) do
84
109
  def run
85
110
  result = @context.execute_hook(command)
86
-
87
111
  if result.success?
88
112
  :pass
89
113
  else
@@ -91,6 +115,25 @@ module Overcommit::HookLoader
91
115
  end
92
116
  end
93
117
  end
118
+ end
119
+
120
+ def create_ad_hoc_hook(hook_name)
121
+ hook_module = Overcommit::Hook.const_get(@context.hook_class_name)
122
+ hook_base = hook_module.const_get('Base')
123
+
124
+ hook_config = @config.for_hook(hook_name, @context.hook_class_name)
125
+ hook_class =
126
+ if hook_config['message_pattern']
127
+ validate_line_aware_config(hook_config)
128
+ create_line_aware_command_hook_class(hook_base)
129
+ else
130
+ create_git_hook_class(hook_base)
131
+ end
132
+
133
+ # A later load may configure the same hook to use a different command format.
134
+ if hook_module.const_defined?(hook_name, false)
135
+ hook_module.send(:remove_const, hook_name)
136
+ end
94
137
 
95
138
  hook_module.const_set(hook_name, hook_class).new(@config, @context)
96
139
  rescue LoadError, NameError => e
@@ -197,7 +197,7 @@ module Overcommit
197
197
  end
198
198
 
199
199
  def load_hooks
200
- require "overcommit/hook/#{@context.hook_type_name}/base"
200
+ require File.join(Overcommit::HOOK_DIRECTORY, @context.hook_type_name, 'base.rb')
201
201
 
202
202
  @hooks += HookLoader::BuiltInHookLoader.new(@config, @context, @log).load_hooks
203
203
 
@@ -45,7 +45,7 @@ module Overcommit
45
45
  'must be tracked by Git in order to be signed'
46
46
  end
47
47
 
48
- File.join(Overcommit::Utils.repo_root, command.first.to_s)
48
+ File.expand_path(command.first.to_s, Overcommit::Utils.repo_root)
49
49
  end
50
50
  end
51
51
  end
@@ -58,25 +58,17 @@ module Overcommit
58
58
  file.start_with?(Overcommit::Utils.repo_root)
59
59
  end
60
60
 
61
- # Return whether the signature for this hook has changed since it was last
62
- # calculated.
61
+ # Return whether this exact combination of hook code and configuration
62
+ # has been explicitly trusted before.
63
63
  #
64
64
  # @return [true,false]
65
- def signature_changed?
66
- signature != stored_signature
65
+ def signature_verified?
66
+ signature_store.include?(signature)
67
67
  end
68
68
 
69
- # Update the current stored signature for this hook.
69
+ # Remember the current signature without forgetting older approvals.
70
70
  def update_signature!
71
- scope = Overcommit::GitConfig.signature_scope
72
- result = Overcommit::Utils.execute(
73
- %W[git config #{scope}] + [signature_config_key, signature]
74
- )
75
-
76
- unless result.success?
77
- raise Overcommit::Exceptions::GitConfigError,
78
- "Unable to write to local repo git config: #{result.stderr}"
79
- end
71
+ signature_store.add(signature)
80
72
  end
81
73
 
82
74
  private
@@ -103,20 +95,8 @@ module Overcommit
103
95
  File.read(hook_path)
104
96
  end
105
97
 
106
- def stored_signature
107
- scope = Overcommit::GitConfig.signature_scope
108
- result = Overcommit::Utils.execute(
109
- %W[git config #{scope} --get] + [signature_config_key]
110
- )
111
-
112
- if result.status == 1 # Key doesn't exist
113
- return ''
114
- elsif result.status != 0
115
- raise Overcommit::Exceptions::GitConfigError,
116
- "Unable to read from local repo git config: #{result.stderr}"
117
- end
118
-
119
- result.stdout.chomp
98
+ def signature_store
99
+ @signature_store ||= Overcommit::Signature.new(signature_config_key)
120
100
  end
121
101
 
122
102
  def signature_config_key
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+
6
+ module Overcommit
7
+ # Repository-local approvals, indexed by hook identity and SHA256 signature.
8
+ # These files are outside Git's object database so fetching cannot add trust.
9
+ class Signature
10
+ def initialize(config_key)
11
+ @config_key = config_key
12
+ scope = Overcommit::GitConfig.signature_scope
13
+ root = if scope == '--worktree'
14
+ Overcommit::Utils.git_path('overcommit-signatures')
15
+ else
16
+ File.join(Overcommit::Utils.git_dir, 'overcommit-signatures')
17
+ end
18
+ @directory = File.join(root, Digest::SHA256.hexdigest(config_key))
19
+ migrate_legacy_signature(scope)
20
+ end
21
+
22
+ def include?(signature)
23
+ File.read(path(signature)) == contents(signature)
24
+ rescue Errno::ENOENT
25
+ false
26
+ rescue SystemCallError => e
27
+ raise Overcommit::Exceptions::SignatureError, "Unable to read signature history: #{e}"
28
+ end
29
+
30
+ def recorded?
31
+ File.directory?(@directory)
32
+ end
33
+
34
+ def add(signature)
35
+ FileUtils.mkdir_p(@directory)
36
+ File.write(path(signature), contents(signature), mode: 'w', perm: 0o600)
37
+ rescue SystemCallError => e
38
+ raise Overcommit::Exceptions::SignatureError, "Unable to write signature history: #{e}"
39
+ end
40
+
41
+ private
42
+
43
+ def path(signature)
44
+ File.join(@directory, signature)
45
+ end
46
+
47
+ def contents(signature)
48
+ "#{@config_key}\n#{signature}\n"
49
+ end
50
+
51
+ # Preserve an existing approval before removing its legacy Git config key.
52
+ # Removing the key means deleting history files actually revokes trust.
53
+ def migrate_legacy_signature(scope)
54
+ result = Overcommit::Utils.execute(%W[git config #{scope} --get] + [@config_key])
55
+ return if result.status == 1
56
+
57
+ check_config_result(result)
58
+ signature = result.stdout.chomp
59
+ add(signature) if signature.match?(/\A[0-9a-f]{64}\z/)
60
+ result = Overcommit::Utils.execute(%W[git config #{scope} --unset-all] + [@config_key])
61
+ check_config_result(result) unless result.status == 5 # Already removed by another process.
62
+ end
63
+
64
+ def check_config_result(result)
65
+ return if result.success?
66
+
67
+ raise Overcommit::Exceptions::GitConfigError,
68
+ "Unable to migrate local repo git config signature: #{result.stderr}"
69
+ end
70
+ end
71
+ end
@@ -35,7 +35,8 @@ module Overcommit
35
35
  process.start
36
36
  if options[:input]
37
37
  begin
38
- process.io.stdin.puts(options[:input])
38
+ process.io.stdin.binmode
39
+ process.io.stdin.write(options[:input])
39
40
  rescue StandardError
40
41
  # Silently ignore if the standard input stream of the spawned
41
42
  # process is closed before we get a chance to write to it. This
@@ -37,6 +37,20 @@ module Overcommit::Utils
37
37
  end
38
38
  end
39
39
 
40
+ def create_type_categorizer(warning_pattern)
41
+ return nil if warning_pattern.nil?
42
+
43
+ lambda do |type|
44
+ warning =
45
+ if warning_pattern.is_a?(Regexp)
46
+ type && warning_pattern.match?(type)
47
+ else
48
+ type&.include?(warning_pattern)
49
+ end
50
+ warning ? :warning : :error
51
+ end
52
+ end
53
+
40
54
  private
41
55
 
42
56
  def extract_file(match, message)
@@ -166,12 +166,7 @@ module Overcommit
166
166
  return if Process.ppid.zero?
167
167
 
168
168
  if OS.windows?
169
- result = execute([
170
- 'powershell.exe', '-NoProfile', '-NonInteractive', '-Command',
171
- "(Get-CimInstance Win32_Process -Filter 'ProcessId = #{Process.ppid}').CommandLine",
172
- ])
173
- command = result.stdout.strip
174
- command if result.success? && !command.empty?
169
+ windows_parent_command
175
170
  elsif OS.cygwin?
176
171
  # Cygwin's `ps` command behaves differently than the traditional
177
172
  # Linux version, but a comparable `procps` is provided to compensate.
@@ -294,6 +289,69 @@ module Overcommit
294
289
 
295
290
  private
296
291
 
292
+ def windows_parent_command
293
+ native_windows_parent_command
294
+ rescue LoadError, StandardError
295
+ # win32ole is optional and absent from newer Ruby distributions.
296
+ # Use Script Host when it is unavailable or the COM query is denied.
297
+ scripted_windows_parent_command
298
+ end
299
+
300
+ def scripted_windows_parent_command
301
+ begin
302
+ result = execute([
303
+ 'cscript.exe', '//nologo', '//B', '//U',
304
+ File.expand_path('../../libexec/overcommit-parent-command.js', __dir__),
305
+ Process.ppid.to_s,
306
+ ])
307
+ if result.success?
308
+ command = result.stdout.dup.force_encoding('UTF-16LE').encode('UTF-8').
309
+ delete_prefix("\uFEFF").strip
310
+ return command.empty? ? nil : command
311
+ end
312
+ rescue Errno::ENOENT, ChildProcess::LaunchError, EncodingError
313
+ # Preserve PowerShell when Script Host is disabled or unavailable.
314
+ end
315
+ powershell_windows_parent_command
316
+ end
317
+
318
+ def powershell_windows_parent_command
319
+ result = execute([
320
+ 'powershell.exe', '-NoProfile', '-NonInteractive', '-Command',
321
+ <<~POWERSHELL,
322
+ $parentId = #{Process.ppid}
323
+ for ($level = 0; $level -lt 4; $level++) {
324
+ $process = Get-CimInstance Win32_Process -Filter "ProcessId = $parentId"
325
+ if (!$process) { break }
326
+ if ($process.Name -ine 'env.exe') { $process.CommandLine; break }
327
+ $parentId = $process.ParentProcessId
328
+ }
329
+ POWERSHELL
330
+ ])
331
+ command = result.stdout.strip
332
+ command if result.success? && !command.empty?
333
+ end
334
+
335
+ # Avoid starting PowerShell for every query when Ruby provides COM support.
336
+ def native_windows_parent_command
337
+ require 'win32ole'
338
+ service = WIN32OLE.connect('winmgmts:root/cimv2')
339
+ parent_id = Process.ppid
340
+ # Git for Windows' /usr/bin/env launcher remains between Git and Ruby.
341
+ 4.times do
342
+ processes = service.ExecQuery(
343
+ 'SELECT Name, CommandLine, ParentProcessId FROM Win32_Process ' \
344
+ "WHERE ProcessId = #{parent_id}"
345
+ )
346
+ process = processes.to_enum.first
347
+ return unless process
348
+ return process.CommandLine unless process.Name.to_s.casecmp?('env.exe')
349
+
350
+ parent_id = process.ParentProcessId
351
+ end
352
+ nil
353
+ end
354
+
297
355
  # Log debug output.
298
356
  #
299
357
  # This is necessary since some specs indirectly call utility functions but