lib/mix/tasks/git_hooks/install.ex

defmodule Mix.Tasks.GitHooks.Install do
  @shortdoc "Installs the configured git hooks backing up the previous files."

  @moduledoc """
  Installs the configured git hooks.

  Before installing the new hooks, the already hooks files are backed up
  with the extension `.pre_git_hooks_backup`.

  ## Command line options
    * `--quiet` - disables the output of the files that are being copied/backed up

  To manually install the git hooks run:

  ```elixir
  mix git_hooks.install`
  ```

  """

  use Mix.Task

  alias GitHooks.Config
  alias GitHooks.Git
  alias GitHooks.Printer

  @impl true
  @spec run(Keyword.t()) :: :ok
  def run(args) do
    {opts, _other_args, _} =
      OptionParser.parse(args, switches: [quiet: :boolean], aliases: [q: :quiet])

    install(opts)
  end

  @spec install(Keyword.t()) :: any()
  defp install(opts) do
    template_file =
      :git_hooks
      |> :code.priv_dir()
      |> Path.join("/hook_template")

    Printer.info("Installing git hooks...")

    mix_path = Config.mix_path()

    ensure_hooks_folder_exists()
    clean_missing_hooks()
    track_configured_hooks()

    Config.git_hooks()
    |> Enum.each(fn git_hook ->
      git_hook_atom_as_string = Atom.to_string(git_hook)
      git_hook_atom_as_kebab_string = Recase.to_kebab(git_hook_atom_as_string)

      case File.read(template_file) do
        {:ok, body} ->
          target_file_path =
            Git.resolve_git_path()
            |> Path.join("/hooks/#{git_hook_atom_as_kebab_string}")

          target_file_body =
            body
            |> String.replace("$git_hook", git_hook_atom_as_string)
            |> String.replace("$mix_path", mix_path)

          unless opts[:quiet] || !Config.verbose?() do
            Printer.info(
              "Writing git hook for `#{git_hook_atom_as_string}` to `#{target_file_path}`"
            )
          end

          backup_current_hook(git_hook_atom_as_kebab_string, opts)

          File.write(target_file_path, target_file_body)
          File.chmod(target_file_path, 0o755)

        {:error, reason} ->
          reason |> inspect() |> Printer.error()
      end
    end)

    :ok
  end

  @spec backup_current_hook(String.t(), Keyword.t()) :: {:error, atom} | {:ok, non_neg_integer()}
  defp backup_current_hook(git_hook_to_backup, opts) do
    source_file_path =
      Git.resolve_git_path()
      |> Path.join("/hooks/#{git_hook_to_backup}")

    target_file_path =
      Git.resolve_git_path()
      |> Path.join("/hooks/#{git_hook_to_backup}.pre_git_hooks_backup")

    unless opts[:quiet] || !Config.verbose?() do
      Printer.info("Backing up git hook file `#{source_file_path}` to `#{target_file_path}`")
    end

    File.copy(source_file_path, target_file_path)
  end

  @spec track_configured_hooks() :: any
  defp track_configured_hooks do
    git_hooks = Config.git_hooks() |> Enum.join(" ")

    Git.resolve_git_path()
    |> Path.join("/hooks/git_hooks.db")
    |> write_backup(git_hooks)
  end

  @spec write_backup(String.t(), String.t()) :: any
  defp write_backup(file_path, git_hooks) do
    file_path
    |> File.open!([:write])
    |> IO.binwrite(git_hooks)
  rescue
    error ->
      Printer.warn(
        "Couldn't find git_hooks.db file, won't be able to restore old backups: #{inspect(error)}"
      )
  end

  @spec ensure_hooks_folder_exists() :: any
  defp ensure_hooks_folder_exists do
    Git.resolve_git_path()
    |> Path.join("/hooks")
    |> File.mkdir_p()
  end

  @spec clean_missing_hooks() :: any
  defp clean_missing_hooks do
    configured_git_hooks =
      Config.git_hooks()
      |> Enum.map(&Atom.to_string/1)

    Git.resolve_git_path()
    |> Path.join("/git_hooks.db")
    |> File.read()
    |> case do
      {:ok, file} ->
        file
        |> String.split(" ")
        |> Enum.each(fn installed_git_hook ->
          if installed_git_hook not in configured_git_hooks do
            git_hook_atom_as_kebab_string = Recase.to_kebab(installed_git_hook)

            Printer.warn(
              "Remove old git hook `#{git_hook_atom_as_kebab_string}` and restore backup"
            )

            Git.resolve_git_path()
            |> Path.join("/hooks/#{git_hook_atom_as_kebab_string}")
            |> File.rm()

            restore_backup(git_hook_atom_as_kebab_string)
          end
        end)

      _error ->
        :ok
    end
  end

  @spec restore_backup(String.t()) :: any
  defp restore_backup(git_hook_atom_as_kebab_string) do
    backup_path =
      Path.join(
        Git.resolve_git_path(),
        "/hooks/#{git_hook_atom_as_kebab_string}.pre_git_hooks_backup"
      )

    restore_path =
      Path.join(
        Git.resolve_git_path(),
        "/#{git_hook_atom_as_kebab_string}"
      )

    case File.rename(backup_path, restore_path) do
      :ok -> :ok
      {:error, reason} -> Printer.warn("Cannot restore backup: #{inspect(reason)}")
    end
  end
end