dco 1.0.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.
@@ -0,0 +1,21 @@
1
+ #
2
+ # Copyright 2016, Noah Kantrowitz
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ eval_gemfile File.expand_path('../../Gemfile', __FILE__)
18
+
19
+ gem 'git', github: 'schacon/ruby-git'
20
+ gem 'rspec-command', github: 'coderanger/rspec-command'
21
+ gem 'thor', github: 'erikhuda/thor'
@@ -0,0 +1,17 @@
1
+ #
2
+ # Copyright 2016, Noah Kantrowitz
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ eval_gemfile File.expand_path('../../Gemfile', __FILE__)
@@ -0,0 +1,24 @@
1
+ #
2
+ # Copyright 2016, Noah Kantrowitz
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+
18
+ # A toolkit for managing projects using DCO.
19
+ #
20
+ # @since 1.0.0
21
+ module Dco
22
+ autoload :CLI, 'dco/cli'
23
+ autoload :VERSION, 'dco/version'
24
+ end
@@ -0,0 +1,402 @@
1
+ #
2
+ # Copyright 2016, Noah Kantrowitz
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+
17
+ require 'shellwords'
18
+
19
+ require 'git'
20
+ require 'thor'
21
+
22
+
23
+ module Dco
24
+ class CLI < Thor
25
+ # Because this isn't the default and exit statuses are what the cool kids do.
26
+ def self.exit_on_failure?
27
+ true
28
+ end
29
+
30
+ # Fix the basename display in ChefDK.
31
+ #
32
+ # @api private
33
+ # @return [String]
34
+ def self.basename
35
+ ret = super
36
+ if ret == 'chef'
37
+ 'chef dco'
38
+ else
39
+ ret
40
+ end
41
+ end
42
+
43
+ no_commands do
44
+ # Return the path for the git repository we will process. Defaults to the
45
+ # current working directory.
46
+ #
47
+ # @return [String]
48
+ def repo_path
49
+ @repo_path || Dir.pwd
50
+ end
51
+
52
+ # Set a new path for the git repository.
53
+ #
54
+ # @param val [String] Path
55
+ # @return [void]
56
+ def repo_path=(val)
57
+ @repo_path = val
58
+ # Force things to reload.
59
+ @repo = nil
60
+ @repo_config = nil
61
+ end
62
+ end
63
+
64
+ # Internal command used by the git hook to implement the processing logic.
65
+ # This is done in Ruby because writing it to work on all platforms in Bash
66
+ # seems unfun.
67
+ #
68
+ # Design note: this should try as hard as possible to be fast, especially
69
+ # in hook mode as it adds overhead time to every commit there. Currently
70
+ # it should only have to touch the filesystem to read/write the message,
71
+ # when in hook mode. For filter mode, it does need to load the git config
72
+ # if using --behalf.
73
+ desc 'process_commit_message', 'process a git commit message to add DCO signoff', hide: true
74
+ options behalf: :string, repo: :string
75
+ def process_commit_message(tmp_path=nil)
76
+ # Set the repo path if passed.
77
+ self.repo_path = options[:repo] if options[:repo]
78
+ # If a path is passed use it as a tmpfile, otherwise assume filter mode.
79
+ commit_msg = tmp_path ? IO.read(tmp_path) : STDIN.read
80
+ unless has_sign_off?(commit_msg)
81
+ # If we're in filter mode and not on-behalf-of, do a final check of the author.
82
+ if !tmp_path && !options[:behalf] && ENV['GIT_AUTHOR_EMAIL'] != repo_config['user.email']
83
+ # Something went wrong, refuse to rewrite.
84
+ STDOUT.write(commit_msg)
85
+ raise Thor::Error.new("Author mismatch on commit #{ENV['GIT_COMMIT']}: #{ENV['GIT_AUTHOR_EMAIL']} vs #{repo_config['user.email']}")
86
+ end
87
+ commit_msg << "\n" unless commit_msg.end_with?("\n")
88
+ commit_msg << "\nSigned-off-by: #{ENV['GIT_AUTHOR_NAME']} <#{ENV['GIT_AUTHOR_EMAIL']}>\n"
89
+ if options[:behalf]
90
+ # This requires loading the actual repo config, which is slower.
91
+ commit_msg << "Sign-off-executed-by: #{git_identity}\n"
92
+ commit_msg << "Approved-at: #{options[:behalf]}\n"
93
+ end
94
+ IO.write(tmp_path, commit_msg) if tmp_path
95
+ end
96
+ # Always display the replacement commit message if we're in filter mode.
97
+ STDOUT.write(commit_msg) unless tmp_path
98
+ end
99
+
100
+ # Full text of the DCO.
101
+ # @api private
102
+ DCO_TEXT = <<-EOH
103
+ Developer Certificate of Origin
104
+ Version 1.1
105
+
106
+ Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
107
+ 1 Letterman Drive
108
+ Suite D4700
109
+ San Francisco, CA, 94129
110
+
111
+ Everyone is permitted to copy and distribute verbatim copies of this
112
+ license document, but changing it is not allowed.
113
+
114
+
115
+ Developer's Certificate of Origin 1.1
116
+
117
+ By making a contribution to this project, I certify that:
118
+
119
+ (a) The contribution was created in whole or in part by me and I
120
+ have the right to submit it under the open source license
121
+ indicated in the file; or
122
+
123
+ (b) The contribution is based upon previous work that, to the best
124
+ of my knowledge, is covered under an appropriate open source
125
+ license and I have the right under that license to submit that
126
+ work with modifications, whether created in whole or in part
127
+ by me, under the same open source license (unless I am
128
+ permitted to submit under a different license), as indicated
129
+ in the file; or
130
+
131
+ (c) The contribution was provided directly to me by some other
132
+ person who certified (a), (b) or (c) and I have not modified
133
+ it.
134
+
135
+ (d) I understand and agree that this project and the contribution
136
+ are public and that a record of the contribution (including all
137
+ personal information I submit with it, including my sign-off) is
138
+ maintained indefinitely and may be redistributed consistent with
139
+ this project or the open source license(s) involved.
140
+ EOH
141
+
142
+ # Git commit-msg hook script to automatically apply DCO.
143
+ # @api private
144
+ HOOK_SCRIPT = <<-EOH
145
+ #!/bin/sh
146
+ # INSTALLED BY DCO GEM
147
+ export #{ENV.select {|key, value| key =~ /^(bundle_|ruby|gem_)/i }.map {|key, value| "#{key}=#{value.inspect}"}.join(' ')}
148
+ #{Thor::Util.ruby_command} #{File.expand_path('../../../bin/dco', __FILE__)} process_commit_message $1
149
+ exit $?
150
+ EOH
151
+
152
+ # Path to the git hook script.
153
+ # @api private
154
+ HOOK_PATH = '.git/hooks/commit-msg'
155
+
156
+ desc 'enable', 'Enable auto-sign-off for this repository'
157
+ option :yes, aliases: 'y', type: :boolean, desc: 'Agree to all prompts'
158
+ def enable
159
+ assert_repo!
160
+ unless our_hook?
161
+ raise Thor::Error.new('commit-msg hook already exists, not overwriting')
162
+ end
163
+ say("#{DCO_TEXT}\n\n", :yellow)
164
+ unless confirm?("Do you, #{git_identity}, certify that all future commits to this repository will be under the terms of the Developer Certificate of Origin? [yes/no]")
165
+ raise Thor::Error.new('Not enabling auto-sign-off without approval')
166
+ end
167
+ IO.write(HOOK_PATH, HOOK_SCRIPT)
168
+ # 755 is what the defaults from `git init` use so probably good enough.
169
+ File.chmod(00755, HOOK_PATH)
170
+ say('DCO auto-sign-off enabled', :green)
171
+ end
172
+
173
+ desc 'disable', 'Disable auto-sign-off for this repository'
174
+ def disable
175
+ assert_repo!
176
+ unless our_hook?
177
+ raise Thor::Error.new('commit-msg hook is external, not removing')
178
+ end
179
+ if File.exist?(HOOK_PATH)
180
+ File.unlink(HOOK_PATH)
181
+ end
182
+ say('DCO auto-sign-off disabled', :green)
183
+ end
184
+
185
+ desc 'sign', 'Retroactively apply sign-off to the a branch'
186
+ option :base, type: :string, banner: '<branch>', default: 'master', desc: 'Base branch (default: master)'
187
+ option :behalf, aliases: 'b', type: :string, banner: '<url>'
188
+ option :yes, aliases: 'y', type: :boolean
189
+ def sign(branch=nil)
190
+ # What two branches are we using?
191
+ base_branch = options[:base]
192
+ branch ||= current_branch
193
+ if base_branch == branch
194
+ # This should also catch people trying to sign-off on master.
195
+ raise Thor::Error.new("Cannot use #{branch} for both the base and target branch")
196
+ end
197
+
198
+ # First check for a stored ref under refs/original/.
199
+ begin
200
+ repo.show("refs/original/refs/heads/#{branch}")
201
+ # If this doesn't error, a backup ref is present.
202
+ unless confirm?("An existing backup of branch #{branch} is present from a previous filter-branch. Do you want to remove this backup and continue? [yes/no]")
203
+ raise Thor::Error.new('Backup ref present, not continuing')
204
+ end
205
+ # Clear the backup.
206
+ File.unlink(".git/refs/original/refs/heads/#{branch}")
207
+ rescue Git::GitExecuteError
208
+ # This means there was no backup, keep going.
209
+ end
210
+
211
+ # Next examine all the commits we will be touching.
212
+ commits = repo.log.between(base_branch, branch).to_a.select {|commit| !has_sign_off?(commit) }
213
+ if commits.empty?
214
+ raise Thor::Error.new("Branch #{branch} has no commits which require sign-off")
215
+ end
216
+ if !options[:behalf] && commits.any? {|commit| commit.author.email != repo_config['user.email'] }
217
+ raise Thor::Error.new("Branch #{branch} contains commits not authored by you. Please use the --behalf flag when signing off for another contributor")
218
+ end
219
+
220
+ # Display the DCO text.
221
+ say("#{DCO_TEXT}\n\n", :yellow) unless options[:behalf]
222
+
223
+ # Display the list of commits.
224
+ say("Going to sign-off the following commits:")
225
+ commits.each do |commit|
226
+ say("* #{format_commit(commit)}")
227
+ end
228
+
229
+ # Get confirmation.
230
+ confirm_msg = if options[:behalf]
231
+ "Do you, #{git_identity}, certify that these commits are contributed under the terms of the Developer Certificate of Origin as evidenced by #{options[:behalf]}? [yes/no]"
232
+ else
233
+ "Do you, #{git_identity}, certify that these commits are contributed under the terms of the Developer Certificate of Origin? [yes/no]"
234
+ end
235
+ unless confirm?(confirm_msg)
236
+ raise Thor::Error.new('Not signing off on commits without approval')
237
+ end
238
+
239
+ # Stash if needed.
240
+ did_stash = false
241
+ status = repo.status
242
+ unless status.changed.empty? && status.added.empty? && status.deleted.empty?
243
+ say("Stashing uncommited changes before continuing")
244
+ repo.lib.send(:command, 'stash', ['save', 'dco sign temp stash'])
245
+ did_stash = true
246
+ end
247
+
248
+ # Run the filter branch. Here be dragons. Yes, I'm calling a private method. I'm sorry.
249
+ filter_cmd = [Thor::Util.ruby_command, File.expand_path('../../../bin/dco', __FILE__), 'process_commit_message', '--repo', repo.dir.path]
250
+ if options[:behalf]
251
+ filter_cmd << '--behalf'
252
+ filter_cmd << options[:behalf]
253
+ end
254
+ begin
255
+ output = repo.lib.send(:command, 'filter-branch', ['--msg-filter', Shellwords.join(filter_cmd), "#{base_branch}..#{branch}"])
256
+ say(output)
257
+ ensure
258
+ if did_stash
259
+ # If we had a stash, make sure to replay it.
260
+ say("Unstashing previous changes")
261
+ # For whatever reason, the git gem doesn't expose this.
262
+ repo.lib.send(:command, 'stash', ['pop'])
263
+ end
264
+ end
265
+
266
+ # Hopefully that worked.
267
+ say("Sign-off complete", :green)
268
+ say("Don't forget to use --force when pushing this branch to your git server (eg. git push --force origin #{branch})", :green) # TODO I could detect the actual remote for this branch, if any.
269
+ end
270
+
271
+ desc 'check', 'Check if a branch or repository has valid sign-off'
272
+ option :all, type: :boolean, aliases: 'a', desc: 'Check commits, not just a single branch'
273
+ option :base, type: :string, banner: '<branch>', default: 'master', desc: 'Base branch (default: master)'
274
+ option :quiet, type: :boolean, aliases: 'q', desc: 'Quiet output'
275
+ option :allow_author_mismatch, type: :boolean, desc: 'Allow author vs. sign-off mismatch'
276
+ def check(branch=nil)
277
+ branch ||= current_branch
278
+ log = (options[:all] || branch == options[:base]) ? repo.log : repo.log.between(options[:base], branch)
279
+ bad_commits = []
280
+ log.each do |commit|
281
+ sign_off = has_sign_off?(commit)
282
+ if !sign_off
283
+ # No sign-off at all, tsk tsk.
284
+ bad_commits << [commit, :no_sign_off]
285
+ elsif !options[:allow_author_mismatch] && sign_off != "#{commit.author.name} <#{commit.author.email}>"
286
+ # The signer-off and commit author don't match.
287
+ bad_commits << [commit, :author_mismatch]
288
+ end
289
+ end
290
+
291
+ if bad_commits.empty?
292
+ # Yay!
293
+ say("All commits are signed off", :green) unless options[:quiet]
294
+ else
295
+ # Something bad happened.
296
+ unless options[:quiet]
297
+ say("N: No Sign-off M: Author mismatch", :red)
298
+ bad_commits.each do |commit, reason|
299
+ reason_string = {no_sign_off: 'N', author_mismatch: 'M'}[reason]
300
+ say("#{reason_string} #{format_commit(commit)}", :red)
301
+ end
302
+ end
303
+ exit 1
304
+ end
305
+ end
306
+
307
+ private
308
+
309
+ # Modified version of Thor's #yes? to understand -y and non-interactive usage.
310
+ #
311
+ # @api private
312
+ # @param msg [String] Message to show
313
+ # @return [Boolean]
314
+ def confirm?(msg)
315
+ return true if options[:yes]
316
+ unless STDOUT.isatty
317
+ say(msg)
318
+ return false
319
+ end
320
+ yes?(msg)
321
+ end
322
+
323
+ # Check that we are in a git repo that we have write access to.
324
+ #
325
+ # @api private
326
+ # @return [void]
327
+ def assert_repo!
328
+ begin
329
+ # Check if the repo fails to load at all.
330
+ repo
331
+ rescue Exception
332
+ raise Thor::Error.new("#{repo_path} does not appear to be a git repository")
333
+ end
334
+ unless repo.repo.writable?
335
+ raise Thor::Error.new("Git repository at #{repo.repo.path} is read-only")
336
+ end
337
+ end
338
+
339
+ # Create a Git repository object for the current repo.
340
+ #
341
+ # @api private
342
+ # @return [Git::Base]
343
+ def repo
344
+ @repo ||= Git.open(repo_path)
345
+ end
346
+
347
+ # Return and cache the git config for this repo because we use it a lot.
348
+ #
349
+ # @api private
350
+ # @return [Hash]
351
+ def repo_config
352
+ @repo_config ||= repo.config
353
+ end
354
+
355
+ # Get the current branch but raise an error if it looks like we're on a detched head.
356
+ #
357
+ # @api private
358
+ # @return [String]
359
+ def current_branch
360
+ repo.current_branch.tap {|b| raise Thor::Error.new("No explicit branch passed and current head looks detached: #{b}") if b[0] == '(' }
361
+ end
362
+
363
+ # Check if we are in control of the commit-msg hook.
364
+ #
365
+ # @api private
366
+ # @return [Boolean]
367
+ def our_hook?
368
+ !File.exist?(HOOK_PATH) || IO.read(HOOK_PATH).include?('INSTALLED BY DCO GEM')
369
+ end
370
+
371
+ # Find the git identity string for the current user.
372
+ #
373
+ # @api private
374
+ # @return [String]
375
+ def git_identity
376
+ "#{repo_config['user.name']} <#{repo_config['user.email']}>"
377
+ end
378
+
379
+ # Make a one-line version of a commit for use in displays.
380
+ #
381
+ # @api private
382
+ # @param commit [Git::Commit] Commit object to format
383
+ # @return [String]
384
+ def format_commit(commit)
385
+ "#{commit.sha[0..6]} #{commit.author.name} <#{commit.author.email}> #{commit.message.split(/\n/).first}"
386
+ end
387
+
388
+ # Check if a commit or commit message is already signed off.
389
+ #
390
+ # @api private
391
+ # @param commit_or_message [String, Git::Commit] Commit object or message string.
392
+ # @return [String, nil]
393
+ def has_sign_off?(commit_or_message)
394
+ message = commit_or_message.is_a?(String) ? commit_or_message : commit_or_message.message
395
+ if message =~ /^Signed-off-by: (.+)$/
396
+ $1
397
+ else
398
+ nil
399
+ end
400
+ end
401
+ end
402
+ end