Skip to content

Commit b4d4ce0

Browse files
authored
Add -diff cli option for running precommit hooks against diffs (#860)
For example, running `overcommit --diff main` from a feature branch will run pre-commit hooks against the diff between the two branches. I was able to very easily leverage existing code for the bulk of the feature - this is mainly just adding the cli option, a hook context to do the execution and some tests based on the existing `--run-all` test. --- For background, my team is responsible for a couple of really old, really large rails apps. Getting them completely in compliance with our various linters is a huge task that isn't getting done anytime soon (things are funky to the point that we've even observed breakages with "safe" auto-correct functions). I introduced/started heavily encouraging overcommit so that we at least don't add _new_ linting offenses and things will naturally improve over time. It's been great, but offenses still slip through though here and there, especially with juniors who might be getting away with not having a local install (and/or abusing `OVERCOMMIT_DISABLE=1`). An option like this would allow me to leverage the very useful "only apply to changed lines" logic within a ci environment and help enforce my desired "no new linting offenses" policy.
1 parent 9ce5492 commit b4d4ce0

File tree

7 files changed

+272
-4
lines changed

7 files changed

+272
-4
lines changed

lib/overcommit/cli.rb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ module Overcommit
99
class CLI # rubocop:disable Metrics/ClassLength
1010
def initialize(arguments, input, logger)
1111
@arguments = arguments
12+
@cli_options = {}
1213
@input = input
1314
@log = logger
1415
@options = {}
@@ -28,6 +29,8 @@ def run
2829
sign
2930
when :run_all
3031
run_all
32+
when :diff
33+
diff
3134
end
3235
rescue Overcommit::Exceptions::ConfigurationSignatureChanged => e
3336
puts e
@@ -45,7 +48,7 @@ def parse_arguments
4548
@parser = create_option_parser
4649

4750
begin
48-
@parser.parse!(@arguments)
51+
@parser.parse!(@arguments, into: @cli_options)
4952

5053
# Default action is to install
5154
@options[:action] ||= :install
@@ -98,6 +101,11 @@ def add_installation_options(opts)
98101
@options[:action] = :run_all
99102
@options[:hook_to_run] = arg ? arg.to_s : 'run-all'
100103
end
104+
105+
opts.on('--diff [ref]', 'Run pre_commit hooks against the diff between a given ref. Defaults to `main`.') do |arg| # rubocop:disable Layout/LineLength
106+
@options[:action] = :diff
107+
arg
108+
end
101109
end
102110

103111
def add_other_options(opts)
@@ -209,6 +217,19 @@ def run_all
209217
halt(status ? 0 : 65)
210218
end
211219

220+
def diff
221+
empty_stdin = File.open(File::NULL) # pre-commit hooks don't take input
222+
context = Overcommit::HookContext.create('diff', config, @arguments, empty_stdin, **@cli_options) # rubocop:disable Layout/LineLength
223+
config.apply_environment!(context, ENV)
224+
225+
printer = Overcommit::Printer.new(config, log, context)
226+
runner = Overcommit::HookRunner.new(config, log, context, printer)
227+
228+
status = runner.run
229+
230+
halt(status ? 0 : 65)
231+
end
232+
212233
# Used for ease of stubbing in tests
213234
def halt(status = 0)
214235
exit status

lib/overcommit/hook_context.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
# Utility module which manages the creation of {HookContext}s.
44
module Overcommit::HookContext
5-
def self.create(hook_type, config, args, input)
5+
def self.create(hook_type, config, args, input, **cli_options)
66
hook_type_class = Overcommit::Utils.camel_case(hook_type)
77
underscored_hook_type = Overcommit::Utils.snake_case(hook_type)
88

99
require "overcommit/hook_context/#{underscored_hook_type}"
1010

11-
Overcommit::HookContext.const_get(hook_type_class).new(config, args, input)
11+
Overcommit::HookContext.const_get(hook_type_class).new(config, args, input, **cli_options)
1212
rescue LoadError, NameError => e
1313
# Could happen when a symlink was created for a hook type Overcommit does
1414
# not yet support.

lib/overcommit/hook_context/base.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ class Base
1818
# @param config [Overcommit::Configuration]
1919
# @param args [Array<String>]
2020
# @param input [IO] standard input stream
21-
def initialize(config, args, input)
21+
# @param options [Hash] cli options
22+
def initialize(config, args, input, **options)
2223
@config = config
2324
@args = args
2425
@input = input
26+
@options = options
2527
end
2628

2729
# Executes a command as if it were a regular git hook, passing all
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
require 'overcommit/git_repo'
4+
5+
module Overcommit::HookContext
6+
# Simulates a pre-commit context based on the diff with another git ref.
7+
#
8+
# This results in pre-commit hooks running against the changes between the current
9+
# and another ref, which is useful for automated CI scripts.
10+
class Diff < Base
11+
def modified_files
12+
@modified_files ||= Overcommit::GitRepo.modified_files(refs: @options[:diff])
13+
end
14+
15+
def modified_lines_in_file(file)
16+
@modified_lines ||= {}
17+
@modified_lines[file] ||= Overcommit::GitRepo.extract_modified_lines(file,
18+
refs: @options[:diff])
19+
end
20+
21+
def hook_class_name
22+
'PreCommit'
23+
end
24+
25+
def hook_type_name
26+
'pre_commit'
27+
end
28+
29+
def hook_script_name
30+
'pre-commit'
31+
end
32+
33+
def initial_commit?
34+
@initial_commit ||= Overcommit::GitRepo.initial_commit?
35+
end
36+
end
37+
end

spec/integration/diff_flag_spec.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
describe 'overcommit --diff' do
6+
subject { shell(%w[overcommit --diff main]) }
7+
8+
context 'when using an existing pre-commit hook script' do
9+
let(:script_name) { 'test-script' }
10+
let(:script_contents) { "#!/bin/bash\nexit 0" }
11+
let(:script_path) { ".#{Overcommit::OS::SEPARATOR}#{script_name}" }
12+
13+
let(:config) do
14+
{
15+
'PreCommit' => {
16+
'MyHook' => {
17+
'enabled' => true,
18+
'required_executable' => script_path,
19+
}
20+
}
21+
}
22+
end
23+
24+
around do |example|
25+
repo do
26+
File.open('.overcommit.yml', 'w') { |f| f.puts(config.to_yaml) }
27+
echo(script_contents, script_path)
28+
`git add #{script_path}`
29+
FileUtils.chmod(0o755, script_path)
30+
example.run
31+
end
32+
end
33+
34+
it 'completes successfully without blocking' do
35+
wait_until(timeout: 10) { subject } # Need to wait long time for JRuby startup
36+
subject.status.should == 0
37+
end
38+
end
39+
end

spec/overcommit/cli_spec.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require 'spec_helper'
44
require 'overcommit/cli'
5+
require 'overcommit/hook_context/diff'
56
require 'overcommit/hook_context/run_all'
67

78
describe Overcommit::CLI do
@@ -125,5 +126,30 @@
125126
subject
126127
end
127128
end
129+
130+
context 'with the diff switch specified' do
131+
let(:arguments) { ['--diff=some-ref'] }
132+
let(:config) { Overcommit::ConfigurationLoader.default_configuration }
133+
134+
before do
135+
cli.stub(:halt)
136+
Overcommit::HookRunner.any_instance.stub(:run)
137+
end
138+
139+
it 'creates a HookRunner with the diff context' do
140+
Overcommit::HookRunner.should_receive(:new).
141+
with(config,
142+
logger,
143+
instance_of(Overcommit::HookContext::Diff),
144+
instance_of(Overcommit::Printer)).
145+
and_call_original
146+
subject
147+
end
148+
149+
it 'runs the HookRunner' do
150+
Overcommit::HookRunner.any_instance.should_receive(:run)
151+
subject
152+
end
153+
end
128154
end
129155
end
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
require 'overcommit/hook_context/diff'
5+
6+
describe Overcommit::HookContext::Diff do
7+
let(:config) { double('config') }
8+
let(:args) { [] }
9+
let(:input) { double('input') }
10+
let(:context) { described_class.new(config, args, input, diff: 'master') }
11+
12+
describe '#modified_files' do
13+
subject { context.modified_files }
14+
15+
context 'when repo contains no files' do
16+
around do |example|
17+
repo do
18+
`git commit --allow-empty -m "Initial commit"`
19+
`git checkout -b other-branch 2>&1`
20+
example.run
21+
end
22+
end
23+
24+
it { should be_empty }
25+
end
26+
27+
context 'when the repo contains files that are unchanged from the ref' do
28+
around do |example|
29+
repo do
30+
touch('some-file')
31+
`git add some-file`
32+
touch('some-other-file')
33+
`git add some-other-file`
34+
`git commit -m "Add files"`
35+
`git checkout -b other-branch 2>&1`
36+
example.run
37+
end
38+
end
39+
40+
it { should be_empty }
41+
end
42+
43+
context 'when repo contains files that have been changed from the ref' do
44+
around do |example|
45+
repo do
46+
touch('some-file')
47+
`git add some-file`
48+
touch('some-other-file')
49+
`git add some-other-file`
50+
`git commit -m "Add files"`
51+
`git checkout -b other-branch 2>&1`
52+
File.open('some-file', 'w') { |f| f.write("hello\n") }
53+
`git add some-file`
54+
`git commit -m "Edit file"`
55+
example.run
56+
end
57+
end
58+
59+
it { should == %w[some-file].map { |file| File.expand_path(file) } }
60+
end
61+
62+
context 'when repo contains submodules' do
63+
around do |example|
64+
submodule = repo do
65+
touch 'foo'
66+
`git add foo`
67+
`git commit -m "Initial commit"`
68+
end
69+
70+
repo do
71+
`git submodule add #{submodule} test-sub 2>&1 > #{File::NULL}`
72+
`git commit --allow-empty -m "Initial commit"`
73+
`git checkout -b other-branch 2>&1`
74+
example.run
75+
end
76+
end
77+
78+
it { should_not include File.expand_path('test-sub') }
79+
end
80+
end
81+
82+
describe '#modified_lines_in_file' do
83+
let(:modified_file) { 'some-file' }
84+
subject { context.modified_lines_in_file(modified_file) }
85+
86+
context 'when file contains a trailing newline' do
87+
around do |example|
88+
repo do
89+
touch(modified_file)
90+
`git add #{modified_file}`
91+
`git commit -m "Add file"`
92+
`git checkout -b other-branch 2>&1`
93+
File.open(modified_file, 'w') { |f| (1..3).each { |i| f.write("#{i}\n") } }
94+
`git add #{modified_file}`
95+
`git commit -m "Edit file"`
96+
example.run
97+
end
98+
end
99+
100+
it { should == Set.new(1..3) }
101+
end
102+
103+
context 'when file does not contain a trailing newline' do
104+
around do |example|
105+
repo do
106+
touch(modified_file)
107+
`git add #{modified_file}`
108+
`git commit -m "Add file"`
109+
`git checkout -b other-branch 2>&1`
110+
File.open(modified_file, 'w') do |f|
111+
(1..2).each { |i| f.write("#{i}\n") }
112+
f.write(3)
113+
end
114+
`git add #{modified_file}`
115+
`git commit -m "Edit file"`
116+
example.run
117+
end
118+
end
119+
120+
it { should == Set.new(1..3) }
121+
end
122+
end
123+
124+
describe '#hook_type_name' do
125+
subject { context.hook_type_name }
126+
127+
it { should == 'pre_commit' }
128+
end
129+
130+
describe '#hook_script_name' do
131+
subject { context.hook_script_name }
132+
133+
it { should == 'pre-commit' }
134+
end
135+
136+
describe '#initial_commit?' do
137+
subject { context.initial_commit? }
138+
139+
before { Overcommit::GitRepo.stub(:initial_commit?).and_return(true) }
140+
141+
it { should == true }
142+
end
143+
end

0 commit comments

Comments
 (0)