defmodule Mix.Tasks.Verify.WorkspaceClean do @moduledoc """ Verifies that `git status` is clean for all pathspecs that ship in the Hex tarball plus `test/**`. Runs as the first step of every publish path (canonical release-please flow, manual-recovery workflow, and per-push CI) so a release cannot ship files that were not reviewed and merged. This gate exists because an earlier release cycle shipped a partial tarball when uncommitted files did not travel to the release tag. See `docs/releasing.md` § Historical context for why parity and workspace gates matter on publish paths. ## Usage mix verify.workspace_clean The task takes no arguments. There is deliberately no escape-hatch flag or environment variable — the friction is the feature. Real emergencies require commenting out the workflow step in a PR. """ @shortdoc "Fails if the working tree has uncommitted changes in packaged paths" use Mix.Task @impl true def run(args) do Mix.Task.run("app.start") ensure_no_args!(args) pathspecs = build_pathspecs() Mix.shell().info("==> Checking workspace cleanliness for packaged paths") {output, exit_status} = System.cmd("git", ["status", "--porcelain", "--" | pathspecs], stderr_to_stdout: true) case classify(output, exit_status, length(pathspecs)) do {:ok, message} -> Mix.shell().info(message) :ok {:dirty, dirty_output} -> raise_dirty!(dirty_output) {:git_error, err} -> Mix.raise("git status failed:\n\n#{err}") end end @doc """ Pure classifier for `git status --porcelain` output. Split out of `run/1` so tests can exercise each branch without a real git subprocess (mirrors the `Mix.Tasks.Verify.ReleaseParity.compute/2` seam). Returns one of: - `{:ok, message}` — clean tree (empty output, exit 0) - `{:dirty, paths}` — uncommitted or untracked files present (non-empty output, exit 0) - `{:git_error, err}` — `git status` itself failed (non-zero exit) """ @spec classify(String.t(), integer(), non_neg_integer()) :: {:ok, String.t()} | {:dirty, String.t()} | {:git_error, String.t()} def classify("", 0, pathspec_count) do {:ok, "Workspace clean across #{pathspec_count} pathspecs"} end def classify(dirty_output, 0, _pathspec_count) when dirty_output != "" do {:dirty, dirty_output} end def classify(err, exit_status, _pathspec_count) when exit_status != 0 do {:git_error, err} end @doc """ Returns the pathspec list used by `run/1` — derived from `mix.exs` `package.files` plus `"test"`. Exposed publicly for testability. """ @spec build_pathspecs() :: [String.t()] def build_pathspecs do project = Mix.Project.config() package_files = project |> Keyword.get(:package, []) |> Keyword.get(:files, []) # test/** included even though not packaged — uncommitted tests # mean "the lib/ state being published was not tested as it will ship." package_files ++ ["test"] end defp ensure_no_args!([]), do: :ok defp ensure_no_args!(args) do Mix.raise("verify.workspace_clean does not accept arguments, got: #{Enum.join(args, " ")}") end @spec raise_dirty!(String.t()) :: no_return() defp raise_dirty!(output) do Mix.raise(""" Workspace is not clean. Uncommitted or untracked files exist in packaged paths: #{output} Resolve with: git add # stage git stash -u # shelve uncommitted changes + untracked git checkout -- # discard working-tree changes This gate exists because an earlier release cycle shipped a partial tarball when uncommitted files did not travel to the release tag. See docs/releasing.md § Historical context for background. """) end end