diff --git a/AGENTS.md b/AGENTS.md index 64a778c..e5f24ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,9 @@ and best practices for agents to follow. ## Standards -- Conventional Commits: app/usage-rules.md — enforced by the `commit-msg` hook - at `githooks/commit-msg` (run `git config core.hooksPath githooks` once per - clone to activate it). +- Conventional Commits: app/usage-rules.md — enforced by the `commit-msg` + and `pre-push` hooks at `githooks/` (run `mix setup` once per clone to + activate them). diff --git a/Readme.adoc b/Readme.adoc index 359d6e1..7690ebc 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -331,12 +331,11 @@ $ lproj list --mine == Development First, activate the repo's git hooks (enforces conventional-commit subjects -before you even push): +on every commit, and again on every commit about to be pushed): [source,sh] ---- -$ cd app -$ mise exec -- mix githooks.install +$ mix setup ---- The project uses ExUnit and `mix format`. Run tests with: diff --git a/app/lib/mix/tasks/githooks.install.ex b/app/lib/mix/tasks/githooks.install.ex deleted file mode 100644 index 463617f..0000000 --- a/app/lib/mix/tasks/githooks.install.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule Mix.Tasks.Githooks.Install do - @moduledoc "Activates this repo's githooks/ (conventional-commit enforcement on commit-msg)." - @shortdoc "Activates the repo's git hooks" - - use Mix.Task - - @impl Mix.Task - def run(_args) do - with {root, 0} <- System.cmd("git", ["rev-parse", "--show-toplevel"], stderr_to_stdout: true), - root = String.trim(root), - {_output, 0} <- - System.cmd("git", ["config", "core.hooksPath", "githooks"], - cd: root, - stderr_to_stdout: true - ) do - Mix.shell().info("githooks activated (core.hooksPath = githooks)") - else - {output, _status} -> Mix.raise("Failed to activate githooks: #{String.trim(output)}") - end - end -end diff --git a/app/usage-rules.md b/app/usage-rules.md index 7f1c4e3..17ea9a2 100644 --- a/app/usage-rules.md +++ b/app/usage-rules.md @@ -7,9 +7,12 @@ - Use the imperative, present tense in the description (`add`, not `added`/`adds`). - Mark breaking changes with `!` before the colon (e.g. `feat!: ...`). - Bare `Merge branch ...` subjects are rejected — reword as `chore: Merge branch ...`. -- Enforced locally by the `commit-msg` hook at `githooks/commit-msg`, which - delegates to `ci/validate_conventional_commit.sh` — run - `git config core.hooksPath githooks` once per clone to activate it. -- Enforced in CI across a whole PR's commit range by `ci/conventional_commits.sh` - (same validator, run per-commit; skips GitHub's own auto-generated - update-branch merge commits). +- Enforced locally by the `commit-msg` hook at `githooks/commit-msg` (each + commit's own subject, via `ci/validate_conventional_commit.sh`) and the + `pre-push` hook at `githooks/pre-push` (every commit about to be pushed, + via `ci/conventional_commits.sh` - catches anything that slipped past + `commit-msg`, e.g. a commit made before the hooks were installed) — run + `mix setup` once per clone to activate both. +- Enforced in CI across a whole PR's commit range by the same + `ci/conventional_commits.sh` the `pre-push` hook uses (skips GitHub's own + auto-generated update-branch merge commits). diff --git a/githooks/pre-push b/githooks/pre-push new file mode 100755 index 0000000..f8828bc --- /dev/null +++ b/githooks/pre-push @@ -0,0 +1,9 @@ +#!/bin/sh +# Enforces Conventional Commits on every commit about to be pushed, not +# just whatever commit-msg already checked at commit time (catches a +# commit made before this hook was installed, an amend, a rebase, etc.). +# See app/usage-rules.md for the rule. Activate with: +# git config core.hooksPath githooks + +repo_top=$(git rev-parse --show-toplevel) || exit 1 +exec "$repo_top/ci/conventional_commits.sh" diff --git a/lib/mix/tasks/git_hooks.ex b/lib/mix/tasks/git_hooks.ex new file mode 100644 index 0000000..6dad2fc --- /dev/null +++ b/lib/mix/tasks/git_hooks.ex @@ -0,0 +1,28 @@ +defmodule Mix.Tasks.GitHooks do + @shortdoc "Installs this repo's git hooks (commit-msg, pre-push - Conventional Commits)" + + @moduledoc """ + #{@shortdoc}. + + mix git_hooks + + Sets `core.hooksPath` to `githooks/` (this repo's own `commit-msg` and + `pre-push` hooks, both enforcing Conventional Commits via + `ci/validate_conventional_commit.sh`/`ci/conventional_commits.sh`) - + the same one-line `git config` this repo's docs already told you to run + by hand, just idempotent and easy to re-run. Safe to run repeatedly: + setting the same git config value twice is a no-op. Wired into + `mix setup` - see that task. + """ + + use Mix.Task + + alias RepoTasks.Shell + + @impl Mix.Task + def run(_argv) do + Shell.run!("git", ["config", "core.hooksPath", "githooks"]) + Mix.shell().info("==> Git hooks installed (core.hooksPath = githooks)") + :ok + end +end diff --git a/lib/mix/tasks/setup.ex b/lib/mix/tasks/setup.ex new file mode 100644 index 0000000..1c2b7d5 --- /dev/null +++ b/lib/mix/tasks/setup.ex @@ -0,0 +1,21 @@ +defmodule Mix.Tasks.Setup do + @shortdoc "Sets up this repo for development" + + @moduledoc """ + #{@shortdoc}. + + mix setup + + Runs every one-time/idempotent setup step this repo needs. Currently + just `mix git_hooks` - expected to grow (e.g. app/'s own `mix deps.get`) + as more repo-management tasks land here. + """ + + use Mix.Task + + @impl Mix.Task + def run(_argv) do + Mix.Task.run("git_hooks") + :ok + end +end