Skip to content

PostToolUse hook runs mix format with no path, reformatting the whole project on every single-file edit #770

Description

@timujinne

.claude/settings.json wires a PostToolUse hook on matcher Write|Edit to bare mix format. With no argument mix format formats everything matched by :inputs in .formatter.exs — the whole project, not the file that was edited.

Editing one file therefore rewrites every other file in the tree that differs from the formatter's canonical form, and in git status that collateral change is indistinguishable from the intended one. A reviewer told to change nothing becomes a writing agent without knowing it.

Observed across seven independent working trees here: each carried a byte-identical stray diff in test/mix/tasks/phoenix_kit_doctor_test.exs that no agent had made (md5 of the diff identical in all seven), and three agents independently reported that file as a change they had not intended.

Reproducing it needs a built tree

On a cold tree (deps not fetched) the hook does not corrupt anything silently — it fails loudly:

(Mix) Unknown dependency :ecto_sql given to :import_deps ...
Unchecked dependencies for environment dev

The silent rewriting only happens on a warm tree, which is where real work happens. A fresh clone shows the noisy failure, not the defect.

exclude: in .formatter.exs is a dead key

.formatter.exs carries exclude: ["priv/templates/**/*.*"] under a comment saying it excludes the EEx-bearing template files. Mix.Tasks.Format never reads :exclude — the key appears nowhere in Elixir 1.18.4's lib/mix/lib/mix/tasks/format.ex (verified against the installed source, zero occurrences).

Those templates are safe today by accident: priv/templates matches none of the :inputs patterns. The protection that is written down does not exist; the one that works was not intended as one.

This matters for any fix, because an explicit mix format <path> bypasses :inputs and :exclude entirely. A hook that simply forwards the edited path would format a template the first time one is edited — a worse regression than the original defect.

A fix here is two files, and git add -A takes only one

.gitignore line 22 is /.claude. A new file under .claude/ is invisible to git status and is not picked up by git add -Agit check-ignore -v answers .gitignore:22:/.claude for a new path there. The two files currently tracked under .claude/ were force-added.

So a fix that adds a hook script and edits settings.json will, applied normally, commit only the settings change, leaving the hook pointing at a script that does not exist and failing on every edit. The script needs git add -f.

The fix we are running, and the conditions it depends on

The PostToolUse command points at a script that resolves the edited path and formats only that file. Three conditions in it are load-bearing and should not be removed:

  1. The path is parsed with python3, not jq. jq is not guaranteed to be present; block-dangerous-git.sh already depends on it, with the failure mode described in the separate issue.

  2. priv/templates is skipped explicitly. Because an explicit path argument bypasses :inputs/:exclude, the dead config key provides nothing here; the skip has to live in the hook.

  3. The file is checked to be inside $CLAUDE_PROJECT_DIR. An explicit path argument also removes the repo-relative character of :inputs, so without this check the hook can rewrite any .ex/.exs/.heex anywhere on disk using this project's formatter settings — a capability bare mix format never had. Containment is os.path.commonpath over os.path.realpath of both sides, not a string-prefix comparison: realpath canonicalizes .., relative segments and symlinks, and commonpath compares path components, so a sibling directory that merely shares a name prefix does not pass.

    Consequence worth knowing: if $CLAUDE_PROJECT_DIR is absent the hook formats nothing at all rather than falling back to whole-project behaviour.

mix compile, the second command in the same hook, is unchanged: it is already incremental (a no-op run on a fully built tree recompiled zero modules in 4.7s) and Mix has no per-file compile mode, so there is nothing to narrow.

Verified in both directions on the live hook

  • a file inside the project is formatted — tested with absolute and relative paths;
  • an unrelated file inside the project is untouched (hash unchanged);
  • a file outside the project is untouched;
  • a sibling directory whose name shares the project's prefix is untouched;
  • a symlink inside the project pointing outside leaves its target untouched;
  • priv/templates is untouched.

The reverse direction matters as much as the forward one: a hook that quietly stopped formatting would pass "no collateral changes" perfectly.

We can send this as a PR if you would like it — noting the git add -f requirement above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions