Skip to main content

lib/mix/tasks/npm.diff.ex

defmodule Mix.Tasks.Npm.Diff do
  @shortdoc "Show changes between lockfile versions"

  @moduledoc """
  Show what changed since the last `npm.lock` was committed.

      mix npm.diff

  Compares the current `npm.lock` with the git HEAD version and
  shows added, removed, and updated packages.
  """

  use Mix.Task

  @impl true
  def run([]) do
    Application.ensure_all_started(:req)

    case read_git_lockfile() do
      {:ok, old_lockfile} ->
        case NPM.Lockfile.read() do
          {:ok, new_lockfile} ->
            print_diff(old_lockfile, new_lockfile)

          {:error, _} ->
            Mix.shell().error("Cannot read npm.lock")
        end

      {:error, :not_committed} ->
        Mix.shell().info("npm.lock is not tracked by git.")
    end
  end

  def run(_) do
    Mix.shell().error("Usage: mix npm.diff")
  end

  defp read_git_lockfile do
    case System.cmd("git", ["show", "HEAD:npm.lock"], stderr_to_stdout: true) do
      {content, 0} ->
        data = NPM.JSON.decode!(content)
        lockfile = NPM.Lockfile.parse_packages(Map.get(data, "packages", %{}))
        {:ok, lockfile}

      {_, _} ->
        {:error, :not_committed}
    end
  end

  defp print_diff(old, new) when old == new do
    Mix.shell().info("No changes in npm.lock")
  end

  defp print_diff(old, new) do
    added = Map.keys(new) -- Map.keys(old)
    removed = Map.keys(old) -- Map.keys(new)

    updated =
      for key <- Map.keys(new),
          Map.has_key?(old, key),
          old[key].version != new[key].version,
          do: key

    if added == [] and removed == [] and updated == [] do
      Mix.shell().info("No version changes in npm.lock")
    else
      Enum.each(Enum.sort(added), &Mix.shell().info("+ #{&1}@#{new[&1].version}"))
      Enum.each(Enum.sort(removed), &Mix.shell().info("- #{&1}@#{old[&1].version}"))

      Enum.each(Enum.sort(updated), fn key ->
        Mix.shell().info("↑ #{key} #{old[key].version}#{new[key].version}")
      end)
    end
  end
end