Skip to content

Commit a359629

Browse files
committed
Add "ad-hoc" line-aware command hooks
1 parent 9e9c87f commit a359629

File tree

6 files changed

+250
-6
lines changed

6 files changed

+250
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## master (unreleased)
44

55
* Add `--disable-pending-cops` as default flag to `RuboCop` pre-commit hook to ignore non-existent cops. Requires RuboCop `0.82.0` or newer.
6+
* Add "ad-hoc" line-aware command hooks
67

78
## 0.58.0
89

README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ Option | Description
240240
`install_command` | Command the user can run to install the `required_executable` (or alternately the specified `required_libraries`). This is intended for documentation purposes, as Overcommit does not install software on your behalf since there are too many edge cases where such behavior would result in incorrectly configured installations (e.g. installing a Python package in the global package space instead of in a virtual environment).
241241
`skip_file_checkout` | Whether to skip this hook for file checkouts (e.g. `git checkout some-ref -- file`). Only applicable to `PostCheckout` hooks.
242242
`skip_if` | Array of arguments to be executed to determine whether or not the hook should run. For example, setting this to a value of `['bash', '-c', '! which my-executable']` would allow you to skip running this hook if `my-executable` was not in the bin path.
243+
`ad_hoc` | *["Ad-hoc" line-aware command hooks](#adding-existing-line-aware-commands) only.*
243244

244245
In addition to the built-in configuration options, each hook can expose its
245246
own unique configuration options. The `AuthorEmail` hook, for example, allows
@@ -671,6 +672,67 @@ of hook, see the [git-hooks documentation][GHD].
671672

672673
[GHD]: https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks
673674

675+
### Adding Existing Line-Aware Commands
676+
677+
Or in other words "low-code error format support."
678+
679+
If you use tools that analyze files and report their findings line-by-line,
680+
and that Overcommit does not yet support, you may be able to integrate them
681+
with Overcommit without writing any Ruby code in a similar way as
682+
[for existing Git hooks](#adding-existing-git-hooks).
683+
684+
These special line-aware command hooks behave and are configured the same way
685+
as the Git ones, except only file arguments get passed to them.
686+
Also they must have the `ad_hoc` option, so that, using the command output:
687+
- differentiating between warnings and errors becomes possible
688+
- modified lines can be detected and acted upon as defined by
689+
the `problem_on_unmodified_line`, `requires_files`, `include` and `exclude`
690+
[hook options](#hook-options)
691+
692+
**Warning**: Only the command's standard output stream is considered for now,
693+
*not* its standard error stream.
694+
695+
To differentiate between warning and error messages,
696+
the `warning_message_type_pattern` suboption may be specified:
697+
the `type` field of the `message_pattern` regexp below must then include
698+
the `warning_message_type_pattern` option's text.
699+
700+
The `message_pattern` suboption specifies the format of the command's messages.
701+
It is a optional [(Ruby) regexp][RubyRE], which if present must at least define
702+
a `file` [named capture group][RubyRENCG].
703+
The only other allowed ones are `line` and `type`, which when specified
704+
enable detection of modified lines and warnings respectively.
705+
706+
**Note**: The default value for this option is often adequate:
707+
it generalizes the quasi-standard [GNU/Emacs-style error format][GNUEerrf],
708+
adding the most frequently used warning syntax to it.
709+
710+
For example:
711+
712+
```yaml
713+
PreCommit:
714+
CustomScript:
715+
enabled: true
716+
command: './bin/custom-script'
717+
ad_hoc:
718+
message_pattern: !ruby/regexp /^(?<file>[^:]+):(?<line>[0-9]+):(?<type>[^ ]+)/
719+
warning_message_type_pattern: warning
720+
```
721+
722+
**Tip**: To get the syntax of the regexps right, a Ruby interpreter like `irb`
723+
can help:
724+
725+
```ruby
726+
require('yaml'); puts YAML.dump(/MY-REGEXP/)
727+
```
728+
729+
Then copy the output line text as the YAML option's value, thereby
730+
omitting the `---` prefix.
731+
732+
[RubyRE]: https://ruby-doc.org/core-2.4.1/Regexp.html
733+
[RubyRENCG]: https://ruby-doc.org/core-2.4.1/Regexp.html#class-Regexp-label-Capturing
734+
[GNUEerrf]: https://www.gnu.org/prep/standards/standards.html#Errors
735+
674736
## Security
675737

676738
While Overcommit can make managing Git hooks easier and more convenient,

lib/overcommit/hook_loader/base.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,34 @@ def load_hooks
2222

2323
private
2424

25+
# GNU/Emacs-style error format:
26+
AD_HOC_HOOK_DEFAULT_MESSAGE_PATTERN =
27+
/^(?<file>(?:\w:)?[^:]+):(?<line>\d+):[^ ]* (?<type>[^ ]+)/.freeze
28+
29+
def create_line_aware_command_hook_class(hook_base)
30+
Class.new(hook_base) do
31+
def run
32+
result = execute(command, args: applicable_files)
33+
34+
return :pass if result.success?
35+
36+
extract_messages(@config['ad_hoc'], result)
37+
end
38+
39+
def extract_messages(ad_hoc_config, result)
40+
warning_message_type_pattern = ad_hoc_config['warning_message_type_pattern']
41+
Overcommit::Utils::MessagesUtils.extract_messages(
42+
result.stdout.split("\n"),
43+
ad_hoc_config['message_pattern'] ||
44+
AD_HOC_HOOK_DEFAULT_MESSAGE_PATTERN,
45+
Overcommit::Utils::MessagesUtils.create_type_categorizer(
46+
warning_message_type_pattern
47+
)
48+
)
49+
end
50+
end
51+
end
52+
2553
attr_reader :log
2654

2755
# Load and return a {Hook} from a CamelCase hook name.

lib/overcommit/hook_loader/plugin_hook_loader.rb

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,23 +74,37 @@ def check_for_modified_plugins
7474
raise Overcommit::Exceptions::InvalidHookSignature
7575
end
7676

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-
77+
def create_git_hook_class(hook_base)
8178
# Implement a simple class that executes the command and returns pass/fail
8279
# based on the exit status
83-
hook_class = Class.new(hook_base) do
80+
Class.new(hook_base) do
8481
def run
8582
result = @context.execute_hook(command)
86-
8783
if result.success?
8884
:pass
8985
else
9086
[:fail, result.stdout + result.stderr]
9187
end
9288
end
9389
end
90+
end
91+
92+
def create_ad_hoc_hook(hook_name)
93+
hook_module = Overcommit::Hook.const_get(@context.hook_class_name)
94+
hook_base = hook_module.const_get('Base')
95+
96+
hook_config = @config.for_hook(hook_name, @context.hook_class_name)
97+
hook_class =
98+
if hook_config['ad_hoc']
99+
create_line_aware_command_hook_class(hook_base)
100+
else
101+
create_git_hook_class(hook_base)
102+
end
103+
104+
# Only to avoid warnings in unit tests...:
105+
if hook_module.const_defined?(hook_name)
106+
return hook_module.const_get(hook_name).new(@config, @context)
107+
end
94108

95109
hook_module.const_set(hook_name, hook_class).new(@config, @context)
96110
rescue LoadError, NameError => e

lib/overcommit/utils/messages_utils.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ def extract_messages(output_messages, regex, type_categorizer = nil)
3737
end
3838
end
3939

40+
def create_type_categorizer(warning_pattern)
41+
return nil if warning_pattern.nil?
42+
43+
lambda do |type|
44+
type.include?(warning_pattern) ? :warning : :error
45+
end
46+
end
47+
4048
private
4149

4250
def extract_file(match, message)

spec/overcommit/hook_loader/plugin_hook_loader_spec.rb

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,135 @@
6565
it { should fail_hook }
6666
end
6767
end
68+
69+
describe 'if line-aware' do
70+
let(:config_contents) do
71+
<<-'YML'
72+
PreCommit:
73+
FooLint:
74+
enabled: true
75+
command: ["foo", "lint"]
76+
ad_hoc:
77+
message_pattern: !ruby/regexp /^(?<file>[^:]+):(?<line>[0-9]+):(?<type>[^ ]+)/
78+
warning_message_type_pattern: warning
79+
flags:
80+
- "--format=emacs"
81+
include: '**/*.foo'
82+
FooLintDefault:
83+
enabled: true
84+
command: ["foo", "lint"]
85+
ad_hoc:
86+
warning_message_type_pattern: warning
87+
flags:
88+
- "--format=emacs"
89+
include: '**/*.foo'
90+
FooLintDefaultNoWarnings:
91+
enabled: true
92+
command: ["foo", "lint"]
93+
ad_hoc:
94+
flags:
95+
- "--format=emacs"
96+
include: '**/*.foo'
97+
YML
98+
end
99+
let(:hook_name) { 'FooLint' }
100+
let(:applicable_files) { %w[file.foo] }
101+
102+
before do
103+
subject.stub(:applicable_files).and_return(applicable_files)
104+
subject.stub(:execute).with(%w[foo lint --format=emacs], args: applicable_files).
105+
and_return(result)
106+
end
107+
108+
context 'when command succeeds' do
109+
let(:result) do
110+
double(success?: true, stdout: '')
111+
end
112+
113+
it { should pass }
114+
end
115+
116+
context 'when command fails with empty stdout' do
117+
let(:result) do
118+
double(success?: false, stdout: '', stderr: '')
119+
end
120+
121+
it { should pass }
122+
end
123+
124+
context 'when command fails with some warning message' do
125+
let(:result) do
126+
double(
127+
success?: false,
128+
stdout: "A:1:warning...\n",
129+
stderr: ''
130+
)
131+
end
132+
133+
it { should warn }
134+
end
135+
136+
context 'when command fails with some error message' do
137+
let(:result) do
138+
double(
139+
success?: false,
140+
stdout: "A:1:???\n",
141+
stderr: ''
142+
)
143+
end
144+
145+
it { should fail_hook }
146+
end
147+
148+
describe '(using default pattern)' do
149+
let(:hook_name) { 'FooLintDefault' }
150+
151+
context 'when command fails with some warning message' do
152+
let(:result) do
153+
double(
154+
success?: false,
155+
stdout: <<-MSG,
156+
B:1: warning: ???
157+
MSG
158+
stderr: ''
159+
)
160+
end
161+
162+
it { should warn }
163+
end
164+
165+
context 'when command fails with some error message' do
166+
let(:result) do
167+
double(
168+
success?: false,
169+
stdout: <<-MSG,
170+
A:1:80: error
171+
MSG
172+
stderr: ''
173+
)
174+
end
175+
176+
it { should fail_hook }
177+
end
178+
end
179+
180+
describe '(using defaults)' do
181+
let(:hook_name) { 'FooLintDefaultNoWarnings' }
182+
183+
context 'when command fails with some messages' do
184+
let(:result) do
185+
double(
186+
success?: false,
187+
stdout: <<-MSG,
188+
A:1:80: error
189+
B:1: warning: ???
190+
MSG
191+
stderr: ''
192+
)
193+
end
194+
195+
it { should fail_hook }
196+
end
197+
end
198+
end
68199
end

0 commit comments

Comments
 (0)