ruby_git_hooks 0.0.31

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,343 @@
1
+ # Copyright (C) 2013 OL2, Inc. See LICENSE.txt for details.
2
+
3
+ require "ruby_git_hooks/version"
4
+
5
+ # This module is the core of the ruby_git_hooks code. It includes the
6
+ # Git commands, the hook types and in general most of the interface.
7
+ # README.md is the best overall documentation for this package, but
8
+ # this is where you can dig into the lowest-level Git specifics.
9
+
10
+ module RubyGitHooks
11
+ # This isn't all hook names, just the ones we already support.
12
+ CAN_FAIL_HOOKS = [ "pre-commit", "pre-receive", "commit-msg" ]
13
+ NO_FAIL_HOOKS = [ "post-receive", "post-commit" ]
14
+ HOOK_NAMES = CAN_FAIL_HOOKS + NO_FAIL_HOOKS
15
+ # applypatch-msg, pre-applypatch, post-applypatch
16
+ # prepare-commit-msg, commit-msg
17
+ # pre-rebase, post-checkout, post-merge, update, post-update,
18
+ # pre-auto-gc, post-rewrite
19
+
20
+ class Hook
21
+ class << self
22
+ # What hooks are running
23
+ attr_reader :registered_hooks
24
+
25
+ # What command line was run
26
+ attr_reader :run_as
27
+
28
+ # What directory to run from
29
+ attr_reader :run_from
30
+
31
+ # What git hook is being run
32
+ attr_reader :run_as_hook
33
+
34
+ # Whether .run has ever been called
35
+ attr_reader :has_run
36
+
37
+ # Array of what files were changed
38
+ attr_accessor :files_changed
39
+
40
+ # Latest contents of all changed files
41
+ attr_accessor :file_contents
42
+
43
+ # A human-readable diff per file
44
+ attr_accessor :file_diffs
45
+
46
+ # All filenames in repo
47
+ attr_accessor :ls_files
48
+
49
+ # All current commits (sometimes empty)
50
+ attr_accessor :commits
51
+
52
+ # Commit message for current commit
53
+ attr_accessor :commit_message
54
+
55
+ # Commit message file for current commit
56
+ attr_accessor :commit_message_file
57
+ end
58
+
59
+ # Instances of Hook delegate these methods to the class methods.
60
+ HOOK_INFO = [ :files_changed, :file_contents, :file_diffs, :ls_files,
61
+ :commits, :commit_message, :commit_message_file ]
62
+ HOOK_INFO.each do |info_method|
63
+ define_method(info_method) do |*args, &block|
64
+ Hook.send(info_method, *args, &block)
65
+ end
66
+ end
67
+
68
+ HOOK_TYPE_SETUP = {
69
+
70
+ # Pre-receive gets no args, but STDIN with a list of changes.
71
+ "pre-receive" => proc {
72
+ changes = []
73
+ STDIN.each_line do |line|
74
+ base, commit, ref = line.strip.split
75
+ changes.push [base, commit, ref]
76
+ end
77
+ self.commits = []
78
+
79
+ self.files_changed = []
80
+ self.file_contents = {}
81
+ self.file_diffs = {}
82
+
83
+ changes.each do |base, commit, ref|
84
+ no_base = false
85
+ if base =~ /\A0+\z/
86
+ # if base is 000... (initial commit), then all files were added, and git diff won't work
87
+ no_base = true
88
+ files_with_status = Hook.shell!("git ls-tree --name-status -r #{commit}").split("\n")
89
+ # put the A at the front
90
+ files_with_status.map!{|filename| "A\t" + filename}
91
+ else
92
+ files_with_status = Hook.shell!("git diff --name-status #{base}..#{commit}").split("\n")
93
+ end
94
+
95
+ files_with_status.each do |f|
96
+ status, file_changed = f.scan(/([ACDMRTUXB])\s+(\S+)$/).flatten
97
+ self.files_changed << file_changed
98
+
99
+ file_diffs[file_changed] = Hook.shell!("git log -p #{commit} -- #{file_changed}")
100
+ begin
101
+ file_contents[file_changed] = status == "D" ? "" : Hook.shell!("git show #{commit}:#{file_changed}")
102
+ rescue
103
+ # weird bug where some repos can't run the git show command even when it's not a deleted file.
104
+ # example: noah-gibbs/barkeep/test/fixtures/text_git_repo I haven't figured out what's
105
+ # weird about it yet but this fails, so put in a hack for now. May want to leave this since
106
+ # we'd rather continue without the changes than fail, right?
107
+ file_contents[file_changed] = ""
108
+ end
109
+ end
110
+ commit_range = no_base ? commit : "#{base}..#{commit}"
111
+ new_commits = Hook.shell!("git log --pretty=format:%H #{commit_range}").split("\n")
112
+ self.commits = self.commits | new_commits
113
+ end
114
+
115
+ if !self.commits.empty?
116
+ file_list_revision = self.commits.first # can't just use HEAD - remote may be on branch with no HEAD
117
+ self.ls_files = Hook.shell!("git ls-tree --full-tree --name-only -r #{file_list_revision}").split("\n")
118
+ # TODO should store ls_files per commit and ls_files with branch name (in case commits on multiple branches)?
119
+ end
120
+
121
+
122
+
123
+ },
124
+
125
+ "pre-commit" => proc {
126
+ files_with_status = Hook.shell!("git diff --name-status --cached").split("\n")
127
+
128
+ self.files_changed = []
129
+ self.file_contents = {}
130
+ self.file_diffs = {}
131
+ self.commits = []
132
+
133
+ files_with_status.each do |f|
134
+ status, file_changed = f.scan(/([ACDMRTUXB])\s+(\S+)$/).flatten
135
+ self.files_changed << file_changed
136
+
137
+ file_diffs[file_changed] = Hook.shell!("git diff --cached -- #{file_changed}")
138
+ file_contents[file_changed] = status == "D"? "": Hook.shell!("git show :#{file_changed}")
139
+ end
140
+
141
+ self.ls_files = Hook.shell!("git ls-files").split("\n")
142
+ },
143
+
144
+ "post-commit" => proc {
145
+ last_commit_files = Hook.shell!("git log --oneline --name-status -1")
146
+ # Split, cut off leading line to get actual files with status
147
+ files_with_status = last_commit_files.split("\n")[1..-1]
148
+
149
+ self.files_changed = []
150
+ self.commits = [ Hook.shell!("git log -n 1 --pretty=format:%H").chomp ]
151
+ self.file_contents = {}
152
+ self.file_diffs = {}
153
+
154
+ files_with_status.each do |f|
155
+ status, file_changed = f.scan(/([ACDMRTUXB])\s+(\S+)$/).flatten
156
+ self.files_changed << file_changed
157
+
158
+ file_diffs[file_changed] = Hook.shell!("git log --oneline -p -1 -- #{file_changed}")
159
+ file_contents[file_changed] = status == "D"? "": Hook.shell!("git show :#{file_changed}")
160
+ end
161
+
162
+ self.ls_files = Hook.shell!("git ls-files").split("\n")
163
+ self.commit_message = Hook.shell!("git log -1 --pretty=%B")
164
+ },
165
+
166
+ "commit-msg" => proc {
167
+ files_with_status = Hook.shell!("git diff --name-status --cached").split("\n")
168
+
169
+ self.files_changed = []
170
+ self.file_contents = {}
171
+ self.file_diffs = {}
172
+ self.commits = []
173
+
174
+ files_with_status.each do |f|
175
+ status, file_changed = f.scan(/([ACDMRTUXB])\s+(\S+)$/).flatten
176
+ self.files_changed << file_changed
177
+
178
+ file_diffs[file_changed] = Hook.shell!("git diff --cached -- #{file_changed}")
179
+ file_contents[file_changed] = status == "D"? "": Hook.shell!("git show :#{file_changed}")
180
+ end
181
+
182
+ self.ls_files = Hook.shell!("git ls-files").split("\n")
183
+ self.commit_message = File.read(ARGV[0])
184
+ self.commit_message_file = ARGV[0]
185
+ }
186
+ }
187
+ HOOK_TYPE_SETUP["post-receive"] = HOOK_TYPE_SETUP["pre-receive"]
188
+
189
+ def self.initial_setup
190
+ return if @run_from
191
+
192
+ @run_from = Dir.getwd
193
+ @run_as = $0
194
+ end
195
+
196
+ def setup
197
+ Dir.chdir Hook.run_from do
198
+ yield
199
+ end
200
+
201
+ ensure
202
+ # Nothing yet
203
+ end
204
+
205
+ def self.get_hooks_to_run(hook_specs)
206
+ @registered_hooks ||= {}
207
+
208
+ if hook_specs.empty?
209
+ return @registered_hooks.values.inject([], &:+)
210
+ end
211
+
212
+ hook_specs.flat_map do |spec|
213
+ if @registered_hooks[spec]
214
+ @registered_hooks[spec]
215
+ elsif spec.is_a?(Hook)
216
+ [ spec ]
217
+ elsif spec.is_a?(String)
218
+ # A string is assumed to be a class name
219
+ @registered_hooks[Object.const_get(spec)]
220
+ else
221
+ raise "Can't find hook for specification: #{spec.inspect}!"
222
+ end
223
+ end
224
+ end
225
+
226
+ # Run takes a list of hook specifications.
227
+ # Those can be Hook classnames or instances of type
228
+ # Hook.
229
+ #
230
+ # @param hook_specs Array[Hook or Class or String] A list of hooks or hook classes
231
+ def self.run(*hook_specs)
232
+ if @has_run
233
+ STDERR.puts <<ERR
234
+ In this version, you can't call .run more than once. For now, please
235
+ register your hooks individually and then call .run with no args, or
236
+ else call .run with both as arguments. This may be fixed in a future
237
+ version. Sorry!
238
+ ERR
239
+ exit 1
240
+ end
241
+ @has_run = true
242
+
243
+ initial_setup
244
+
245
+ run_as_specific_githook
246
+
247
+ # By default, run all hooks
248
+ hooks_to_run = get_hooks_to_run(hook_specs.flatten)
249
+
250
+ failed_hooks = []
251
+ val = nil
252
+ hooks_to_run.each do |hook|
253
+ begin
254
+ hook.setup { val = hook.check } # Re-init each time, just in case
255
+ failed_hooks.push(hook) unless val
256
+ rescue
257
+ # Failed. Return non-zero if that makes a difference.
258
+ STDERR.puts "Hook #{hook.inspect} raised exception: #{$!.inspect}!\n#{$!.backtrace.join("\n")}"
259
+ failed_hooks.push hook
260
+ end
261
+ end
262
+
263
+ if CAN_FAIL_HOOKS.include?(@run_as_hook) && failed_hooks.size > 0
264
+ STDERR.puts "Hooks failed: #{failed_hooks}"
265
+ STDERR.puts "Use 'git commit -eF .git/COMMIT_EDITMSG' to restore your commit message" if commit_message
266
+ STDERR.puts "Exiting!"
267
+ exit 1
268
+ end
269
+ end
270
+
271
+ def self.run_as_specific_githook
272
+ return if @run_as_hook # Already did this
273
+
274
+ self.initial_setup # Might have already done this
275
+
276
+ if ARGV.include? "--hook"
277
+ idx = ARGV.find_index "--hook"
278
+ @run_as_hook = ARGV[idx + 1]
279
+ 2.times { ARGV.delete_at(idx) }
280
+ else
281
+ @run_as_hook = HOOK_NAMES.detect { |hook| @run_as.include?(hook) }
282
+ end
283
+
284
+ unless @run_as_hook
285
+ STDERR.puts "Name #{@run_as.inspect} doesn't include " +
286
+ "any of: #{HOOK_NAMES.inspect}"
287
+ exit 1
288
+ end
289
+
290
+ unless HOOK_TYPE_SETUP[@run_as_hook]
291
+ STDERR.puts "No setup defined for hook type #{@run_as_hook.inspect}!"
292
+ exit 1
293
+ end
294
+ self.instance_eval(&HOOK_TYPE_SETUP[@run_as_hook])
295
+ end
296
+
297
+ def self.register(hook)
298
+ @registered_hooks ||= {}
299
+ @registered_hooks[hook.class.name] ||= []
300
+ @registered_hooks[hook.class.name].push hook
301
+
302
+ # Figure out when to set this up...
303
+ #at_exit do
304
+ # unless RubyGitHooks::Hook.has_run
305
+ # STDERR.puts "No call to RubyGitHooks.run happened, so no hooks ran!"
306
+ # end
307
+ #end
308
+ end
309
+
310
+ def self.shell!(*args)
311
+ output = `#{args.join(" ")}`
312
+
313
+ unless $?.success?
314
+ STDERR.puts "Job #{args.inspect} failed in dir #{Dir.getwd.inspect}"
315
+ STDERR.puts "Failed job output:\n#{output}\n======"
316
+ raise "Exec of #{args.inspect} failed: #{$?}!"
317
+ end
318
+
319
+ output
320
+ end
321
+ end
322
+
323
+ # Forward these calls from RubyGitHooks to RubyGitHooks::Hook
324
+ class << self
325
+ [ :run, :register, :run_as ].each do |method|
326
+ define_method(method) do |*args, &block|
327
+ RubyGitHooks::Hook.send(method, *args, &block)
328
+ end
329
+ end
330
+ end
331
+
332
+ def self.shebang
333
+ ENV['RUBYGITHOOKS_SHEBANG']
334
+ end
335
+
336
+ def self.current_hook
337
+ RubyGitHooks::Hook.run_as_specific_githook
338
+ RubyGitHooks::Hook.run_as_hook
339
+ end
340
+ end
341
+
342
+ # Default to /usr/bin/env ruby for shebang line
343
+ ENV['RUBYGITHOOKS_SHEBANG'] ||= "#!/usr/bin/env ruby"
@@ -0,0 +1,37 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'ruby_git_hooks/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "ruby_git_hooks"
8
+ spec.version = RubyGitHooks::VERSION
9
+ spec.authors = ["Noah Gibbs", "Ruth Helfinstein", "Alex Snyatkov"]
10
+ spec.email = ["noah@onlive.com", "ruth.helfinstein@onlive.com",
11
+ "alex.snyatkov@onlive.com"]
12
+ spec.description = <<DESC
13
+ Ruby_git_hooks is a library to allow easy writing and installing of
14
+ git hooks in Ruby. It abstracts away the differences between
15
+ different hook interfaces and supplies implementations of some common
16
+ Git hooks. It allows overriding "git clone" to automatically
17
+ install your prefered hooks.
18
+ DESC
19
+ spec.summary = %q{DSL and manager for git hooks in Ruby.}
20
+ spec.homepage = ""
21
+ spec.license = "MIT"
22
+
23
+ spec.files = `git ls-files`.split($/)
24
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
25
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
26
+ spec.require_paths = ["lib"]
27
+ spec.bindir = "bin"
28
+
29
+ spec.add_runtime_dependency "pony" # For email
30
+ spec.add_runtime_dependency "rest-client"
31
+ spec.add_runtime_dependency "json"
32
+
33
+ spec.add_development_dependency "bundler", "~> 1.3"
34
+ spec.add_development_dependency "minitest"
35
+ spec.add_development_dependency "rr"
36
+ spec.add_development_dependency "rake"
37
+ end
@@ -0,0 +1,143 @@
1
+ # Copyright (C) 2013 OL2, Inc. See LICENSE.txt for details.
2
+
3
+ require "test_helper"
4
+
5
+ require "minitest/autorun"
6
+
7
+ class BasicHookTest < HookTestCase
8
+ REPOS_DIR = File.expand_path File.join(File.dirname(__FILE__), "repos")
9
+ TEST_PATH = File.join(REPOS_DIR, "hook_test_file")
10
+ TEST_HOOK_BODY = <<HOOK
11
+ #{RubyGitHooks.shebang}
12
+ require "ruby_git_hooks"
13
+
14
+ class TestHook < RubyGitHooks::Hook
15
+ def check
16
+ File.open("#{TEST_PATH}", "w") do |f|
17
+ f.puts files_changed.inspect, file_contents.inspect
18
+ end
19
+
20
+ puts "Test hook runs!"
21
+ true
22
+ end
23
+ end
24
+
25
+ RubyGitHooks.run TestHook.new
26
+ HOOK
27
+
28
+ TEST_HOOK_COMMIT_MSG = <<HOOK
29
+ #{RubyGitHooks.shebang}
30
+ require "ruby_git_hooks"
31
+
32
+ class TestHook < RubyGitHooks::Hook
33
+ def check
34
+ File.open("#{TEST_PATH}", "w") do |f|
35
+ f.puts commit_message
36
+ end
37
+
38
+ puts "Test hook runs!"
39
+ true
40
+ end
41
+ end
42
+
43
+ RubyGitHooks.run TestHook.new
44
+ HOOK
45
+
46
+ def setup(do_first_commit = true)
47
+ # Empty out the test repos dir
48
+ Hook.shell! "rm -rf #{File.join(REPOS_DIR, "*")}"
49
+
50
+ # Create local parent and child repos with a single shared commit
51
+ Dir.chdir REPOS_DIR
52
+
53
+ new_bare_repo
54
+ clone_repo
55
+ if do_first_commit
56
+ new_single_file_commit
57
+ git_push
58
+ end
59
+ end
60
+
61
+ def test_simple_pre_commit
62
+ add_hook("child_repo", "pre-commit", TEST_HOOK_BODY)
63
+
64
+ new_single_file_commit "child_repo"
65
+
66
+ assert File.exist?(TEST_PATH), "Test pre-commit hook didn't run!"
67
+ assert File.read(TEST_PATH).include?("Single-file commit"),
68
+ "No file contents reached pre-commit hook!"
69
+ end
70
+
71
+ def test_pre_commit_with_delete
72
+ add_hook("child_repo", "pre-commit", TEST_HOOK_BODY)
73
+ new_commit "child_repo", "file_to_delete"
74
+ git_delete "child_repo", "file_to_delete"
75
+ git_commit "child_repo", "Deleted file_to_delete"
76
+
77
+ assert File.exist?(TEST_PATH), "Test pre-commit hook didn't run!"
78
+ assert File.read(TEST_PATH).include?('"file_to_delete"=>""'),
79
+ "File not deleted properly"
80
+ end
81
+
82
+ def test_pre_commit_with_rename
83
+ add_hook("child_repo", "pre-commit", TEST_HOOK_BODY)
84
+ new_commit "child_repo", "file_to_rename"
85
+ git_rename "child_repo", "file_to_rename", "renamed_file"
86
+ new_commit "child_repo", "renamed_file", nil, "Renamed file"
87
+
88
+ assert File.exist?(TEST_PATH), "Test pre-commit hook didn't run!"
89
+ assert File.read(TEST_PATH).include?('"file_to_rename"=>""'),
90
+ "File not deleted properly"
91
+ end
92
+
93
+ def test_simple_pre_receive
94
+ add_hook("parent_repo.git", "pre-receive", TEST_HOOK_BODY)
95
+
96
+ new_single_file_commit "child_repo"
97
+ git_push("child_repo")
98
+
99
+ assert File.exist?(TEST_PATH), "Test pre-receive hook didn't run!"
100
+ assert File.read(TEST_PATH).include?("Single-file commit"),
101
+ "No file contents reached pre-receive hook!"
102
+ end
103
+
104
+
105
+ def test_pre_receive_with_delete
106
+ add_hook("parent_repo.git", "pre-receive", TEST_HOOK_BODY)
107
+
108
+ new_commit "child_repo", "file_to_delete"
109
+ git_push "child_repo"
110
+
111
+
112
+ git_delete "child_repo", "file_to_delete"
113
+ git_commit "child_repo", "Deleted file_to_delete"
114
+ git_push "child_repo"
115
+
116
+
117
+ assert File.exist?(TEST_PATH), "Test pre-receive hook didn't run!"
118
+ assert File.read(TEST_PATH).include?('"file_to_delete"=>""'),
119
+ "File deletion did not reach pre-receive hook!"
120
+ end
121
+
122
+ def test_commit_msg
123
+ add_hook("child_repo", "commit-msg", TEST_HOOK_COMMIT_MSG)
124
+ new_commit "child_repo", "my_file", "Commit contents", "This is my commit message"
125
+ assert File.exist?(TEST_PATH), "Test commit-msg hook didn't run!"
126
+ assert File.read(TEST_PATH).include?("This is my commit message"),
127
+ "Commit message did not reach commit-msg hook"
128
+ end
129
+
130
+ def test_post_commit_has_commit_msg
131
+ add_hook("child_repo", "post-commit", TEST_HOOK_COMMIT_MSG)
132
+ new_commit "child_repo", "my_file", "Commit contents", "This is my commit message"
133
+ assert File.exist?(TEST_PATH), "Test post-commit hook didn't run!"
134
+ assert File.read(TEST_PATH).include?("This is my commit message"),
135
+ "Commit message did not reach post-commit hook"
136
+ end
137
+
138
+ def test_first_pre_receive
139
+ setup(false) # don't do first commit
140
+ test_simple_pre_receive
141
+ end
142
+
143
+ end
@@ -0,0 +1,58 @@
1
+ # Copyright (C) 2013 OL2, Inc. See LICENSE.txt for details.
2
+
3
+ require "test_helper"
4
+
5
+ require "minitest/autorun"
6
+
7
+ class CaseClashHookTest < HookTestCase
8
+ REPOS_DIR = File.expand_path File.join(File.dirname(__FILE__), "repos")
9
+ TEST_HOOK_BODY = <<TEST
10
+ #{RubyGitHooks.shebang}
11
+ require "ruby_git_hooks/case_clash"
12
+
13
+ RubyGitHooks.run CaseClashHook.new
14
+ TEST
15
+
16
+ def setup
17
+ # Empty out the test repos dir
18
+ Hook.shell! "rm -rf #{File.join(REPOS_DIR, "*")}"
19
+
20
+ # Create local parent and child repos with a single shared commit
21
+ Dir.chdir REPOS_DIR
22
+
23
+ new_bare_repo
24
+ clone_repo
25
+ new_commit "child_repo", "README"
26
+ git_push
27
+ end
28
+
29
+ def test_case_clash_pre_commit
30
+ add_hook("child_repo", "pre-commit", TEST_HOOK_BODY)
31
+
32
+ new_commit "child_repo", "CaseClashFile1"
33
+ case1_sha = last_commit_sha
34
+ rewind_one_commit
35
+
36
+ new_commit "child_repo", "CASECLASHFILE1"
37
+ case2_sha = last_commit_sha
38
+
39
+ # Cherry-pick new content into place -- this means both files.
40
+ Hook.shell!("cd child_repo && git cherry-pick #{case1_sha}")
41
+ case_both = last_commit_sha
42
+
43
+ rewind_one_commit
44
+
45
+ # Soft-reset so content is still present
46
+ Hook.shell!("cd child_repo && git reset #{case_both}")
47
+
48
+ # Should reject w/ pre-commit hook
49
+ assert_raises RuntimeError do
50
+ Hook.shell!("cd child_repo && git commit -m \"Message\"")
51
+ end
52
+ end
53
+
54
+ #def test_case_clash_pre_receive
55
+ # add_hook("parent_repo.git", "pre-receive", TEST_HOOK_BODY)
56
+ #end
57
+
58
+ end