From f7ceffcf1dfc0c3c15c861d0cdc02efd9175c52a Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 5 Sep 2026 17:06:08 -0400 Subject: [PATCH 1/3] docs: no not tell it to test the negative --only case --- documents/phase-15-plan.adoc | 4 ---- 1 file changed, 4 deletions(-) diff --git a/documents/phase-15-plan.adoc b/documents/phase-15-plan.adoc index a506aea..b8aed5d 100644 --- a/documents/phase-15-plan.adoc +++ b/documents/phase-15-plan.adoc @@ -184,10 +184,6 @@ preserve Phase 14's required protection behavior. * `mix precommit` needs no network, credentials, Git fetch, container runtime, externally managed database, or other service after dependencies are present. * `mix ci` runs every `mix precommit` check plus documented CI-only checks. -* Tests enforce both command sequences: `precommit` uses - `mix test --exclude ci_only`; `ci` uses unfiltered `mix test`; neither uses - `--only`; and no global `ci_only` exclusion is configured. Future plans may - add real tagged tests to this established boundary. * GitHub Actions calls `mix ci`; Conventional Commit and PR-title validation remain required CI behavior. * Developer documentation describes the two commands without calling the local From 914b47ea7785e61ac8e2e1cfb667dab587562def Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 5 Sep 2026 17:07:09 -0400 Subject: [PATCH 2/3] chore(deps): update ash dependency for hex audit finding --- app/mix.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/mix.lock b/app/mix.lock index 3f6f6db..436fe87 100644 --- a/app/mix.lock +++ b/app/mix.lock @@ -1,5 +1,5 @@ %{ - "ash": {:hex, :ash, "3.32.3", "a6390b9f6497458f4575220cd507b6b5aae52c43bba0cc057aa3fa96e44ef416", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4eebbd3fa7dab05d0aab9c7552155c07e746b18c8dc1cf5ed2f62195cea87f3d"}, + "ash": {:hex, :ash, "3.33.0", "ffbf9c14d00135cd2ae53199a5d00a1a3c456b69c0b74fc53bd2aba771eb879f", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a1b313eefd0e04aa626d4e54313aadec17f3d82422f75d45e66c2736b807bf00"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "burrito": {:hex, :burrito, "1.6.0", "7af0a75f11680e8a6e9c01370c9af51cb9d0e15b3226eddf4f438dbc68570520", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, ">= 0.5.0", [hex: :req, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.2.0 or ~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "e636a00b032c45a69ff755d9fc53fa5fdc9e1d21bdbd229075fe4a15b05355fe"}, "castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"}, @@ -51,7 +51,7 @@ "sbom": {:hex, :sbom, "0.10.0", "b99be5407bc196d0ad71b8061126a67aae46dc3bfaa852b4c1c04645dd1ad984", [:mix], [{:hex_core, "~> 0.15.0", [hex: :hex_core, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:optimus, "~> 0.6.1", [hex: :optimus, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.16.0", [hex: :protobuf, repo: "hexpm", optional: false]}, {:purl, "~> 0.3.0", [hex: :purl, repo: "hexpm", optional: false]}], "hexpm", "a8116ef965c1ebd103e223545794bd0a6691edd3ec678ec07972d473e2badc95"}, "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, "spark": {:hex, :spark, "2.7.2", "36becc6ff03b40908cc821d403d7f06d893498e293d2f718afc6ca097fcb9d93", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "adb323ddbf9dbbe326f9e5def54ac96c47911e852b2c270bb19a5147c56f1b45"}, - "spitfire": {:hex, :spitfire, "0.4.0", "6d98c10cf585434b9439ba0c6dd3cc7aeff0e06ab73bfe5488f42e7c0f883d9b", [:mix], [], "hexpm", "7e5c6d1523c111b59f332f9dc49edc0377111d0c17167a29830f0e98233f5472"}, + "spitfire": {:hex, :spitfire, "0.4.1", "69e90335d00ca328295e1e1e77cac5d7575aa6d34274e3467ebfc654b8858be3", [:mix], [], "hexpm", "27d86f67681179682b15c6758d64ac2eb2b3637ed8340800c8b885c69754cdcd"}, "splode": {:hex, :splode, "0.3.2", "7716b6b2260a98a6f018c65cc0393da2cdf17314202eb351a0956e8762190dff", [:mix], [], "hexpm", "08fd658f80da7f1cd254b149164dcff8acd44b22f032001d5416b97223d32bc9"}, "stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, From 7df3d3bd2a96ea69c55056fd11e0e79343a75e04 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 5 Sep 2026 17:40:57 -0400 Subject: [PATCH 3/3] fix: adds precommit and configures ash --- app/config/config.exs | 3 +- ci/validate_commit_branch.sh | 15 +++++++ documents/quality-gates-decision.adoc | 5 +++ git-hooks/pre-commit | 9 +++++ lib/mix/tasks/git_hooks.ex | 11 ++--- lib/mix/tasks/precommit.ex | 2 +- test/git_hooks_test.exs | 58 +++++++++++++++++++++++++-- 7 files changed, 93 insertions(+), 10 deletions(-) create mode 100755 ci/validate_commit_branch.sh create mode 100755 git-hooks/pre-commit diff --git a/app/config/config.exs b/app/config/config.exs index 5ca90e9..07e3d63 100644 --- a/app/config/config.exs +++ b/app/config/config.exs @@ -54,7 +54,8 @@ config :ash, bulk_actions_default_to_errors?: true, transaction_rollback_on_error?: true, redact_sensitive_values_in_errors?: true, - many_to_many_destroy_destination_on_match?: true + many_to_many_destroy_destination_on_match?: true, + default_string_length_count: :codepoints config :spark, formatter: [ diff --git a/ci/validate_commit_branch.sh b/ci/validate_commit_branch.sh new file mode 100755 index 0000000..9a20d8b --- /dev/null +++ b/ci/validate_commit_branch.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Rejects ordinary commits made directly on main. Called by git-hooks/pre-commit +# so the failure occurs before Git creates the commit object. + +if ! branch=$(git branch --show-current) +then + printf 'ERROR: unable to determine the current Git branch\n' >&2 + exit 1 +fi + +if [ "$branch" = "main" ] +then + printf 'ERROR: committing directly to main is not allowed; create a feature branch instead\n' >&2 + exit 1 +fi diff --git a/documents/quality-gates-decision.adoc b/documents/quality-gates-decision.adoc index 8eec0c6..7663b68 100644 --- a/documents/quality-gates-decision.adoc +++ b/documents/quality-gates-decision.adoc @@ -90,3 +90,8 @@ audits and usage-rule drift), followed by the unfiltered app test suite. That final test run includes both ordinary tests and any future tests tagged `ci_only`; the tag is excluded only by `mix precommit`, never globally. GitHub Actions calls `mix ci`, not `mix precommit`. + +The repository's `pre-commit` hook refuses commits directly on `main` and then +invokes `mix precommit`. The subsequent `commit-msg` hook validates the commit +message after Git writes it; the `pre-push` hook remains the final local guard +for every pushed commit subject and the Hex audit. diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit new file mode 100755 index 0000000..bafd898 --- /dev/null +++ b/git-hooks/pre-commit @@ -0,0 +1,9 @@ +#!/bin/sh +# Rejects direct commits to main, then runs the fast local quality gate. See +# app/usage-rules.md. Activate with: mix git_hooks + +repo_top=$(git rev-parse --show-toplevel) || exit 1 +"$repo_top/ci/validate_commit_branch.sh" || exit $? + +cd "$repo_top" || exit 1 +exec mix precommit diff --git a/lib/mix/tasks/git_hooks.ex b/lib/mix/tasks/git_hooks.ex index ffe0545..8a1e3d8 100644 --- a/lib/mix/tasks/git_hooks.ex +++ b/lib/mix/tasks/git_hooks.ex @@ -1,15 +1,16 @@ defmodule Mix.Tasks.GitHooks do - @shortdoc "Installs this repo's commit-msg and pre-push quality hooks" + @shortdoc "Installs this repo's pre-commit, commit-msg, and pre-push hooks" @moduledoc """ #{@shortdoc}. mix git_hooks - Sets `core.hooksPath` to `git-hooks/`: its `commit-msg` hook enforces - Conventional Commits on each commit subject, and its `pre-push` hook - validates all commit subjects introduced by the push before running Hex's - dependency security audit. + Sets `core.hooksPath` to `git-hooks/`: its `pre-commit` hook prevents direct + commits to `main` and runs `mix precommit`; its `commit-msg` hook enforces + Conventional Commits on each commit subject; and its `pre-push` hook validates + all commit subjects introduced by the push before running Hex's dependency + security audit. This is idempotent and safe to run repeatedly: setting the same Git config value twice is a no-op. Wired into `mix setup` - see that task. """ diff --git a/lib/mix/tasks/precommit.ex b/lib/mix/tasks/precommit.ex index 18b8418..d71d19a 100644 --- a/lib/mix/tasks/precommit.ex +++ b/lib/mix/tasks/precommit.ex @@ -7,7 +7,7 @@ defmodule Mix.Tasks.Precommit do mix precommit A fast, self-contained command designed for frequent developer use: between - edits, before committing, and as the pre-push hook target. On a warm checkout + edits and before committing. The repository's pre-commit hook invokes it. On a warm checkout with dependencies already installed, it completes in under five seconds. It requires no network access, credentials, containers, or external services. diff --git a/test/git_hooks_test.exs b/test/git_hooks_test.exs index 676f5be..fe33519 100644 --- a/test/git_hooks_test.exs +++ b/test/git_hooks_test.exs @@ -2,6 +2,7 @@ defmodule GitHooksTest do use ExUnit.Case, async: true @subject_guard Path.expand("../ci/validate_conventional_subject.sh", __DIR__) + @branch_guard Path.expand("../ci/validate_commit_branch.sh", __DIR__) @title_guard Path.expand("../ci/validate_pull_request_title.sh", __DIR__) @range_guard Path.expand("../ci/validate_commit_range.sh", __DIR__) @push_refs_guard Path.expand("../ci/validate_push_refs.sh", __DIR__) @@ -246,10 +247,42 @@ defmodule GitHooksTest do assert {"", 0} = run_with_stdin(@push_refs_guard, stdin, cd: worktree) end - test "git-hooks/ directory contains only the commit-msg and pre-push adapters" do + test "git-hooks/ directory contains the pre-commit, commit-msg, and pre-push adapters" do hooks_dir = Path.expand("../git-hooks", __DIR__) entries = File.ls!(hooks_dir) |> Enum.sort() - assert entries == ["commit-msg", "pre-push"] + assert entries == ["commit-msg", "pre-commit", "pre-push"] + end + + test "the pre-commit adapter rejects main before running the quality gate" do + {worktree, ci_dir} = setup_ci_worktree!() + marker = Path.join(worktree, "precommit-ran") + hook = install_pre_commit_hook!(worktree, ci_dir) + fake_bin = install_fake_mix!(worktree) + + assert {output, 1} = + run(hook, [], + cd: worktree, + env: [{"MIX_MARKER", marker}, {"PATH", fake_bin <> ":" <> System.get_env("PATH")}] + ) + + assert output =~ "committing directly to main is not allowed" + refute File.exists?(marker) + end + + test "the pre-commit adapter runs mix precommit on a feature branch" do + {worktree, ci_dir} = setup_ci_worktree!() + marker = Path.join(worktree, "precommit-ran") + hook = install_pre_commit_hook!(worktree, ci_dir) + fake_bin = install_fake_mix!(worktree) + git!(worktree, ["checkout", "-b", "feature"]) + + assert {"", 0} = + run(hook, [], + cd: worktree, + env: [{"MIX_MARKER", marker}, {"PATH", fake_bin <> ":" <> System.get_env("PATH")}] + ) + + assert File.read!(marker) == "precommit\n" end test "the pre-push adapter validates refs before running the Hex audit" do @@ -343,7 +376,7 @@ defmodule GitHooksTest do ci_dir = Path.join(worktree, "ci") File.mkdir_p!(ci_dir) - for guard <- [@subject_guard, @range_guard, @push_refs_guard] do + for guard <- [@subject_guard, @branch_guard, @range_guard, @push_refs_guard] do destination = Path.join(ci_dir, Path.basename(guard)) File.cp!(guard, destination) File.chmod!(destination, 0o755) @@ -352,6 +385,25 @@ defmodule GitHooksTest do {worktree, ci_dir} end + defp install_pre_commit_hook!(worktree, _ci_dir) do + hooks_dir = Path.join(worktree, "git-hooks") + File.mkdir_p!(hooks_dir) + + hook = Path.join(hooks_dir, "pre-commit") + File.cp!(Path.expand("../git-hooks/pre-commit", __DIR__), hook) + File.chmod!(hook, 0o755) + hook + end + + defp install_fake_mix!(worktree) do + fake_bin = Path.join(worktree, "fake-bin") + File.mkdir!(fake_bin) + fake_mix = Path.join(fake_bin, "mix") + File.write!(fake_mix, "#!/bin/sh\nprintf 'precommit\\n' > \"$MIX_MARKER\"\n") + File.chmod!(fake_mix, 0o755) + fake_bin + end + defp install_pre_push_hook!(worktree, ci_dir) do hooks_dir = Path.join(worktree, "git-hooks") File.mkdir_p!(hooks_dir)