0.2.2 - 2026-10-08
Added
Message.autosquash/1::fixup,:squashor:amendfor a subject that starts with git'sfixup!,squash!oramend!autosquash prefix,nilotherwise.- Rules take
on_autosquash: :checkto run on autosquash commits, which every other rule now skips.
Changed
- Every rule but
Rules.Message:no_fixupskips afixup!,squash!oramend!commit, whichgit rebase --autosquashfolds into an earlier commit, so its own message and diff never land. Thecommit-msghook used to refusegit commit --fixupunder anysubject:pattern, sincefixup! [tag] textdoes not start with[tag]. The report opens withfixup! commit — rules skipped until it is squashed into its target.:no_fixupstill fails such a commit on--head,--revand--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:prodenvironment, the one it is compiled in as a dependency, read frommix.exswithout evaluating it. It follows the usualelixirc_paths(Mix.env())helper, so for a project whosemix.exshasdefp elixirc_paths(:test), do: ["lib", "test/support"]anddefp 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.exfiles every project in the tree ships, joined to each project's directory (Paths.lib_source/0, the.exfiles under anylib/, when the tree has nomix.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:shippedas their pattern: the filesMixFile.shipped_source/1matches, read from the tree before the change for removals and from the tree after it for everything else.
Fixed
Rules.Changelog:api_changes_loggedcounted a public function in any changed.exor.exsfile as public API, test support and fixtures included. In a project whosemix.exscompilestest/supportonly underMIX_ENV=test, addingdef calls(module)toMyApp.Test.CallCountintest/support/call_count.exwarnedMyApp.Test.CallCount.calls/1 addeduntil the changelog changed. The rule now counts only the files the project ships, the.exfiles under its:prodelixirc_paths: an added function by themix.exsafter the change, a removed one by themix.exsbefore it.Query.public_api_diff/1andpublic_api_changed?/1report the same.Source.Diff.public_added/1and its siblings still cover every changed file.Rules.Elixir:removals_deprecatedcounted a function removed from any changed file, so deletingdef code, do: ...from a test-support module failed it for want of a@deprecatedthat nobody outside the project could have seen. It now counts only functions removed from the code the project shipped before the change.Rules.Elixir:specsand:moduledocchecked everylib/directory, whatever the project compiles: in a project withelixirc_paths: ["src"]a newsrc/function without a@specpassed. They now check the code the project ships, the same files as:api_changes_logged;assert_specs/2andassert_moduledoc/2default to the:shippedpattern instead ofPaths.lib(). For the usual project, which compileslib/(andtest/supportonly 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 mostmax_body_line_length:characters (default 72, counted as graphemes). Trailers are not measured, and neither are lines that start with whitespace or>,Key: valuelines 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 skipsRevert "…"andMerge …subjects like the other shape rules.Assertions.assert_body_line_length/2andMessage.body_lines/1, the body's lines numbered as they appear in the message.
Changed
- The default configuration (no
.presubmit.exs) runsRules.Message:body_line_lengthas well as:no_fixupand:subject_length, so a commit whose body has an unwrapped paragraph now fails. A policy that namesRules.Messagewithoutonly:gets the rule too;warn: [:body_line_length]reports without failing, andexcept: [: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 mostmaxcharacters.mix presubmitwrites its verdict,passorfail, to the file named byPRESUBMIT_VERDICTonce 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 checksand let the commit through, where they used to refuse it. They read the verdict fromPRESUBMIT_VERDICT; with presubmit 0.1.0, which writes none, its report stands in. Reinstall withmix presubmit.installto 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_loggedpasses a commit that createsmix.exs: starting a project at 0.1.0 is not a release to log.Rules.Mix:lock_in_syncnames the right command for each half:mix deps.getlocks an added dependency, andmix deps.unlock --unuseddrops a removed one's entry, whichdeps.getnever does. It used to suggestmix deps.getfor both.Rules.Message:subject_lengthcounts 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.--colorcolours the report when the output is not a terminal, as documented; it used to defer toIO.ANSI.enabled?/0and print plain text into a pipe.- The
commit-msghook passes the empty tree as--basewhen amending a root commit under macOS's/bin/sh(bash 3.2), which read the empty-treecaseinside$(...)as ending at the first pattern and passed a fragment of the script instead. Rules.Hygiene:no_merge_markersalso 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 fromHEAD, elseHEAD, and the first line of output always names what was examined.--format json,--list, and a warning when a dirty working tree is examined underCI.mix presubmit.installwritesprepare-commit-msgandcommit-msghooks (and, with--pre-commit, apre-commithook) that run the policy on the staged changes and the message; when the commit amendsHEAD, the amended commit (HEAD^to the index) is checked rather than the delta.--uninstallremoves them; a hook it did not write is never overwritten (Presubmit.Hooks).--base REVmeasures a--stagedor--worktreechange set againstREVinstead ofHEAD(Commit.staged/1andworktree/1takebase:).--message-file PATH/--message TEXTattach a commit message to a--stagedor--worktreechange set so message rules run from acommit-msghook; comment and scissors lines are stripped as git does (Message.clean/1,Presubmit.load/1message:).- Detection-aware defaults: rule sets declare
requires:(use Presubmit.RuleSet, requires: [{:phoenix, Phoenix.Router}]); without a.presubmit.exs,Presubmit.Config.default/1enablesRules.Phoenix/Rules.Ectowhen the library is loaded in the VM or is a declared or locked dependency of the examined tree, andRules.ChangelogwhenCHANGELOG.mdexists. The output names what was enabled and why (Presubmit.RuleSet.applicable?/2,Presubmit.Config.env/1, JSONconfig). Query.behaviour_changed?/2andfunction_changes/2take a path pattern;behaviour_changes_testedis scoped tolib/, so a migration or script no longer demands a test change.- Rule sets:
Presubmit.RuleSet(use+rule/3) and built-inRules.Elixir,Rules.Phoenix,Rules.Ecto,Rules.OTP,Rules.ExUnit,Rules.Mix,Rules.Changelog,Rules.Message,Rules.Hygiene,Rules.Shape, withonly:/except:and per-set options. - Change sources:
Commit.worktree/1(via a temporary index, never touching the real one) andPresubmit.load/1withsource: :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; plusPresubmit.MixFileandPresubmit.Changelogparsers. - 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.RuleSetappends a "Rules" section (id, name, source/severity notes) to the set's@moduledoc. - Commits can exempt themselves with
Presubmit-Skip: rule, ruleorNo-Presubmit: truetrailers (Presubmit.Rule.exemptions/1); the exemption is announced on every run, unlike--no-verify. - Renamed from
assert_committopresubmit(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]}(orrule … severity: :warn) reports a violation without failing the run (⚠, JSON statuswarn);--warnings-as-errorspromotes 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 theExUnitrules are opt-in; the defaults are the rules that do not fail an ordinary commit. assert_pure_movealso 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/2bulk-reads paths with onegit archiveand is used for changed files, discovery, andmix.exsscanning;mix.exsfiles undertest/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()andA.B.f()are the same body and an alias-only refactor is not a behaviour change;assert_pure_movecompares Elixir files through their facts with the rename map applied to resolved module names (Facts.from_source/3renames:). 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 anPresubmit.RuleTimeoutErrorwhile 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.exsunder--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_fixupapplies to committed revisions only, sofixup!commits can be created locally and autosquashed. Subject-shape rules exempt git's own subjects (Revert "…",Merge …) unlessexempt:says otherwise. assert_pure_movecompares 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.defdelegateis a public function in the facts and the API diff; functions in@moduledoc falsemodules are not API.- Fewer false positives:
assert_routedexemptsaction_fallbacktargets and any module a router names (custom route macros,forward);assert_supervisedaccepts a process that any otherlib/module names (dynamic supervision);EctoSchema.columns/1skipsvirtual: truefields and honourssource:;assert_migrations_immutable(commit, since: "origin/main")makes only published migrations immutable.Commitrecords itsrepo. - Hooks step aside with a note while a merge is in progress, when amending a merge commit, and when
mixis not onPATH; they pass--on-error warnso a crashed rule cannot block a commit;mix presubmit.installruns from the project directory (subdirectory projects get hooks thatcdthere) and refuses to install whencore.hooksPathis set. --on-error fail|warncontrols 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 everymix.exs/mix.lockin the tree (MixFile.all_deps/1,all_locked/1), and a required file such asCHANGELOG.mdcounts 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 overFileChange.max_diff_bytes/0(1 MB) are not line-diffed (diffed?: false). Presubmit.Commit.new/1for synthetic change sets; explicit errors for merge commits and shallow clones.Source.Facts.Function.api?/1:@doc falsefunctions are not API, sopublic_api_diff/1,assert_api_changes_logged, andassert_removals_deprecatedignore them.- Fixture repositories as base trees plus
format-patchscenarios underfixtures/.
Removed
- ExUnit hosting (
use Presubmit): rules run throughmix presubmitinstead, so the source is chosen per invocation and announced in the output.