defmodule Mix.Tasks.Raxol.InstallHooks do @moduledoc """ Install Git hooks for the Raxol pre-commit system. This task installs the pre-commit hook that automatically runs `mix raxol.pre_commit` before each commit. ## Usage mix raxol.install_hooks ## Options * `--force` - Overwrite existing hooks without asking * `--backup` - Create backup of existing hooks * `--check` - Only check if hooks are installed (dry run) ## Examples # Install hooks (interactive if hooks exist) mix raxol.install_hooks # Force overwrite existing hooks mix raxol.install_hooks --force # Create backup of existing hooks mix raxol.install_hooks --backup # Check if hooks are installed mix raxol.install_hooks --check """ use Mix.Task @shortdoc "Install Git pre-commit hooks" @pre_commit_hook """ #!/bin/sh # Raxol pre-commit hook # Generated by mix raxol.install_hooks # Allow bypassing the hook with RAXOL_SKIP_CHECKS environment variable if [ "$RAXOL_SKIP_CHECKS" = "true" ] || [ "$RAXOL_SKIP_CHECKS" = "1" ]; then echo "Skipping Raxol pre-commit checks (RAXOL_SKIP_CHECKS is set)" exit 0 fi # Check if we're in a rebase if [ -d "$(git rev-parse --git-dir)/rebase-merge" ] || [ -d "$(git rev-parse --git-dir)/rebase-apply" ]; then echo "Skipping pre-commit checks during rebase" exit 0 fi # Run the pre-commit checks echo "Running Raxol pre-commit checks..." mix raxol.pre_commit # Capture the exit code EXIT_CODE=$? # Exit with the same code if [ $EXIT_CODE -ne 0 ]; then echo "" echo "Pre-commit checks failed. You can bypass with:" echo " git commit --no-verify" echo " RAXOL_SKIP_CHECKS=true git commit" fi exit $EXIT_CODE """ @impl Mix.Task def run(args) do {opts, _, _} = OptionParser.parse(args, switches: [ force: :boolean, backup: :boolean, check: :boolean ] ) git_dir = get_git_directory() hooks_dir = Path.join(git_dir, "hooks") pre_commit_path = Path.join(hooks_dir, "pre-commit") if opts[:check] do check_hook_status(pre_commit_path) else install_hook(pre_commit_path, opts) end end defp get_git_directory do case System.cmd("git", ["rev-parse", "--git-dir"], stderr_to_stdout: true) do {output, 0} -> String.trim(output) _ -> Mix.raise( "Not in a Git repository. Please run this command from a Git repository." ) end end defp check_hook_status(hook_path) do case File.exists?(hook_path) do true -> case File.read!(hook_path) do content when content == @pre_commit_hook -> IO.puts("✅ Raxol pre-commit hook is installed and up to date") {:ok, :current} content -> case String.contains?(content, "mix raxol.pre_commit") do true -> IO.puts("⚠️ Raxol pre-commit hook is installed but outdated") IO.puts(" Run 'mix raxol.install_hooks' to update") {:ok, :outdated} false -> IO.puts("⚠️ A different pre-commit hook is installed") IO.puts( " Run 'mix raxol.install_hooks --backup' to install Raxol hook" ) {:ok, :different} end end false -> IO.puts("❌ No pre-commit hook installed") IO.puts(" Run 'mix raxol.install_hooks' to install") {:ok, :missing} end end defp install_hook(hook_path, opts) do # Check if hook already exists case File.exists?(hook_path) do true -> handle_existing_hook(hook_path, opts) false -> write_hook(hook_path) end end defp handle_existing_hook(hook_path, opts) do existing_content = File.read!(hook_path) cond do existing_content == @pre_commit_hook -> IO.puts("✅ Raxol pre-commit hook is already installed and up to date") {:ok, :already_installed} opts[:force] -> maybe_backup(hook_path, opts) write_hook(hook_path) String.contains?(existing_content, "mix raxol.pre_commit") -> IO.puts("An older version of the Raxol pre-commit hook is installed.") case prompt_user("Update to the latest version?") do true -> maybe_backup(hook_path, opts) write_hook(hook_path) false -> IO.puts("Hook installation cancelled") {:ok, :cancelled} end true -> IO.puts("A different pre-commit hook already exists.") IO.puts("Content preview:") IO.puts(String.slice(existing_content, 0, 200) <> "...") case prompt_user("Replace with Raxol pre-commit hook?") do true -> maybe_backup(hook_path, opts) write_hook(hook_path) false -> IO.puts("Hook installation cancelled") IO.puts( "You can manually integrate Raxol by adding 'mix raxol.pre_commit' to your existing hook" ) {:ok, :cancelled} end end end defp maybe_backup(hook_path, opts) do case opts[:backup] do true -> backup_path = "#{hook_path}.backup.#{timestamp()}" File.copy!(hook_path, backup_path) IO.puts("📦 Created backup: #{backup_path}") _ -> :ok end end defp write_hook(hook_path) do # Ensure hooks directory exists hook_path |> Path.dirname() |> File.mkdir_p!() # Write the hook file File.write!(hook_path, @pre_commit_hook) # Make it executable case System.cmd("chmod", ["+x", hook_path]) do {_, 0} -> IO.puts("✅ Successfully installed Raxol pre-commit hook") IO.puts(" Location: #{hook_path}") IO.puts("") IO.puts("The hook will run automatically before each commit.") IO.puts("To bypass: git commit --no-verify") {:ok, :installed} _ -> Mix.raise( "Failed to make hook executable. Please run: chmod +x #{hook_path}" ) end end defp prompt_user(question) do case IO.gets("#{question} [y/N]: ") do :eof -> false response when is_binary(response) -> response |> String.trim() |> String.downcase() |> then(&(&1 in ["y", "yes"])) _ -> false end end defp timestamp do {{year, month, day}, {hour, minute, second}} = :calendar.local_time() "#{year}#{pad(month)}#{pad(day)}_#{pad(hour)}#{pad(minute)}#{pad(second)}" end defp pad(num), do: num |> Integer.to_string() |> String.pad_leading(2, "0") end