lib/mix/tasks/git_ops.message_hook.ex

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