defmodule Mix.Tasks.GitOps.MessageHook do
use Mix.Task
@shortdoc "Enables automatic check if git commit message follows Conventional Commits spec"
@commit_msg_hook_name "commit-msg"
@moduledoc """
Installs a Git #{@commit_msg_hook_name} hook to automatically check if the commit message follows
the Conventional Commits spec:
mix git_ops.message_hook
The actual check is done by using the git_ops.check_message mix task:
mix git_ops.check_message /path/to/commit/message/file
It does nothing if a Git #{@commit_msg_hook_name} hook already exists that contains the above
message validation task (unless --force was used).
Logs an error if a Git #{@commit_msg_hook_name} hook exists but it does not call the message
validation task.
## Switches:
* `--force|-f` - Overwrites the Git #{@commit_msg_hook_name} hook if one exists but it does not
call the message validation task.
* `--uninstall` - Uninstalls the git #{@commit_msg_hook_name} hook if it exists.
* `--verbose|-v` - Be more verbose. Pass this option twice to be even more verbose.
"""
alias GitOps.{Config, Git}
@doc false
def run(args) do
{opts, _other_args, _} =
OptionParser.parse(args,
strict: [
force: :boolean,
uninstall: :boolean,
verbose: :count,
commit_msg_hook_path_override: :string
],
aliases: [f: :force, v: :verbose]
)
opts = Keyword.merge([verbose: 0], opts)
if opts[:uninstall] do
uninstall(opts)
else
install(opts)
end
:ok
end
defp install(opts) do
{commit_msg_hook_path, commit_msg_hook_exists} = commit_msg_hook_info!(opts)
template_file_path = template_file_path(opts)
if commit_msg_hook_exists do
normalized_commit_msg_hook =
normalize_script!(
commit_msg_hook_path,
"Git #{@commit_msg_hook_name} hook script (normalized)",
opts
)
normalized_validation_script =
normalize_script!(
template_file_path,
"Conventional Commits message validation script",
opts
)
if normalized_commit_msg_hook =~ normalized_validation_script do
if opts[:verbose] >= 1 do
Mix.shell().info("""
Nothing to do: the #{@commit_msg_hook_name} hook `#{commit_msg_hook_path}` already contains the Conventional Commits message validation.
""")
end
else
if opts[:force] do
Mix.shell().info("""
The #{@commit_msg_hook_name} hook `#{commit_msg_hook_path}` does not call the Conventional Commits message validation task.
""")
if Mix.shell().yes?("Replacing forcefully (the current version will be lost)?") do
install_commit_msg_hook!(template_file_path, commit_msg_hook_path, opts)
end
else
error_exit("""
The #{@commit_msg_hook_name} hook `#{commit_msg_hook_path}` does not call the Conventional Commits message validation task.
Please use --help to check the available options, or manually edit the hook to call the following:
#{normalized_validation_script}
""")
end
end
else
install_commit_msg_hook!(template_file_path, commit_msg_hook_path, opts)
end
:ok
end
defp uninstall(opts) do
{commit_msg_hook_path, commit_msg_hook_exists} = commit_msg_hook_info!(opts)
template_file_path = template_file_path(opts)
if commit_msg_hook_exists do
normalized_commit_msg_hook =
normalize_script!(
commit_msg_hook_path,
"Git #{@commit_msg_hook_name} hook script (normalized)",
opts
)
normalized_validation_script =
normalize_script!(
template_file_path,
"Conventional Commits message validation script",
opts
)
if normalized_commit_msg_hook == normalized_validation_script do
uninstall_commit_msg_hook!(commit_msg_hook_path, opts)
else
error_exit("""
The #{@commit_msg_hook_name} hook `#{commit_msg_hook_path}` will not be deleted because it
is not identical to the version installed by this tool. Please check it manually.
""")
end
else
error_exit("""
The #{@commit_msg_hook_name} hook `#{commit_msg_hook_path}` does not exists. Nothing to uninstall.
""")
end
:ok
end
defp install_commit_msg_hook!(template_file_path, commit_msg_hook_path, _opts) do
Mix.shell().info("""
Installing #{@commit_msg_hook_name} hook from #{template_file_path} to #{commit_msg_hook_path}...
""")
File.cp!(template_file_path, commit_msg_hook_path)
Mix.shell().info("""
done.
""")
end
defp uninstall_commit_msg_hook!(commit_msg_hook_path, _opts) do
Mix.shell().info("""
Uninstalling #{@commit_msg_hook_name} hook from #{commit_msg_hook_path}...
""")
File.rm!(commit_msg_hook_path)
Mix.shell().info("""
done.
""")
end
defp commit_msg_hook_info!(opts) do
commit_msg_hook_path_override = opts[:commit_msg_hook_path_override]
commit_msg_hook_path =
if commit_msg_hook_path_override && is_binary(commit_msg_hook_path_override) do
commit_msg_hook_path_override
else
Config.repository_path()
|> Git.init!()
|> Git.hooks_path()
|> Path.join(@commit_msg_hook_name)
end
commit_msg_hook_exists = File.exists?(commit_msg_hook_path)
if opts[:verbose] >= 2 do
Mix.shell().info("""
Git hooks path: #{commit_msg_hook_path} (#{if commit_msg_hook_exists, do: "existing", else: "not existing"})
""")
end
{commit_msg_hook_path, commit_msg_hook_exists}
end
defp template_file_path(_opts) do
Path.join([:code.priv_dir(:git_ops), "githooks", "#{@commit_msg_hook_name}.template"])
end
defp normalize_script!(script_path, desciption, opts) do
# regex to delete 1) lines starting with # and 2) empty lines
normalize_regex = ~r/(*ANYCRLF)(^#.*\R)|(^\s*\R)/m
normalized_script = Regex.replace(normalize_regex, File.read!(script_path), "")
if opts[:verbose] >= 2 do
Mix.shell().info("""
#{desciption}:
#{normalized_script}
""")
end
normalized_script
end
@spec error_exit(String.t()) :: no_return
defp error_exit(message), do: raise(Mix.Error, message: message)
end