diff --git a/config/default.yml b/config/default.yml index bfc73020..6d86e30e 100644 --- a/config/default.yml +++ b/config/default.yml @@ -1143,6 +1143,20 @@ PostRewrite: - 'package.json' - 'yarn.lock' +# Hooks that run during the `prepare-commit-msg` hook. +PrepareCommitMsg: + ALL: + requires_files: false + required: false + quiet: false + + ReplaceBranch: + enabled: false + description: 'Prepends the commit message with text based on the branch name' + branch_pattern: '\A.*\w+[-_](\d+).*\z' + replacement_text: '[#\1]' + on_fail: warn + # Hooks that run during `git push`, after remote refs have been updated but # before any objects have been transferred. PrePush: diff --git a/lib/overcommit/hook/prepare_commit_msg/base.rb b/lib/overcommit/hook/prepare_commit_msg/base.rb new file mode 100644 index 00000000..c278b5a6 --- /dev/null +++ b/lib/overcommit/hook/prepare_commit_msg/base.rb @@ -0,0 +1,23 @@ +require 'forwardable' + +module Overcommit::Hook::PrepareCommitMsg + # Functionality common to all prepare-commit-msg hooks. + class Base < Overcommit::Hook::Base + extend Forwardable + + def_delegators :@context, + :commit_message_filename, :commit_message_source, :commit, :lock + + def modify_commit_message + raise 'This expects a block!' unless block_given? + # NOTE: this assumes all the hooks of the same type share the context's + # memory. If that's not the case, this won't work. + lock.synchronize do + contents = File.read(commit_message_filename) + File.open(commit_message_filename, 'w') do |f| + f << (yield contents) + end + end + end + end +end diff --git a/lib/overcommit/hook/prepare_commit_msg/replace_branch.rb b/lib/overcommit/hook/prepare_commit_msg/replace_branch.rb new file mode 100644 index 00000000..8a5c1f82 --- /dev/null +++ b/lib/overcommit/hook/prepare_commit_msg/replace_branch.rb @@ -0,0 +1,50 @@ +module Overcommit::Hook::PrepareCommitMsg + # Prepends the commit message with a message based on the branch name. + # It's possible to reference parts of the branch name through the captures in + # the `branch_pattern` regex. + class ReplaceBranch < Base + def run + return :pass unless !commit_message_source || + commit_message_source == :commit # NOTE: avoid 'merge' and 'rebase' + Overcommit::Utils.log.debug( + "Checking if '#{Overcommit::GitRepo.current_branch}' matches #{branch_pattern}" + ) + if branch_pattern.match(Overcommit::GitRepo.current_branch) + Overcommit::Utils.log.debug("Writing #{commit_message_filename} with #{new_template}") + modify_commit_message do |old_contents| + "#{new_template}\n#{old_contents}" + end + :pass + else + :warn + end + end + + def new_template + @new_template ||= Overcommit::GitRepo.current_branch.gsub(branch_pattern, replacement_text) + end + + def branch_pattern + @branch_pattern ||= + begin + pattern = config['branch_pattern'] + Regexp.new((pattern || '').empty? ? '\A.*\w+[-_](\d+).*\z' : pattern) + end + end + + def replacement_text + @replacement_text ||= + begin + if File.exist?(replacement_text_config) + File.read(replacement_text_config) + else + replacement_text_config + end + end + end + + def replacement_text_config + @replacement_text_config ||= config['replacement_text'] + end + end +end diff --git a/lib/overcommit/hook_context/prepare_commit_msg.rb b/lib/overcommit/hook_context/prepare_commit_msg.rb new file mode 100644 index 00000000..d5bbf993 --- /dev/null +++ b/lib/overcommit/hook_context/prepare_commit_msg.rb @@ -0,0 +1,32 @@ +module Overcommit::HookContext + # Contains helpers related to contextual information used by prepare-commit-msg + # hooks. + class PrepareCommitMsg < Base + # Returns the name of the file that contains the commit log message + def commit_message_filename + @args[0] + end + + # Returns the source of the commit message, and can be: message (if a -m or + # -F option was given); template (if a -t option was given or the + # configuration option commit.template is set); merge (if the commit is a + # merge or a .git/MERGE_MSG file exists); squash (if a .git/SQUASH_MSG file + # exists); or commit, followed by a commit SHA-1 (if a -c, -C or --amend + # option was given) + def commit_message_source + @args[1].to_sym if @args[1] + end + + # Returns the commit's SHA-1. + # If commit_message_source is :commit, it's passed through the command-line. + def commit_message_source_ref + @args[2] || `git rev-parse HEAD` + end + + # Lock for the pre_commit_message file. Should be shared by all + # prepare-commit-message hooks + def lock + @lock ||= Monitor.new + end + end +end diff --git a/spec/overcommit/hook/prepare_commit_msg/base_spec.rb b/spec/overcommit/hook/prepare_commit_msg/base_spec.rb new file mode 100644 index 00000000..fffa2a43 --- /dev/null +++ b/spec/overcommit/hook/prepare_commit_msg/base_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' +require 'overcommit/hook_context/prepare_commit_msg' + +describe Overcommit::Hook::PrepareCommitMsg::Base do + let(:config) { Overcommit::ConfigurationLoader.default_configuration } + let(:context) { Overcommit::HookContext::PrepareCommitMsg.new(config, [], StringIO.new) } + let(:printer) { double('printer') } + + context 'when multiple hooks run simultaneously' do + let(:hook_1) { described_class.new(config, context) } + let(:hook_2) { described_class.new(config, context) } + + let(:tempfile) { 'test-prepare-commit-msg.txt' } + + let(:initial_content) { "This is a test\n" } + + before do + File.open(tempfile, 'w') do |f| + f << initial_content + end + end + + after do + File.delete(tempfile) + end + + it 'works well with concurrency' do + allow(context).to receive(:commit_message_filename).and_return(tempfile) + allow(hook_1).to receive(:run) do + hook_1.modify_commit_message do |contents| + "alpha\n" + contents + end + end + allow(hook_2).to receive(:run) do + hook_2.modify_commit_message do |contents| + contents + "bravo\n" + end + end + Thread.new { hook_1.run } + Thread.new { hook_2.run } + Thread.list.each { |t| t.join unless t == Thread.current } + expect(File.read(tempfile)).to match(/alpha\n#{initial_content}bravo\n/m) + end + end +end diff --git a/spec/overcommit/hook/prepare_commit_msg/replace_branch_spec.rb b/spec/overcommit/hook/prepare_commit_msg/replace_branch_spec.rb new file mode 100644 index 00000000..ad3fd2d0 --- /dev/null +++ b/spec/overcommit/hook/prepare_commit_msg/replace_branch_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' +require 'overcommit/hook_context/prepare_commit_msg' + +describe Overcommit::Hook::PrepareCommitMsg::ReplaceBranch do + let(:config) { Overcommit::ConfigurationLoader.default_configuration } + let(:context) do + Overcommit::HookContext::PrepareCommitMsg.new( + config, [prepare_commit_message_file, 'commit'], StringIO.new + ) + end + + let(:prepare_commit_message_file) { 'prepare_commit_message_file.txt' } + + subject(:hook) { described_class.new(config, context) } + + before do + File.open(prepare_commit_message_file, 'w') + allow(Overcommit::Utils).to receive_message_chain(:log, :debug) + allow(Overcommit::GitRepo).to receive(:current_branch).and_return(new_head) + end + + after do + File.delete(prepare_commit_message_file) unless ENV['APPVEYOR'] + end + + let(:new_head) { 'userbeforeid-12345-branch-description' } + + describe '#run' do + context 'when the checked out branch matches the pattern' do + it { is_expected.to pass } + + context 'template contents' do + subject(:template) { hook.new_template } + + before do + hook.stub(:replacement_text).and_return('Id is: \1') + end + + it { is_expected.to eq('Id is: 12345') } + end + end + + context 'when the checked out branch does not match the pattern' do + let(:new_head) { "this shouldn't match the default pattern" } + + it { is_expected.to warn } + end + end + + describe '#replacement_text' do + subject(:replacement_text) { hook.replacement_text } + let(:replacement_template_file) { 'valid_filename.txt' } + let(:replacement) { 'Id is: \1' } + + context 'when the replacement text points to a valid filename' do + before do + hook.stub(:replacement_text_config).and_return(replacement_template_file) + File.stub(:exist?).and_return(true) + File.stub(:read).with(replacement_template_file).and_return(replacement) + end + + describe 'it reads it as the replacement template' do + it { is_expected.to eq(replacement) } + end + end + end +end diff --git a/spec/overcommit/hook_context/prepare_commit_msg_spec.rb b/spec/overcommit/hook_context/prepare_commit_msg_spec.rb new file mode 100644 index 00000000..9371fe6c --- /dev/null +++ b/spec/overcommit/hook_context/prepare_commit_msg_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' +require 'overcommit/hook_context/prepare_commit_msg' + +describe Overcommit::HookContext::PrepareCommitMsg do + let(:config) { double('config') } + let(:args) { [commit_message_filename, commit_message_source] } + let(:commit_message_filename) { 'message-template.txt' } + let(:commit_message_source) { :file } + let(:commit) { 'SHA-1 here' } + let(:input) { double('input') } + let(:context) { described_class.new(config, args, input) } + + describe '#commit_message_filename' do + subject { context.commit_message_filename } + + it { should == commit_message_filename } + end + + describe '#commit_message_source' do + subject { context.commit_message_source } + + it { should == commit_message_source } + end +end diff --git a/spec/overcommit/utils_spec.rb b/spec/overcommit/utils_spec.rb index 4a39d1a9..76272cdb 100644 --- a/spec/overcommit/utils_spec.rb +++ b/spec/overcommit/utils_spec.rb @@ -118,7 +118,7 @@ subject { described_class.supported_hook_types } # rubocop:disable Metrics/LineLength - it { should =~ %w[commit-msg pre-commit post-checkout post-commit post-merge post-rewrite pre-push pre-rebase] } + it { should =~ %w[commit-msg pre-commit post-checkout post-commit post-merge post-rewrite pre-push pre-rebase prepare-commit-msg] } # rubocop:enable Metrics/LineLength end @@ -126,7 +126,7 @@ subject { described_class.supported_hook_type_classes } # rubocop:disable Metrics/LineLength - it { should =~ %w[CommitMsg PreCommit PostCheckout PostCommit PostMerge PostRewrite PrePush PreRebase] } + it { should =~ %w[CommitMsg PreCommit PostCheckout PostCommit PostMerge PostRewrite PrePush PreRebase PrepareCommitMsg] } # rubocop:enable Metrics/LineLength end diff --git a/template-dir/hooks/prepare-commit-msg b/template-dir/hooks/prepare-commit-msg new file mode 100755 index 00000000..b345d9c7 --- /dev/null +++ b/template-dir/hooks/prepare-commit-msg @@ -0,0 +1,115 @@ +#!/usr/bin/env ruby + +# Entrypoint for Overcommit hook integration. Installing Overcommit will result +# in all of your git hooks being copied from this file, allowing the framework +# to manage your hooks for you. + +# Prevent a Ruby stack trace from appearing when we interrupt the hook. +# Note that this will be overridden when Overcommit is loaded, since the +# InterruptHandler will redefine the trap at that time. +Signal.trap('INT') do + puts 'Hook run interrupted' + exit 130 +end + +# Allow hooks to be disabled via environment variable so git commands can be run +# in scripts without Overcommit running hooks +if ENV['OVERCOMMIT_DISABLE'].to_i != 0 || ENV['OVERCOMMIT_DISABLED'].to_i != 0 + exit +end + +hook_type = File.basename($0) +if hook_type == 'overcommit-hook' + puts "Don't run `overcommit-hook` directly; it is intended to be symlinked " \ + "by each hook in a repository's .git/hooks directory." + exit 64 # EX_USAGE +end + +# Check if Overcommit should invoke a Bundler context for loading gems +require 'yaml' +# rubocop:disable Style/RescueModifier +if gemfile = YAML.load_file('.overcommit.yml')['gemfile'] rescue nil + ENV['BUNDLE_GEMFILE'] = gemfile + require 'bundler' + + begin + Bundler.setup + rescue Bundler::BundlerError => ex + puts "Problem loading '#{gemfile}': #{ex.message}" + puts "Try running:\nbundle install --gemfile=#{gemfile}" if ex.is_a?(Bundler::GemNotFound) + exit 78 # EX_CONFIG + end +end +# rubocop:enable Style/RescueModifier + +begin + require 'overcommit' +rescue LoadError + if gemfile + puts 'You have specified the `gemfile` option in your Overcommit ' \ + 'configuration but have not added the `overcommit` gem to ' \ + "#{gemfile}." + else + puts 'This repository contains hooks installed by Overcommit, but the ' \ + "`overcommit` gem is not installed.\n" \ + 'Install it with `gem install overcommit`.' + end + + exit 64 # EX_USAGE +end + +begin + logger = Overcommit::Logger.new(STDOUT) + Overcommit::Utils.log = logger + + # Ensure master hook is up-to-date + installer = Overcommit::Installer.new(logger) + if installer.run(Overcommit::Utils.repo_root, action: :update) + exec($0, *ARGV) # Execute the updated hook with all original arguments + end + + config = Overcommit::ConfigurationLoader.new(logger).load_repo_config + + context = Overcommit::HookContext.create(hook_type, config, ARGV, STDIN) + config.apply_environment!(context, ENV) + + printer = Overcommit::Printer.new(config, logger, context) + runner = Overcommit::HookRunner.new(config, logger, context, printer) + + status = runner.run + + exit(status ? 0 : 65) # 65 = EX_DATAERR +rescue Overcommit::Exceptions::ConfigurationError => error + puts error + exit 78 # EX_CONFIG +rescue Overcommit::Exceptions::HookContextLoadError => error + puts error + puts 'Are you running an old version of Overcommit?' + exit 69 # EX_UNAVAILABLE +rescue Overcommit::Exceptions::HookLoadError, + Overcommit::Exceptions::InvalidHookDefinition => error + puts error.message + puts error.backtrace + exit 78 # EX_CONFIG +rescue Overcommit::Exceptions::HookSetupFailed, + Overcommit::Exceptions::HookCleanupFailed => error + puts error.message + exit 74 # EX_IOERR +rescue Overcommit::Exceptions::HookCancelled + puts 'You cancelled the hook run' + exit 130 # Ctrl-C cancel +rescue Overcommit::Exceptions::InvalidGitRepo => error + puts error + exit 64 # EX_USAGE +rescue Overcommit::Exceptions::ConfigurationSignatureChanged => error + puts error + puts "For more information, see #{Overcommit::REPO_URL}#security" + exit 1 +rescue Overcommit::Exceptions::InvalidHookSignature + exit 1 +rescue StandardError => error + puts error.message + puts error.backtrace + puts "Report this bug at #{Overcommit::BUG_REPORT_URL}" + exit 70 # EX_SOFTWARE +end