0.2.2 - 2026-10-08

Added

  • Message.autosquash/1: :fixup, :squash or :amend for a subject that starts with git's fixup!, squash! or amend! autosquash prefix, nil otherwise.
  • Rules take on_autosquash: :check to run on autosquash commits, which every other rule now skips.

Changed

  • Every rule but Rules.Message :no_fixup skips a fixup!, squash! or amend! commit, which git rebase --autosquash folds into an earlier commit, so its own message and diff never land. The commit-msg hook used to refuse git commit --fixup under any subject: pattern, since fixup! [tag] text does not start with [tag]. The report opens with fixup! commit — rules skipped until it is squashed into its target. :no_fixup still fails such a commit on --head, --rev and --range, so CI refuses a branch that was never squashed.

0.2.1 - 2026-10-06

Added

  • MixFile.elixirc_paths/2: the paths a project compiles Elixir from in the :prod environment, the one it is compiled in as a dependency, read from mix.exs without evaluating it. It follows the usual elixirc_paths(Mix.env()) helper, so for a project whose mix.exs has defp elixirc_paths(:test), do: ["lib", "test/support"] and defp elixirc_paths(_), do: ["lib"] it returns ["lib"]. An umbrella root gets []; a value it cannot read gets Mix's default, ["lib"].
  • MixFile.shipped_source/1: a path pattern for the .ex files every project in the tree ships, joined to each project's directory (Paths.lib_source/0, the .ex files under any lib/, when the tree has no mix.exs).
  • The Elixir-source queries (Query.modules_added/2, modules_removed/2, module_facts_added/2, functions_added/2, functions_removed/2, function_changes/2, behaviour_changed?/2) accept :shipped as their pattern: the files MixFile.shipped_source/1 matches, read from the tree before the change for removals and from the tree after it for everything else.

Fixed

  • Rules.Changelog :api_changes_logged counted a public function in any changed .ex or .exs file as public API, test support and fixtures included. In a project whose mix.exs compiles test/support only under MIX_ENV=test, adding def calls(module) to MyApp.Test.CallCount in test/support/call_count.ex warned MyApp.Test.CallCount.calls/1 added until the changelog changed. The rule now counts only the files the project ships, the .ex files under its :prod elixirc_paths: an added function by the mix.exs after the change, a removed one by the mix.exs before it. Query.public_api_diff/1 and public_api_changed?/1 report the same. Source.Diff.public_added/1 and its siblings still cover every changed file.
  • Rules.Elixir :removals_deprecated counted a function removed from any changed file, so deleting def code, do: ... from a test-support module failed it for want of a @deprecated that nobody outside the project could have seen. It now counts only functions removed from the code the project shipped before the change.
  • Rules.Elixir :specs and :moduledoc checked every lib/ directory, whatever the project compiles: in a project with elixirc_paths: ["src"] a new src/ function without a @spec passed. They now check the code the project ships, the same files as :api_changes_logged; assert_specs/2 and assert_moduledoc/2 default to the :shipped pattern instead of Paths.lib(). For the usual project, which compiles lib/ (and test/support only in tests), nothing changes.

0.2.0 - 2026-10-03

Added

  • Rules.Message :body_line_length: every body line that wrapping could bring within the limit is at most max_body_line_length: characters (default 72, counted as graphemes). Trailers are not measured, and neither are lines that start with whitespace or >, Key: value lines with a one-word value, or lines whose last word (a URL, a path) is longer than the limit on its own. The failure names each line by its number in the message, with its length, how far over it is, and the start of its text. The rule skips Revert "…" and Merge … subjects like the other shape rules.
  • Assertions.assert_body_line_length/2 and Message.body_lines/1, the body's lines numbered as they appear in the message.

Changed

  • The default configuration (no .presubmit.exs) runs Rules.Message :body_line_length as well as :no_fixup and :subject_length, so a commit whose body has an unwrapped paragraph now fails. A policy that names Rules.Message without only: gets the rule too; warn: [:body_line_length] reports without failing, and except: [:body_line_length] turns it off.

0.1.1 - 2026-09-29

Added

  • Assertions.assert_subject_length/2: the subject is non-empty and at most max characters.
  • mix presubmit writes its verdict, pass or fail, to the file named by PRESUBMIT_VERDICT once the rules have run. Nothing is written when presubmit stops first (a usage or configuration error, a crash, or Mix failing to load the project), so a caller can tell a failed rule from a presubmit that never ran.

Changed

  • The hooks block a commit only when presubmit ran and a rule failed. When presubmit cannot run (Mix stops on stale or unresolvable dependencies, the configuration does not load, presubmit crashes) they print presubmit could not run: <first error line>; commit allowed, CI still checks and let the commit through, where they used to refuse it. They read the verdict from PRESUBMIT_VERDICT; with presubmit 0.1.0, which writes none, its report stands in. Reinstall with mix presubmit.install to get the new scripts.
  • A subdirectory project's hooks let the commit through, with the same warning, when the project directory is gone.

Fixed

  • Rules.Changelog :release_logged passes a commit that creates mix.exs: starting a project at 0.1.0 is not a release to log.
  • Rules.Mix :lock_in_sync names the right command for each half: mix deps.get locks an added dependency, and mix deps.unlock --unused drops a removed one's entry, which deps.get never does. It used to suggest mix deps.get for both.
  • Rules.Message :subject_length counts characters, not bytes, so an em dash or an accented letter no longer pushes a fitting subject over the limit; the failure says how long the subject is and by how much it is over, instead of quoting a regex.
  • --color colours the report when the output is not a terminal, as documented; it used to defer to IO.ANSI.enabled?/0 and print plain text into a pipe.
  • The commit-msg hook passes the empty tree as --base when amending a root commit under macOS's /bin/sh (bash 3.2), which read the empty-tree case inside $(...) as ending at the first pattern and passed a fragment of the script instead.
  • Rules.Hygiene :no_merge_markers also refuses the ||||||| base marker of a diff3 or zdiff3 conflict, the line most often left behind when a conflict is resolved by deleting the others.

0.1.0 - 2026-09-16

Added

  • mix presubmit: runs the rule sets in .presubmit.exs (or every built-in set by default) against a change set and reports each rule as passed, failed, or skipped. Sources: --head, --rev, --staged, --worktree, --range A..B; the default is the working tree when anything differs from HEAD, else HEAD, and the first line of output always names what was examined. --format json, --list, and a warning when a dirty working tree is examined under CI.
  • mix presubmit.install writes prepare-commit-msg and commit-msg hooks (and, with --pre-commit, a pre-commit hook) that run the policy on the staged changes and the message; when the commit amends HEAD, the amended commit (HEAD^ to the index) is checked rather than the delta. --uninstall removes them; a hook it did not write is never overwritten (Presubmit.Hooks).
  • --base REV measures a --staged or --worktree change set against REV instead of HEAD (Commit.staged/1 and worktree/1 take base:).
  • --message-file PATH / --message TEXT attach a commit message to a --staged or --worktree change set so message rules run from a commit-msg hook; comment and scissors lines are stripped as git does (Message.clean/1, Presubmit.load/1 message:).
  • Detection-aware defaults: rule sets declare requires: (use Presubmit.RuleSet, requires: [{:phoenix, Phoenix.Router}]); without a .presubmit.exs, Presubmit.Config.default/1 enables Rules.Phoenix/Rules.Ecto when the library is loaded in the VM or is a declared or locked dependency of the examined tree, and Rules.Changelog when CHANGELOG.md exists. The output names what was enabled and why (Presubmit.RuleSet.applicable?/2, Presubmit.Config.env/1, JSON config).
  • Query.behaviour_changed?/2 and function_changes/2 take a path pattern; behaviour_changes_tested is scoped to lib/, so a migration or script no longer demands a test change.
  • Rule sets: Presubmit.RuleSet (use + rule/3) and built-in Rules.Elixir, Rules.Phoenix, Rules.Ecto, Rules.OTP, Rules.ExUnit, Rules.Mix, Rules.Changelog, Rules.Message, Rules.Hygiene, Rules.Shape, with only:/except: and per-set options.
  • Change sources: Commit.worktree/1 (via a temporary index, never touching the real one) and Presubmit.load/1 with source: :auto.
  • Structural source layer: Presubmit.Source.Facts (per-file modules, functions, attributes, struct fields, alias-resolved references), Presubmit.Source.Diff (modules and functions added/removed/renamed/changed, behaviour_changed?/1), Presubmit.Source.Index.
  • Library-aware adapters implementing Presubmit.Adapter: PhoenixRouter, PhoenixHandler, EctoSchema, EctoMigration, OtpProcess, ExUnitCase; plus Presubmit.MixFile and Presubmit.Changelog parsers.
  • Assertion verbs raising Presubmit.Violation: library-aware (assert_routed, assert_migrations_ordered, assert_schema_changes_migrated, assert_supervised, assert_tested, assert_lock_in_sync, assert_api_changes_logged, …), Elixir-source (assert_specs, assert_moduledoc, assert_removals_deprecated, assert_pure_move, assert_references), file, line, message, and shape.
  • The facts cache is its own module, Presubmit.Source.Cache, so the concurrent protocol (table creation, owner death, bounding) is isolated and model-checked with Concuerror.
  • Rules run in a plain linked worker instead of a Task; a worker killed from outside is reported as an error on the remaining rules rather than crashing the caller.
  • A rule that exits or throws is reported as an error instead of crashing the run; a result sent by a worker in the instant before a timeout kill is flushed rather than left in the mailbox.
  • The facts cache is keyed by blob and path: facts carry the path, so identical files at different paths must not share an entry (found by CI running tests in a different order).
  • Rule sets document themselves: use Presubmit.RuleSet appends a "Rules" section (id, name, source/severity notes) to the set's @moduledoc.
  • Commits can exempt themselves with Presubmit-Skip: rule, rule or No-Presubmit: true trailers (Presubmit.Rule.exemptions/1); the exemption is announced on every run, unlike --no-verify.
  • Renamed from assert_commit to presubmit (mix presubmit, .presubmit.exs, Presubmit.*), after Chromium's presubmit checks, which this tool resembles far more than an ExUnit assertion library.
  • Warning severity: {Rules.X, warn: [:rule]} (or rule … severity: :warn) reports a violation without failing the run (⚠, JSON status warn); --warnings-as-errors promotes them. The policy rules (specs, removals_deprecated, api_changes_logged, behaviour_changes_tested) are on by default as warnings.
  • Scoping: {Rules.X, in: pattern} restricts a rule set to changes under a path (Commit.restrict/2); a set with no changes in scope is skipped. Together these mirror Chromium presubmit's severities and per-directory checks.
  • Defaults calibrated on real histories: specs, removals_deprecated, api_changes_logged, and the ExUnit rules are opt-in; the defaults are the rules that do not fail an ordinary commit.
  • assert_pure_move also rewrites moved files' paths in non-Elixir files, so moving a file and updating references to its path is a pure move (found on a real history: images moved, docs updated).
  • Performance: facts are cached by blob object id in a shared ETS table (a range parses each file version once); Tree.prefetch/2 bulk-reads paths with one git archive and is used for changed files, discovery, and mix.exs scanning; mix.exs files under test/ are fixtures, not the project. A 300-commit range of the Elixir repository went from 5.5 minutes to about a minute; a single commit takes well under two seconds.
  • Function clause identity expands aliases, so alias A.B; B.f() and A.B.f() are the same body and an alias-only refactor is not a behaviour change; assert_pure_move compares Elixir files through their facts with the rename map applied to resolved module names (Facts.from_source/3 renames:). Property tests cover the change model, the JSON encoder, pure moves, and default-argument arities.
  • Rules run in a worker process with a per-rule timeout (--timeout SECS, default 30); a rule that overruns is stopped and reported as an Presubmit.RuleTimeoutError while the rest still run.
  • Hexdocs: README and CHANGELOG as extras, modules grouped; package description updated. README notes import_deps: [:presubmit] for the formatter and the trust model of .presubmit.exs under --repo.
  • The facts cache lives in the calling process and is cleared between the commits of a --range (Facts.clear_cache/0), so long histories do not accumulate every revision's facts.
  • Rules can declare sources:; Rules.Message :no_fixup applies to committed revisions only, so fixup! commits can be created locally and autosquashed. Subject-shape rules exempt git's own subjects (Revert "…", Merge …) unless exempt: says otherwise.
  • assert_pure_move compares both sides under the rename map: callers, aliases (including short aliases), tests, and docs updated for a module's new name are part of the move; any other change still fails. defdelegate is a public function in the facts and the API diff; functions in @moduledoc false modules are not API.
  • Fewer false positives: assert_routed exempts action_fallback targets and any module a router names (custom route macros, forward); assert_supervised accepts a process that any other lib/ module names (dynamic supervision); EctoSchema.columns/1 skips virtual: true fields and honours source:; assert_migrations_immutable(commit, since: "origin/main") makes only published migrations immutable. Commit records its repo.
  • Hooks step aside with a note while a merge is in progress, when amending a merge commit, and when mix is not on PATH; they pass --on-error warn so a crashed rule cannot block a commit; mix presubmit.install runs from the project directory (subdirectory projects get hooks that cd there) and refuses to install when core.hooksPath is set.
  • --on-error fail|warn controls whether a rule that raises affects the exit status.
  • Umbrella and subdirectory projects: every default directory pattern is anchored at any depth (Presubmit.Paths), dependencies are detected and compared across every mix.exs/mix.lock in the tree (MixFile.all_deps/1, all_locked/1), and a required file such as CHANGELOG.md counts at any project root.
  • The user's git configuration is honoured (safe.directory, credentials, transports); plumbing calls pass the flags that keep their output configuration-independent. Git.config/2; the empty tree is derived from the repository's object format (no /dev/null).
  • Robustness: modules without a literal name (defmodule unquote(name), defmodule __MODULE__.X) are skipped instead of crashing extraction; submodule entries are ignored; files over FileChange.max_diff_bytes/0 (1 MB) are not line-diffed (diffed?: false).
  • Presubmit.Commit.new/1 for synthetic change sets; explicit errors for merge commits and shallow clones.
  • Source.Facts.Function.api?/1: @doc false functions are not API, so public_api_diff/1, assert_api_changes_logged, and assert_removals_deprecated ignore them.
  • Fixture repositories as base trees plus format-patch scenarios under fixtures/.

Removed

  • ExUnit hosting (use Presubmit): rules run through mix presubmit instead, so the source is chosen per invocation and announced in the output.