defmodule Mix.Tasks.Commit do @moduledoc """ This will create a REPL that allows the user to create a commit message following specifications defined by [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) when the user requests `mix commit`. """ @shortdoc "creates a conventional commit" use Mix.Task @types ~w(fix feat build chore ci docs style refactor perf test)a @aliases [ t: :type, f: :footer, b: :body, h: :help, B: :breaking ] @switches [ type: :string, footer: :boolean, body: :boolean, help: :boolean, breaking: :boolean ] @impl Mix.Task def run(args) do case parse_args(args) do {:ok, :help} -> help_docs() |> IO.puts() {:ok, opts} -> opts |> build_repl_args() |> Utils.Cli.run_repl() |> make_commit() {:error, reason} -> print_error(reason) end end defp parse_args(argv) do {opts, _args, invalid} = OptionParser.parse(argv, strict: @switches, aliases: @aliases) cond do invalid != [] -> {:error, "Invalid options: #{inspect(invalid)}"} Keyword.has_key?(opts, :help) == true -> {:ok, :help} !Keyword.has_key?(opts, :type) -> {:error, "Type of commit flag (-t or --type) required. "} Keyword.has_key?(opts, :type) && !valid_commit_types?(opts) -> {:error, "Unknown commit type. Allowed types are: [fix, feat, build, chore, ci, docs, style, refactor, perf, test]"} Keyword.has_key?(opts, :type) && valid_commit_types?(opts) -> {:ok, opts} true -> {:error, "Unknown error occured"} end end defp print_error(error) do error = IO.ANSI.red() <> "USAGE ERROR: " <> error <> IO.ANSI.reset() IO.puts(error) end defp build_repl_args(opts) do config = %{ type: Keyword.get(opts, :type), body: Keyword.get(opts, :body), footer: Keyword.get(opts, :footer), breaking: Keyword.get(opts, :breaking) } user_config = if File.exists?(Path.join(File.cwd!(), ".commit.exs")) do {user_config, _} = Code.eval_file(".commit.exs") Enum.map(user_config, fn {k, v} -> Map.put(%{}, k, v) end) |> Enum.reduce(fn elem, acc -> Map.merge(elem, acc) end) else %{} end Map.merge(config, user_config) end defp valid_commit_types?(opts) do type_value = Keyword.get(opts, :type) |> String.to_atom() Enum.member?(@types, type_value) end defp help_docs do """ NAME `mix commit` - Mix task allowing for conventional commits SYNOPSIS `mix commit [OPTIONS]` DESCRIPTION Opens a REPL allowing users to interactively write commit messages that conform to conventional commits as specified here: https://www.conventionalcommits.org/en/v1.0.0/ OPTIONS -h | --help : Opens this message -t | --type : (required) Accepts a string that defines the type of commit message. Allowed options are: \n\t#{inspect(Enum.map(@types, &Atom.to_string(&1)))}\n -f | --footer : A boolean indicating whether the commit has a footer. Can also be specified in `.commit.exs` -n | --no-body : A boolean indicating whether to include a body to the commit message. Default is false meaning that the commit will require a body. Can also be specified in `.commit.exs` USER DEFINED OPTIONS You can define a `.commit.exs` file which returns ``` [ required: [], # specifies the parts of a commit that must be included. Valid options are [:scope, :body, :footer] footer_fields: [:author, :reviewed_by, :refs] # specifies the required footer fields. ] ``` """ end defp make_commit(state) do current_commit = format_commit(state) IO.puts("\nThis is your current commit:\n") IO.puts(colorize(current_commit)) case confirm?("\nDo you want to commit? (y/yes/n/no): ") do true -> Utils.Git.commit(current_commit) false -> IO.puts("Commit aborted.") :ok end end defp format_commit(state) do """ #{state.type}#{if state.scope, do: "(#{state.scope})", else: ""}#{if state.breaking, do: "!", else: ""}: #{state.description} #{state.body} #{state.footer} """ end defp colorize(prompt) do IO.ANSI.light_green() <> prompt <> IO.ANSI.reset() end defp confirm?(prompt) do prompt |> IO.gets() |> normalize_input() |> case do "y" -> true "yes" -> true "n" -> false "no" -> false _ -> IO.puts("Invalid input. Please enter y/yes/n/no.") confirm?(prompt) end end defp normalize_input(nil), do: "\n" defp normalize_input(input) do input |> String.trim() |> String.downcase() end end