7.5 KiB · text History b868907
defmodule GitGud.Workflows.PathDispatchTest do
  @moduledoc """
  `paths:` / `paths-ignore:` decide a push by the files it changed.

  Written for bigtrippr, whose Android workflow says `paths: ["android/**"]`
  and ran — and re-published an identical APK under a new versionCode — on
  every server-only push, because the filter was never read.

  On disk, with real commits, because what matters here is the diff the
  dispatcher computes (from the ref's old value, or for a new branch from
  where it left the default branch), not only the matching, which
  `TriggerFiltersTest` covers.
  """
  use GitGud.DataCase, async: false

  alias GitGud.Repositories.Repository
  alias GitGud.Workflows

  import GitGud.ForgeFixtures

  setup do
    {_user, repo} = repository_fixture()
    {:ok, repo: repo}
  end

  defp workflow(filters) do
    """
    name: ci
    on:
      push:
    #{filters}
    jobs:
      build:
        runs-on: self-hosted
        steps:
          - run: echo hello
    """
  end

  # A bare repository built with plumbing: `files` is a map of path =>
  # contents, written as one commit on top of `parents`.
  defp commit!(%Repository{disk_path: path}, files, parents \\ []) do
    index = Path.join(System.tmp_dir!(), "gitidx-#{System.unique_integer([:positive])}")
    env = [{"GIT_INDEX_FILE", index}]

    try do
      for {file, contents} <- files do
        blob = git!(path, ["hash-object", "-w", "--stdin"], contents)
        git!(path, ["update-index", "--add", "--cacheinfo", "100644,#{blob},#{file}"], nil, env)
      end

      tree = git!(path, ["write-tree"], nil, env)
      parent_args = Enum.flat_map(parents, &["-p", &1])
      git!(path, ["commit-tree", tree | parent_args] ++ ["-m", "commit"])
    after
      File.rm(index)
    end
  end

  # Each commit carries the whole tree, so a child is its parent's files
  # plus the changes.
  defp child!(repo, parent_files, changes, parent) do
    commit!(repo, Map.merge(parent_files, changes), [parent])
  end

  defp git!(path, args, input \\ nil, env \\ []) do
    {out, 0} =
      case input do
        nil ->
          System.cmd("git", args, cd: path, stderr_to_stdout: true, env: env)

        _ ->
          file = Path.join(System.tmp_dir!(), "gitin-#{System.unique_integer([:positive])}")
          File.write!(file, input)

          try do
            command = Enum.map_join(["git" | args] ++ ["<", file], " ", &quote_arg/1)
            System.cmd("sh", ["-c", command], cd: path, stderr_to_stdout: true, env: env)
          after
            File.rm(file)
          end
      end

    String.trim(out)
  end

  defp quote_arg("<"), do: "<"
  defp quote_arg(arg), do: "'" <> String.replace(arg, "'", "'\\''") <> "'"

  defp sha!(hex), do: elem(GitGud.Git.from_hex(hex), 1)

  defp push!(repo, ref, base, head) do
    git!(repo.disk_path, ["update-ref", ref, head])

    {:ok, runs} =
      Workflows.dispatch(repo, %{
        trigger: "push",
        head_sha: sha!(head),
        base_sha: base && sha!(base),
        ref: ref
      })

    runs
  end

  # A base commit on `main` (the fixture's default branch) holding the
  # workflow, and a function that pushes one more commit on top of it.
  defp on_main(repo, filters) do
    files = %{".forgejo/workflows/ci.yml" => workflow(filters), "README.md" => "hi\n"}
    base = commit!(repo, files)
    git!(repo.disk_path, ["update-ref", "refs/heads/main", base])
    {files, base}
  end

  describe "paths:" do
    test "skips a push that changes nothing under it, runs one that does", %{repo: repo} do
      {files, base} = on_main(repo, ~s(    paths: ["android/**"]))

      readme = child!(repo, files, %{"README.md" => "changed\n"}, base)
      assert push!(repo, "refs/heads/main", base, readme) == []

      android = child!(repo, files, %{"android/x" => "x\n"}, readme)
      assert [_run] = push!(repo, "refs/heads/main", readme, android)
    end
  end

  describe "paths-ignore:" do
    test "skips a push that only touches ignored files", %{repo: repo} do
      {files, base} = on_main(repo, ~s(    paths-ignore: ["docs/**"]))

      docs = child!(repo, files, %{"docs/a" => "a\n"}, base)
      assert push!(repo, "refs/heads/main", base, docs) == []

      both = child!(repo, files, %{"docs/b" => "b\n", "lib/a" => "a\n"}, docs)
      assert [_run] = push!(repo, "refs/heads/main", docs, both)
    end
  end

  describe "a new branch" do
    test "is compared with where it left the default branch", %{repo: repo} do
      {files, base} = on_main(repo, ~s(    paths: ["lib/**"]))

      topic = child!(repo, files, %{"docs/a" => "a\n"}, base)
      assert push!(repo, "refs/heads/topic", nil, topic) == []

      zero = String.duplicate("0", 40)
      assert push!(repo, "refs/heads/topic", zero, topic) == []
    end

    test "runs when there is no default branch to compare with", %{repo: repo} do
      {files, base} = on_main(repo, ~s(    paths: ["lib/**"]))
      topic = child!(repo, files, %{"docs/a" => "a\n"}, base)
      git!(repo.disk_path, ["update-ref", "-d", "refs/heads/main"])

      assert [_run] = push!(repo, "refs/heads/topic", nil, topic)
    end

    test "runs when it is the default branch itself, pushed new", %{repo: repo} do
      {_files, base} = on_main(repo, ~s(    paths: ["lib/**"]))

      assert [_run] = push!(repo, "refs/heads/main", nil, base)
    end
  end

  test "a tag push ignores path filters", %{repo: repo} do
    {_files, base} =
      on_main(repo, """
          tags: ['v*']
          paths: ["nothing/**"]
      """)

    assert [_run] = push!(repo, "refs/tags/v1.0.0", nil, base)
  end

  test "an unresolvable base fails open", %{repo: repo} do
    {files, base} = on_main(repo, ~s(    paths: ["lib/**"]))
    docs = child!(repo, files, %{"docs/a" => "a\n"}, base)

    bogus = Base.encode16(:crypto.strong_rand_bytes(20), case: :lower)
    assert [_run] = push!(repo, "refs/heads/main", bogus, docs)
  end

  describe "the event payload" do
    test "lists the pushed commits back to the old value, head first", %{repo: repo} do
      {files, base} = on_main(repo, "    branches: [main]")
      one = child!(repo, files, %{"lib/a" => "a\n"}, base)
      two = child!(repo, files, %{"lib/b" => "b\n"}, one)

      assert [run] = push!(repo, "refs/heads/main", base, two)
      payload = run.event_payload

      assert payload["ref"] == "refs/heads/main"
      assert payload["before"] == base
      assert payload["after"] == two
      assert payload["created"] == false
      assert payload["head_commit"]["id"] == two
      assert payload["head_commit"]["message"] =~ "commit"
      # Oldest first, as on GitHub, and the old value itself isn't listed.
      assert Enum.map(payload["commits"], & &1["id"]) == [one, two]
      assert payload["compare"] =~ "/compare?base=#{base}&head=#{two}"
      assert payload["repository"]["name"] == repo.name
    end

    test "a new branch's before is 40 zeros and lists only its own commits", %{repo: repo} do
      {files, base} = on_main(repo, "    branches: [topic]")
      topic = child!(repo, files, %{"lib/a" => "a\n"}, base)

      assert [run] = push!(repo, "refs/heads/topic", nil, topic)
      assert run.event_payload["before"] == String.duplicate("0", 40)
      assert run.event_payload["created"] == true
      assert Enum.map(run.event_payload["commits"], & &1["id"]) == [topic]
    end
  end

  test "a workflow without path filters runs on every push", %{repo: repo} do
    {files, base} = on_main(repo, "    branches: [main]")
    docs = child!(repo, files, %{"docs/a" => "a\n"}, base)

    assert [_run] = push!(repo, "refs/heads/main", base, docs)
  end
end