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.
- checksums.yaml +7 -0
- data/.gitignore +6 -0
- data/.travis.yml +15 -0
- data/.yardopts +3 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +19 -0
- data/LICENSE +202 -0
- data/README.md +88 -0
- data/Rakefile +32 -0
- data/appveyor.yml +25 -0
- data/bin/dco +25 -0
- data/dco.gemspec +47 -0
- data/gemfiles/master.gemfile +21 -0
- data/gemfiles/release.gemfile +17 -0
- data/lib/dco.rb +24 -0
- data/lib/dco/cli.rb +402 -0
- data/lib/dco/version.rb +21 -0
- data/spec/baseline_spec.rb +34 -0
- data/spec/check_spec.rb +172 -0
- data/spec/disable_spec.rb +61 -0
- data/spec/enable_spec.rb +104 -0
- data/spec/process_commit_message_spec.rb +132 -0
- data/spec/sign_spec.rb +230 -0
- data/spec/spec_helper.rb +91 -0
- metadata +221 -0
@@ -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__)
|
data/lib/dco.rb
ADDED
@@ -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
|
data/lib/dco/cli.rb
ADDED
@@ -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
|